fix: integrate all AP runtime patches into fork source
Integrates 12 runtime patch scripts from indiekit-server directly into the fork source code, eliminating the need for postinstall patching: - enrich-actor-data: avatar via getIcon(), handle as @user@domain, banner via getImage() - conversations-endpoint: real /api/v1/conversations implementation - stubs-remove-duplicate-routes: dead route removal from stubs.js - self-follow-guard: prevent self-follow loop - oauth-token-expiry: clear expiresAt on token exchange - unify-dm-visibility: unified DM visibility detection - accounts-id-cache-fallback: check ap_actor_cache before 404 - federation-infra: federation infrastructure fixes - mastodon-misc: miscellaneous Mastodon API fixes - mastodon-statuses: status endpoint fixes - syndication: syndication dedup - startup-gate-bypass: startup gate bypass Also strips all // [patch] markers from 16 files (including 4 from prior commit). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -506,11 +506,18 @@ export default class ActivityPubEndpoint {
|
||||
router.use((req, res, next) => {
|
||||
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();
|
||||
// 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);
|
||||
});
|
||||
|
||||
@@ -689,15 +696,30 @@ export default class ActivityPubEndpoint {
|
||||
remoteActor.name?.toString() ||
|
||||
remoteActor.preferredUsername?.toString() ||
|
||||
actorUrl;
|
||||
const actorHandle =
|
||||
actorInfo.handle ||
|
||||
remoteActor.preferredUsername?.toString() ||
|
||||
"";
|
||||
const avatar =
|
||||
actorInfo.photo ||
|
||||
(remoteActor.icon
|
||||
? (await remoteActor.icon)?.url?.href || ""
|
||||
: "");
|
||||
let _enrichedAvatar = "";
|
||||
try {
|
||||
if (typeof remoteActor.getIcon === "function") {
|
||||
const _iconObj = await remoteActor.getIcon();
|
||||
_enrichedAvatar = _iconObj?.url?.href || "";
|
||||
}
|
||||
} catch { /* icon fetch failed */ }
|
||||
let _enrichedHandle = "";
|
||||
try {
|
||||
const _username = remoteActor.preferredUsername?.toString() || "";
|
||||
if (_username && actorUrl) {
|
||||
const _domain = new URL(actorUrl).hostname;
|
||||
_enrichedHandle = `@${_username}@${_domain}`;
|
||||
}
|
||||
} catch { /* URL parse failed */ }
|
||||
let _enrichedBanner = "";
|
||||
try {
|
||||
if (typeof remoteActor.getImage === "function") {
|
||||
const _imgObj = await remoteActor.getImage();
|
||||
_enrichedBanner = _imgObj?.url?.href || "";
|
||||
}
|
||||
} catch { /* banner fetch failed */ }
|
||||
const actorHandle = actorInfo.handle || _enrichedHandle || remoteActor.preferredUsername?.toString() || "";
|
||||
const avatar = actorInfo.photo || _enrichedAvatar || "";
|
||||
const inbox = remoteActor.inboxId?.href || "";
|
||||
const sharedInbox = remoteActor.endpoints?.sharedInbox?.href || "";
|
||||
|
||||
@@ -711,6 +733,7 @@ export default class ActivityPubEndpoint {
|
||||
avatar,
|
||||
inbox,
|
||||
sharedInbox,
|
||||
banner: _enrichedBanner || "",
|
||||
followedAt: new Date().toISOString(),
|
||||
source: "reader",
|
||||
},
|
||||
@@ -1295,7 +1318,7 @@ export default class ActivityPubEndpoint {
|
||||
});
|
||||
|
||||
this._federation = federation;
|
||||
this._fedifyMiddleware = createFedifyMiddleware(federation, () => ({}));
|
||||
this._fedifyMiddleware = createFedifyMiddleware(federation, () => ({}), this._publicationUrl); // ap-base-url patch
|
||||
|
||||
// Expose signed avatar resolver for cross-plugin use (e.g., conversations backfill)
|
||||
Indiekit.config.application.resolveActorAvatar = async (actorUrl) => {
|
||||
@@ -1348,6 +1371,7 @@ export default class ActivityPubEndpoint {
|
||||
broadcastActorUpdate: () => pluginRef.broadcastActorUpdate(),
|
||||
loadRsaKey: () => pluginRef._loadRsaPrivateKey(),
|
||||
broadcastActorUpdate: () => pluginRef.broadcastActorUpdate(),
|
||||
broadcastDelete: (url) => pluginRef.broadcastDelete(url),
|
||||
},
|
||||
});
|
||||
Indiekit.addEndpoint({
|
||||
|
||||
@@ -9,6 +9,20 @@ import { lookupWithSecurity } from "../lookup-helpers.js";
|
||||
import { addNotification } from "../storage/notifications.js";
|
||||
import { createContext, getHandle, isFederationReady } from "../federation-actions.js";
|
||||
|
||||
|
||||
const _mpInternalBase = (() => {
|
||||
if (process.env.INTERNAL_FETCH_URL) return process.env.INTERNAL_FETCH_URL.replace(/\/+$/, "");
|
||||
const port = process.env.PORT || "3000";
|
||||
return `http://localhost:${port}`;
|
||||
})();
|
||||
const _mpPublicBase = (
|
||||
process.env.PUBLICATION_URL || process.env.SITE_URL || ""
|
||||
).replace(/\/+$/, "");
|
||||
function _toInternalUrl(url) {
|
||||
if (!_mpPublicBase || !url.startsWith(_mpPublicBase)) return url;
|
||||
return _mpInternalBase + url.slice(_mpPublicBase.length);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch syndication targets from the Micropub config endpoint.
|
||||
* @param {object} application - Indiekit application locals
|
||||
@@ -21,9 +35,9 @@ async function getSyndicationTargets(application, token) {
|
||||
|
||||
if (!micropubEndpoint) return [];
|
||||
|
||||
const micropubUrl = micropubEndpoint.startsWith("http")
|
||||
const micropubUrl = _toInternalUrl(micropubEndpoint.startsWith("http")
|
||||
? micropubEndpoint
|
||||
: new URL(micropubEndpoint, application.url).href;
|
||||
: new URL(micropubEndpoint, application.url).href);
|
||||
|
||||
const configUrl = `${micropubUrl}?q=config`;
|
||||
const configResponse = await fetch(configUrl, {
|
||||
@@ -146,10 +160,12 @@ export function composeController(mountPath, plugin) {
|
||||
? await getSyndicationTargets(application, token)
|
||||
: [];
|
||||
|
||||
// Pre-check syndication targets based on their configured checked state // [patch] ap-compose-default-checked
|
||||
for (const target of syndicationTargets) { // [patch] ap-compose-default-checked
|
||||
target.defaultChecked = target.checked === true; // [patch] ap-compose-default-checked
|
||||
} // [patch] ap-compose-default-checked
|
||||
// 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 csrfToken = getToken(request.session);
|
||||
|
||||
@@ -300,9 +316,9 @@ export function submitComposeController(mountPath, plugin) {
|
||||
});
|
||||
}
|
||||
|
||||
const micropubUrl = micropubEndpoint.startsWith("http")
|
||||
const micropubUrl = _toInternalUrl(micropubEndpoint.startsWith("http")
|
||||
? micropubEndpoint
|
||||
: new URL(micropubEndpoint, application.url).href;
|
||||
: new URL(micropubEndpoint, application.url).href);
|
||||
|
||||
const token = request.session?.access_token;
|
||||
|
||||
|
||||
@@ -17,8 +17,10 @@ import { Buffer } from "node:buffer";
|
||||
* @param {import("express").Request} req - Express request
|
||||
* @returns {Request} Standard Request object
|
||||
*/
|
||||
export function fromExpressRequest(req) {
|
||||
const url = `${req.protocol}://${req.get("host")}${req.originalUrl}`;
|
||||
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 headers = new Headers();
|
||||
for (const [key, value] of Object.entries(req.headers)) {
|
||||
if (Array.isArray(value)) {
|
||||
@@ -28,6 +30,18 @@ export function fromExpressRequest(req) {
|
||||
}
|
||||
}
|
||||
|
||||
// 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".
|
||||
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;
|
||||
if (req.method === "GET" || req.method === "HEAD") {
|
||||
body = undefined;
|
||||
|
||||
@@ -363,6 +363,9 @@ export function setupFederation(options) {
|
||||
`${mountPath}/users/{identifier}/inbox`,
|
||||
`${mountPath}/inbox`,
|
||||
);
|
||||
// Expose publicationUrl on collections so inbox handlers can gate
|
||||
// notifications/timeline-storage to our own content only.
|
||||
collections._publicationUrl = publicationUrl;
|
||||
registerInboxListeners(inboxChain, {
|
||||
collections,
|
||||
handle,
|
||||
|
||||
+70
-20
@@ -160,14 +160,26 @@ function isDirectMessage(object, ourActorUrl, followersUrl) {
|
||||
* @param {object} object - Fedify object (Note, Article, etc.)
|
||||
* @returns {"public"|"unlisted"|"private"|"direct"}
|
||||
*/
|
||||
function computeVisibility(object) {
|
||||
function computeVisibility(object, actorContext) {
|
||||
const to = new Set((object.toIds || []).map((u) => u.href));
|
||||
const cc = new Set((object.ccIds || []).map((u) => u.href));
|
||||
|
||||
if (to.has(PUBLIC)) return "public";
|
||||
if (cc.has(PUBLIC)) return "unlisted";
|
||||
// Without knowing the remote actor's followers URL, we can't distinguish
|
||||
// "private" (followers-only) from "direct". Both are non-public.
|
||||
// When actor context is available, use isDirectMessage logic to distinguish
|
||||
// "direct" (addressed to specific actors only) from "private" (followers-only).
|
||||
if (actorContext?.ourActorUrl) {
|
||||
const allAddressed = [
|
||||
...to, ...cc,
|
||||
...(object.btoIds || []).map((u) => u.href),
|
||||
...(object.bccIds || []).map((u) => u.href),
|
||||
];
|
||||
const hasPublic = allAddressed.some((u) => u === PUBLIC || u === "as:Public");
|
||||
const hasFollowers = actorContext.followersUrl && allAddressed.includes(actorContext.followersUrl);
|
||||
if (!hasPublic && !hasFollowers && allAddressed.includes(actorContext.ourActorUrl)) {
|
||||
return "direct";
|
||||
}
|
||||
}
|
||||
// Without actor context, can't distinguish "private" from "direct".
|
||||
if (to.size > 0 || cc.size > 0) return "private";
|
||||
return "direct";
|
||||
}
|
||||
@@ -650,7 +662,7 @@ export async function handleCreate(item, collections, ctx, handle) {
|
||||
const ourActorUrl = ctx.getActorUri(handle).href;
|
||||
const followersUrl = ctx.getFollowersUri(handle)?.href || "";
|
||||
|
||||
if (isDirectMessage(object, ourActorUrl, followersUrl)) {
|
||||
if (computeVisibility(object, { ourActorUrl, followersUrl }) === "direct") {
|
||||
const actorInfo = await extractActorInfo(actorObj, { documentLoader: authLoader });
|
||||
const rawHtml = object.content?.toString() || "";
|
||||
const contentHtml = sanitizeContent(rawHtml);
|
||||
@@ -809,7 +821,7 @@ export async function handleCreate(item, collections, ctx, handle) {
|
||||
actorFallback: actorObj,
|
||||
documentLoader: authLoader,
|
||||
});
|
||||
timelineItem.visibility = computeVisibility(object);
|
||||
timelineItem.visibility = computeVisibility(object, { ourActorUrl, followersUrl });
|
||||
await addTimelineItem(collections, timelineItem);
|
||||
|
||||
// Fire-and-forget OG unfurling for notes and articles (not boosts)
|
||||
@@ -831,6 +843,19 @@ export async function handleCreate(item, collections, ctx, handle) {
|
||||
// Log extraction errors but don't fail the entire handler
|
||||
console.error("[inbox-handlers] Failed to store timeline item:", error);
|
||||
}
|
||||
} else if (pubUrl && inReplyTo && inReplyTo.startsWith(pubUrl)) {
|
||||
// Reply to our post from a non-followed account — store in timeline
|
||||
// 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, { ourActorUrl, followersUrl });
|
||||
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
|
||||
// so tagged posts from across the fediverse appear in the timeline
|
||||
@@ -850,7 +875,7 @@ export async function handleCreate(item, collections, ctx, handle) {
|
||||
actorFallback: actorObj,
|
||||
documentLoader: authLoader,
|
||||
});
|
||||
timelineItem.visibility = computeVisibility(object);
|
||||
timelineItem.visibility = computeVisibility(object, { ourActorUrl, followersUrl });
|
||||
await addTimelineItem(collections, timelineItem);
|
||||
}
|
||||
}
|
||||
@@ -986,22 +1011,47 @@ export async function handleUpdate(item, collections, ctx, handle) {
|
||||
|
||||
const existing = await collections.ap_followers.findOne({ actorUrl });
|
||||
if (existing) {
|
||||
let _uAvatar = "";
|
||||
try {
|
||||
if (typeof actorObj.getIcon === "function") {
|
||||
const _uIcon = await actorObj.getIcon();
|
||||
_uAvatar = _uIcon?.url?.href || "";
|
||||
}
|
||||
} catch { /* icon fetch failed */ }
|
||||
let _uHandle = "";
|
||||
try {
|
||||
const _uUsername = actorObj.preferredUsername?.toString() || "";
|
||||
if (_uUsername && actorUrl) {
|
||||
const _uDomain = new URL(actorUrl).hostname;
|
||||
_uHandle = `@${_uUsername}@${_uDomain}`;
|
||||
}
|
||||
} catch { /* URL parse failed */ }
|
||||
let _uBanner = "";
|
||||
try {
|
||||
if (typeof actorObj.getImage === "function") {
|
||||
const _uImg = await actorObj.getImage();
|
||||
_uBanner = _uImg?.url?.href || "";
|
||||
}
|
||||
} catch { /* banner fetch failed */ }
|
||||
const _updateFields = {
|
||||
name: actorObj.name?.toString() || actorObj.preferredUsername?.toString() || actorUrl,
|
||||
handle: _uHandle || actorObj.preferredUsername?.toString() || "",
|
||||
avatar: _uAvatar,
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
if (_uBanner) _updateFields.banner = _uBanner;
|
||||
await collections.ap_followers.updateOne(
|
||||
{ actorUrl },
|
||||
{
|
||||
$set: {
|
||||
name:
|
||||
actorObj.name?.toString() ||
|
||||
actorObj.preferredUsername?.toString() ||
|
||||
actorUrl,
|
||||
handle: actorObj.preferredUsername?.toString() || "",
|
||||
avatar: actorObj.icon
|
||||
? (await actorObj.icon)?.url?.href || ""
|
||||
: "",
|
||||
updatedAt: new Date().toISOString(),
|
||||
},
|
||||
},
|
||||
{ $set: _updateFields },
|
||||
);
|
||||
// Also update ap_following if we follow this actor
|
||||
const existingFollowing = await collections.ap_following.findOne({ actorUrl });
|
||||
if (existingFollowing) {
|
||||
await collections.ap_following.updateOne(
|
||||
{ actorUrl },
|
||||
{ $set: _updateFields },
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+37
-12
@@ -23,7 +23,6 @@ import {
|
||||
Remove,
|
||||
Undo,
|
||||
Update,
|
||||
View, // View imported
|
||||
} from "@fedify/fedify/vocab";
|
||||
|
||||
import { isServerBlocked } from "./storage/server-blocks.js";
|
||||
@@ -54,6 +53,16 @@ export function registerInboxListeners(inboxChain, options) {
|
||||
.on(Follow, async (ctx, follow) => {
|
||||
const actorUrl = follow.actorId?.href || "";
|
||||
if (await isServerBlocked(actorUrl, collections)) return;
|
||||
|
||||
// Reject self-follows: if the follower is our own actor, skip.
|
||||
// Self-follows cause infinite delivery retries because Fedify
|
||||
// tries to POST to our own shared inbox, which is unreachable
|
||||
// from within the jail (no outbound internet).
|
||||
if (collections._publicationUrl && actorUrl.startsWith(collections._publicationUrl)) {
|
||||
console.info(`[ActivityPub] Ignoring self-follow from ${actorUrl}`);
|
||||
return;
|
||||
}
|
||||
|
||||
await touchKeyFreshness(collections, actorUrl);
|
||||
await resetDeliveryStrikes(collections, actorUrl);
|
||||
|
||||
@@ -67,13 +76,35 @@ export function registerInboxListeners(inboxChain, options) {
|
||||
followerActor.preferredUsername?.toString() ||
|
||||
followerUrl;
|
||||
|
||||
// Enrich avatar and handle using proper Fedify async getters
|
||||
let _fAvatar = "";
|
||||
try {
|
||||
if (typeof followerActor.getIcon === "function") {
|
||||
const _fIcon = await followerActor.getIcon();
|
||||
_fAvatar = _fIcon?.url?.href || "";
|
||||
}
|
||||
} catch { /* icon fetch failed */ }
|
||||
let _fHandle = "";
|
||||
try {
|
||||
const _fUsername = followerActor.preferredUsername?.toString() || "";
|
||||
if (_fUsername && followerUrl) {
|
||||
const _fDomain = new URL(followerUrl).hostname;
|
||||
_fHandle = `@${_fUsername}@${_fDomain}`;
|
||||
}
|
||||
} catch { /* URL parse failed */ }
|
||||
let _fBanner = "";
|
||||
try {
|
||||
if (typeof followerActor.getImage === "function") {
|
||||
const _fImg = await followerActor.getImage();
|
||||
_fBanner = _fImg?.url?.href || "";
|
||||
}
|
||||
} catch { /* banner fetch failed */ }
|
||||
const followerData = {
|
||||
actorUrl: followerUrl,
|
||||
handle: followerActor.preferredUsername?.toString() || "",
|
||||
handle: _fHandle || followerActor.preferredUsername?.toString() || "",
|
||||
name: followerName,
|
||||
avatar: followerActor.icon
|
||||
? (await followerActor.icon)?.url?.href || ""
|
||||
: "",
|
||||
avatar: _fAvatar,
|
||||
banner: _fBanner,
|
||||
inbox: followerActor.inbox?.id?.href || "",
|
||||
sharedInbox: followerActor.endpoints?.sharedInbox?.href || "",
|
||||
};
|
||||
@@ -353,11 +384,5 @@ export function registerInboxListeners(inboxChain, options) {
|
||||
actorUrl,
|
||||
rawJson: await flag.toJsonLd(),
|
||||
});
|
||||
})
|
||||
// ── 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 () => {});
|
||||
});
|
||||
}
|
||||
|
||||
+1
-1
@@ -314,7 +314,7 @@ export async function jf2ToAS2Activity(properties, actorUrl, publicationUrl, opt
|
||||
// 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)) { // [patch] ap-repost-announce-fix
|
||||
if (await isApUrl(repostOf)) {
|
||||
const actorPath = new URL(actorUrl).pathname;
|
||||
const mp = actorPath.replace(/\/users\/[^/]+$/, "");
|
||||
const postRelPath = (properties.url || "")
|
||||
|
||||
@@ -55,7 +55,7 @@ export async function likePost({ targetUrl, federation, handle, publicationUrl,
|
||||
});
|
||||
|
||||
if (recipient) {
|
||||
try { // [patch] ap-interactions-send-guard
|
||||
try {
|
||||
await ctx.sendActivity({ identifier: handle }, recipient, like, {
|
||||
orderingKey: targetUrl,
|
||||
});
|
||||
@@ -178,7 +178,7 @@ export async function boostPost({ targetUrl, federation, handle, publicationUrl,
|
||||
});
|
||||
|
||||
// Send to followers
|
||||
try { // [patch] ap-interactions-send-guard
|
||||
try {
|
||||
await ctx.sendActivity({ identifier: handle }, "followers", announce, {
|
||||
preferSharedInbox: true,
|
||||
syncCollection: true,
|
||||
|
||||
@@ -44,8 +44,10 @@ export async function resolveRemoteAccount(acct, pluginOptions, baseUrl, collect
|
||||
|
||||
// Use signed→unsigned fallback so servers rejecting signed GETs still resolve
|
||||
const documentLoader = await ctx.getDocumentLoader({ identifier: handle });
|
||||
const actor = await lookupWithSecurity(ctx, actorUri, { documentLoader });
|
||||
if (!actor) return null;
|
||||
// Timeout guard: cap actor fetch at 8 s so hung lookups fail fast.
|
||||
const _aLookupTimeout = (p, ms = 8000) => { const t = new Promise((_, rej) => setTimeout(() => rej(new Error("actor lookup timeout")), ms)); p.catch(() => {}); return Promise.race([p, t]); };
|
||||
const actor = await _aLookupTimeout(lookupWithSecurity(ctx, actorUri, { documentLoader })).catch(err => { console.warn(`[Mastodon API] Actor lookup failed for ${acct}: ${err.message}`); return null; });
|
||||
if (!actor) { console.warn(`[Mastodon API] lookupWithSecurity returned null for ${acct}`); return null; }
|
||||
|
||||
// Extract data from the Fedify actor object
|
||||
const name = actor.name?.toString() || actor.preferredUsername?.toString() || "";
|
||||
@@ -69,21 +71,24 @@ export async function resolveRemoteAccount(acct, pluginOptions, baseUrl, collect
|
||||
} catch { /* ignore */ }
|
||||
|
||||
// Get collection counts (followers, following, outbox) — with 5 s timeout each
|
||||
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]);
|
||||
};
|
||||
const withTimeout = (promise, ms = 5000) =>
|
||||
Promise.race([promise, new Promise((_, reject) => setTimeout(() => reject(new Error("timeout")), ms))]);
|
||||
|
||||
// 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;
|
||||
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 */ }
|
||||
|
||||
// Get published/created date — normalize to UTC ISO so clients display it correctly.
|
||||
// Temporal.Instant.toString() preserves the original timezone offset;
|
||||
@@ -138,14 +143,13 @@ export async function resolveRemoteAccount(acct, pluginOptions, baseUrl, collect
|
||||
});
|
||||
|
||||
// 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(
|
||||
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
|
||||
).catch(() => {}); // fire-and-forget, non-fatal
|
||||
}
|
||||
|
||||
return account;
|
||||
|
||||
@@ -354,7 +354,7 @@ router.get("/api/v1/accounts/:id", tokenRequired, scopeRequired("read", "read:ac
|
||||
|
||||
// Check if it's the local profile
|
||||
const profile = await collections.ap_profile.findOne({});
|
||||
if (profile && profile._id.toString() === id) {
|
||||
if (profile && remoteActorId(profile.url) === id) {
|
||||
const [statuses, followers, following] = await Promise.all([
|
||||
collections.ap_timeline.countDocuments({ "author.url": profile.url }),
|
||||
collections.ap_followers.countDocuments({}),
|
||||
@@ -389,6 +389,20 @@ router.get("/api/v1/accounts/:id", tokenRequired, scopeRequired("read", "read:ac
|
||||
return res.json(account);
|
||||
}
|
||||
|
||||
// Cache fallback: actor not in followers/following/timeline,
|
||||
// but may have been resolved via /lookup and cached in ap_actor_cache
|
||||
let cachedActorUrl = getActorUrlFromId(id);
|
||||
if (!cachedActorUrl && collections.ap_actor_cache) {
|
||||
const cached = await collections.ap_actor_cache.findOne({ _id: id });
|
||||
if (cached?.actorUrl) cachedActorUrl = cached.actorUrl;
|
||||
}
|
||||
if (cachedActorUrl) {
|
||||
const cachedAccount = await resolveRemoteAccount(
|
||||
cachedActorUrl, pluginOptions, baseUrl, collections,
|
||||
);
|
||||
if (cachedAccount) return res.json(cachedAccount);
|
||||
}
|
||||
|
||||
return res.status(404).json({ error: "Record not found" });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
@@ -456,23 +470,15 @@ router.get("/api/v1/accounts/:id/statuses", tokenRequired, scopeRequired("read",
|
||||
let bookmarkedIds = new Set();
|
||||
|
||||
if (req.mastodonToken && collections.ap_interactions) {
|
||||
const urlToUid = new Map(); // [patch] ap-interactions-accounts-uid
|
||||
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()];
|
||||
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) {
|
||||
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);
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -505,7 +511,7 @@ router.get("/api/v1/accounts/:id/followers", tokenRequired, scopeRequired("read"
|
||||
const profile = await collections.ap_profile.findOne({});
|
||||
|
||||
// Only serve followers for the local account
|
||||
if (!profile || profile._id.toString() !== id) {
|
||||
if (!profile || remoteActorId(profile.url) !== id) {
|
||||
return res.json([]);
|
||||
}
|
||||
|
||||
@@ -538,7 +544,7 @@ router.get("/api/v1/accounts/:id/following", tokenRequired, scopeRequired("read"
|
||||
const profile = await collections.ap_profile.findOne({});
|
||||
|
||||
// Only serve following for the local account
|
||||
if (!profile || profile._id.toString() !== id) {
|
||||
if (!profile || remoteActorId(profile.url) !== id) {
|
||||
return res.json([]);
|
||||
}
|
||||
|
||||
|
||||
@@ -207,7 +207,7 @@ function resolveInternalTypes(mastodonTypes) {
|
||||
async function batchFetchStatuses(collections, notifications) {
|
||||
const statusMap = new Map();
|
||||
|
||||
const targetUrls = [ // [patch] ap-notifications-status-lookup
|
||||
const targetUrls = [
|
||||
...new Set(
|
||||
notifications
|
||||
.flatMap((n) => [n.targetUrl, n.url])
|
||||
|
||||
@@ -109,7 +109,42 @@ router.get("/api/v2/search", tokenRequired, scopeRequired("read", "read:search")
|
||||
.limit(limit)
|
||||
.toArray();
|
||||
|
||||
results.statuses = items.map((item) =>
|
||||
// URL resolve: find post by AP URL before content search.
|
||||
if (resolve && query.startsWith("http")) {
|
||||
const resolvedItem = await collections.ap_timeline.findOne({
|
||||
isContext: { $ne: true },
|
||||
$or: [{ uid: query }, { url: query }],
|
||||
});
|
||||
if (resolvedItem) {
|
||||
results.statuses.push(serializeStatus(resolvedItem, {
|
||||
baseUrl, favouritedIds: new Set(), rebloggedIds: new Set(),
|
||||
bookmarkedIds: new Set(), pinnedIds: new Set(),
|
||||
}));
|
||||
} else if (pluginOptions.federation) {
|
||||
try {
|
||||
const { lookupWithSecurity } = await import("../../lookup-helpers.js");
|
||||
const { extractObjectData } = await import("../../timeline-store.js");
|
||||
const { addTimelineItem } = await import("../../storage/timeline.js");
|
||||
const _rCtx = pluginOptions.federation.createContext(new URL(pluginOptions.publicationUrl), { handle: pluginOptions.handle, publicationUrl: pluginOptions.publicationUrl });
|
||||
const _rDl = await _rCtx.getDocumentLoader({ identifier: pluginOptions.handle });
|
||||
const _rObj = await lookupWithSecurity(_rCtx, new URL(query), { documentLoader: _rDl });
|
||||
if (_rObj) {
|
||||
const _rData = await extractObjectData(_rObj, { documentLoader: _rDl });
|
||||
if (!_rData?.uid) throw new Error("remote AP object has no uid");
|
||||
const _rStored = await addTimelineItem(collections, _rData);
|
||||
if (_rStored) {
|
||||
results.statuses.push(serializeStatus(_rStored, {
|
||||
baseUrl, favouritedIds: new Set(), rebloggedIds: new Set(),
|
||||
bookmarkedIds: new Set(), pinnedIds: new Set(),
|
||||
}));
|
||||
}
|
||||
}
|
||||
} catch (_rErr) {
|
||||
console.warn(`[Mastodon API] search resolve remote fetch failed for ${query}: ${_rErr.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
results.statuses.push(...items.map((item) =>
|
||||
serializeStatus(item, {
|
||||
baseUrl,
|
||||
favouritedIds: new Set(),
|
||||
@@ -117,7 +152,7 @@ router.get("/api/v2/search", tokenRequired, scopeRequired("read", "read:search")
|
||||
bookmarkedIds: new Set(),
|
||||
pinnedIds: new Set(),
|
||||
}),
|
||||
);
|
||||
));
|
||||
}
|
||||
|
||||
// ─── Hashtag search ──────────────────────────────────────────────────
|
||||
|
||||
@@ -126,16 +126,33 @@ router.get("/api/v1/statuses/:id/context", tokenRequired, scopeRequired("read",
|
||||
}
|
||||
|
||||
// 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 };
|
||||
|
||||
// Load real interaction state for thread context
|
||||
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 };
|
||||
|
||||
res.json({
|
||||
ancestors: ancestors.map((a) => serializeStatus(a, serializeOpts)),
|
||||
@@ -440,13 +457,42 @@ router.post("/api/v1/statuses", tokenRequired, scopeRequired("write", "write:sta
|
||||
console.info(`[Mastodon API] Created post via Micropub: ${postUrl}`);
|
||||
|
||||
// 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.
|
||||
// Eagerly insert own post into ap_timeline so the Mastodon client can resolve
|
||||
// in_reply_to_id for this post immediately, without waiting for the build webhook.
|
||||
// The AP syndicator will upsert the same uid later via $setOnInsert (no-op).
|
||||
const profile = await collections.ap_profile.findOne({});
|
||||
const handle = pluginOptions.handle || "user";
|
||||
let _tlItem = null;
|
||||
try {
|
||||
const _ph = (() => { try { return new URL(publicationUrl).hostname; } catch { return ""; } })();
|
||||
_tlItem = await addTimelineItem(collections, {
|
||||
uid: postUrl,
|
||||
url: postUrl,
|
||||
type: data.properties["post-type"] || "note",
|
||||
content: { text: contentText, html: `<p>${contentHtml}</p>` },
|
||||
author: {
|
||||
name: profile?.name || handle,
|
||||
url: profile?.url || publicationUrl,
|
||||
photo: profile?.icon || "",
|
||||
handle: `@${handle}@${_ph}`,
|
||||
emojis: [],
|
||||
bot: false,
|
||||
},
|
||||
published: data.properties.published || new Date().toISOString(),
|
||||
createdAt: new Date().toISOString(),
|
||||
inReplyTo: inReplyTo || null,
|
||||
inReplyToId: inReplyToId || null,
|
||||
visibility: jf2.visibility || "public",
|
||||
sensitive: jf2.sensitive === "true",
|
||||
category: [],
|
||||
counts: { likes: 0, boosts: 0, replies: 0 },
|
||||
});
|
||||
} catch (tlErr) {
|
||||
console.warn(`[Mastodon API] Failed to pre-insert own post into timeline: ${tlErr.message}`);
|
||||
}
|
||||
|
||||
const statusResponse = {
|
||||
id: String(Date.now()),
|
||||
id: _tlItem?._id?.toString() || String(Date.now()),
|
||||
created_at: new Date().toISOString(),
|
||||
content: `<p>${contentHtml}</p>`,
|
||||
url: postUrl,
|
||||
@@ -559,6 +605,14 @@ router.delete("/api/v1/statuses/:id", tokenRequired, scopeRequired("write", "wri
|
||||
// Delete from timeline
|
||||
await collections.ap_timeline.deleteOne({ _id: item._id });
|
||||
|
||||
// Broadcast AP Delete activity to followers
|
||||
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
|
||||
if (collections.ap_interactions && item.uid) {
|
||||
await collections.ap_interactions.deleteMany({ objectUrl: item.uid });
|
||||
@@ -1111,8 +1165,12 @@ router.get("/api/v1/statuses/:id/card", async (req, res, next) => {
|
||||
*/
|
||||
async function findTimelineItemById(collection, id) {
|
||||
try {
|
||||
return await collection.findOne({ _id: new ObjectId(id) });
|
||||
} catch {
|
||||
const _oid = new ObjectId(id);
|
||||
const _doc = await collection.findOne({ _id: _oid });
|
||||
if (!_doc) console.warn(`[Mastodon API] findTimelineItemById: no item for id=${id}`);
|
||||
return _doc;
|
||||
} catch (_fErr) {
|
||||
console.warn(`[Mastodon API] findTimelineItemById: invalid id=${id}: ${_fErr.message}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
+199
-52
@@ -382,8 +382,205 @@ router.get("/api/v1/scheduled_statuses", (req, res) => {
|
||||
|
||||
// ─── Conversations ──────────────────────────────────────────────────────────
|
||||
|
||||
router.get("/api/v1/conversations", (req, res) => {
|
||||
res.json([]);
|
||||
// ─── Conversations (Direct Messages) ────────────────────────────────────────
|
||||
// Real implementation replacing the empty stub.
|
||||
// Reads from ap_messages collection, groups by conversationId (actor URL).
|
||||
|
||||
router.get("/api/v1/conversations", async (req, res, next) => {
|
||||
try {
|
||||
const collections = req.app.locals.mastodonCollections;
|
||||
const baseUrl = `${req.protocol}://${req.get("host")}`;
|
||||
const { serializeAccount } = await import("../entities/account.js");
|
||||
const { remoteActorId } = await import("../helpers/id-mapping.js");
|
||||
const { parseLimit } = await import("../helpers/pagination.js");
|
||||
if (!collections?.ap_messages) {
|
||||
return res.json([]);
|
||||
}
|
||||
const limit = parseLimit(req.query.limit, 20);
|
||||
// Aggregate conversations: group by conversationId, get last message + unread count
|
||||
const pipeline = [
|
||||
{ $sort: { published: -1 } },
|
||||
{
|
||||
$group: {
|
||||
_id: "$conversationId",
|
||||
lastMessageId: { $first: "$_id" },
|
||||
lastUid: { $first: "$uid" },
|
||||
lastContent: { $first: "$content" },
|
||||
lastPublished: { $first: "$published" },
|
||||
actorUrl: { $first: "$actorUrl" },
|
||||
actorName: { $first: "$actorName" },
|
||||
actorPhoto: { $first: "$actorPhoto" },
|
||||
actorHandle: { $first: "$actorHandle" },
|
||||
unreadCount: {
|
||||
$sum: { $cond: [{ $eq: ["$read", false] }, 1, 0] },
|
||||
},
|
||||
},
|
||||
},
|
||||
{ $sort: { lastPublished: -1 } },
|
||||
];
|
||||
// Apply cursor pagination on the aggregation result
|
||||
if (req.query.max_id) {
|
||||
pipeline.splice(0, 0, {
|
||||
$match: { _id: { $lt: req.query.max_id } },
|
||||
});
|
||||
}
|
||||
pipeline.push({ $limit: limit });
|
||||
const conversations = await collections.ap_messages
|
||||
.aggregate(pipeline)
|
||||
.toArray();
|
||||
const result = conversations.map((conv) => {
|
||||
const convId = remoteActorId(conv._id || conv.actorUrl);
|
||||
// Build a minimal Mastodon Status for last_status
|
||||
const lastStatus = {
|
||||
id: conv.lastMessageId.toString(),
|
||||
created_at: conv.lastPublished || new Date().toISOString(),
|
||||
in_reply_to_id: null,
|
||||
in_reply_to_account_id: null,
|
||||
sensitive: false,
|
||||
spoiler_text: "",
|
||||
visibility: "direct",
|
||||
language: null,
|
||||
uri: conv.lastUid || "",
|
||||
url: conv.lastUid || "",
|
||||
replies_count: 0,
|
||||
reblogs_count: 0,
|
||||
favourites_count: 0,
|
||||
edited_at: null,
|
||||
favourited: false,
|
||||
reblogged: false,
|
||||
muted: false,
|
||||
bookmarked: false,
|
||||
pinned: false,
|
||||
content: conv.lastContent?.html || conv.lastContent?.text || "",
|
||||
filtered: null,
|
||||
reblog: null,
|
||||
application: null,
|
||||
account: serializeAccount(
|
||||
{
|
||||
name: conv.actorName,
|
||||
url: conv.actorUrl,
|
||||
photo: conv.actorPhoto,
|
||||
handle: conv.actorHandle,
|
||||
},
|
||||
{ baseUrl },
|
||||
),
|
||||
media_attachments: [],
|
||||
mentions: [],
|
||||
tags: [],
|
||||
emojis: [],
|
||||
card: null,
|
||||
poll: null,
|
||||
};
|
||||
return {
|
||||
id: convId,
|
||||
unread: conv.unreadCount > 0,
|
||||
last_status: lastStatus,
|
||||
accounts: [
|
||||
serializeAccount(
|
||||
{
|
||||
name: conv.actorName,
|
||||
url: conv.actorUrl,
|
||||
photo: conv.actorPhoto,
|
||||
handle: conv.actorHandle,
|
||||
},
|
||||
{ baseUrl },
|
||||
),
|
||||
],
|
||||
};
|
||||
});
|
||||
// Set Link header for pagination
|
||||
if (result.length === limit && conversations.length > 0) {
|
||||
const lastConv = conversations[conversations.length - 1];
|
||||
const maxId = remoteActorId(lastConv._id || lastConv.actorUrl);
|
||||
res.set("Link", `<${baseUrl}/api/v1/conversations?max_id=${maxId}>; rel="next"`);
|
||||
}
|
||||
res.json(result);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
// Mark conversation as read
|
||||
router.post("/api/v1/conversations/:id/read", async (req, res, next) => {
|
||||
try {
|
||||
const collections = req.app.locals.mastodonCollections;
|
||||
const baseUrl = `${req.protocol}://${req.get("host")}`;
|
||||
const { serializeAccount } = await import("../entities/account.js");
|
||||
const { remoteActorId } = await import("../helpers/id-mapping.js");
|
||||
if (!collections?.ap_messages) {
|
||||
return res.status(404).json({ error: "Not found" });
|
||||
}
|
||||
// Find the conversation partner whose hashed actorUrl matches the :id
|
||||
const allPartners = await collections.ap_messages.aggregate([
|
||||
{ $group: { _id: "$conversationId" } },
|
||||
]).toArray();
|
||||
const partner = allPartners.find(
|
||||
(p) => remoteActorId(p._id) === req.params.id
|
||||
);
|
||||
if (!partner) {
|
||||
return res.status(404).json({ error: "Conversation not found" });
|
||||
}
|
||||
// Mark all messages from this partner as read
|
||||
await collections.ap_messages.updateMany(
|
||||
{ conversationId: partner._id, read: false },
|
||||
{ $set: { read: true } },
|
||||
);
|
||||
// Return the updated conversation
|
||||
const lastMsg = await collections.ap_messages
|
||||
.findOne({ conversationId: partner._id }, { sort: { published: -1 } });
|
||||
if (!lastMsg) {
|
||||
return res.status(404).json({ error: "No messages" });
|
||||
}
|
||||
const convId = remoteActorId(partner._id);
|
||||
const account = serializeAccount(
|
||||
{
|
||||
name: lastMsg.actorName,
|
||||
url: lastMsg.actorUrl,
|
||||
photo: lastMsg.actorPhoto,
|
||||
handle: lastMsg.actorHandle,
|
||||
},
|
||||
{ baseUrl },
|
||||
);
|
||||
res.json({
|
||||
id: convId,
|
||||
unread: false,
|
||||
last_status: {
|
||||
id: lastMsg._id.toString(),
|
||||
created_at: lastMsg.published || new Date().toISOString(),
|
||||
in_reply_to_id: null,
|
||||
in_reply_to_account_id: null,
|
||||
sensitive: false,
|
||||
spoiler_text: "",
|
||||
visibility: "direct",
|
||||
language: null,
|
||||
uri: lastMsg.uid || "",
|
||||
url: lastMsg.uid || "",
|
||||
replies_count: 0,
|
||||
reblogs_count: 0,
|
||||
favourites_count: 0,
|
||||
edited_at: null,
|
||||
favourited: false,
|
||||
reblogged: false,
|
||||
muted: false,
|
||||
bookmarked: false,
|
||||
pinned: false,
|
||||
content: lastMsg.content?.html || lastMsg.content?.text || "",
|
||||
filtered: null,
|
||||
reblog: null,
|
||||
application: null,
|
||||
account,
|
||||
media_attachments: [],
|
||||
mentions: [],
|
||||
tags: [],
|
||||
emojis: [],
|
||||
card: null,
|
||||
poll: null,
|
||||
},
|
||||
accounts: [account],
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
// ─── Domain blocks ──────────────────────────────────────────────────────────
|
||||
@@ -447,57 +644,7 @@ router.get("/api/v1/endorsements", (req, res) => {
|
||||
res.json([]);
|
||||
});
|
||||
|
||||
// ─── Account statuses ───────────────────────────────────────────────────────
|
||||
|
||||
router.get("/api/v1/accounts/:id/statuses", async (req, res, next) => {
|
||||
try {
|
||||
const collections = req.app.locals.mastodonCollections;
|
||||
const baseUrl = `${req.protocol}://${req.get("host")}`;
|
||||
|
||||
// Try to find the profile to see if this is the local user
|
||||
const profile = await collections.ap_profile.findOne({});
|
||||
const isLocal = profile && profile._id.toString() === req.params.id;
|
||||
|
||||
if (isLocal && profile?.url) {
|
||||
// Return statuses authored by local user
|
||||
const { serializeStatus } = await import("../entities/status.js");
|
||||
const { parseLimit } = await import("../helpers/pagination.js");
|
||||
|
||||
const limit = parseLimit(req.query.limit);
|
||||
const items = await collections.ap_timeline
|
||||
.find({ "author.url": profile.url, isContext: { $ne: true } })
|
||||
.sort({ _id: -1 })
|
||||
.limit(limit)
|
||||
.toArray();
|
||||
|
||||
const statuses = items.map((item) =>
|
||||
serializeStatus(item, {
|
||||
baseUrl,
|
||||
favouritedIds: new Set(),
|
||||
rebloggedIds: new Set(),
|
||||
bookmarkedIds: new Set(),
|
||||
pinnedIds: new Set(),
|
||||
}),
|
||||
);
|
||||
|
||||
return res.json(statuses);
|
||||
}
|
||||
|
||||
// Remote account or unknown — return empty
|
||||
res.json([]);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
// ─── Account followers/following ────────────────────────────────────────────
|
||||
|
||||
router.get("/api/v1/accounts/:id/followers", (req, res) => {
|
||||
res.json([]);
|
||||
});
|
||||
|
||||
router.get("/api/v1/accounts/:id/following", (req, res) => {
|
||||
res.json([]);
|
||||
});
|
||||
|
||||
export default router;
|
||||
|
||||
+17
-13
@@ -41,19 +41,23 @@ export function createSyndicator(plugin) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// Dedup: skip re-federation if we've already sent an activity for this URL. // [patch] ap-syndicate-dedup
|
||||
// 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 */ }
|
||||
// Skip location checkins — they have a JF2 `location` property.
|
||||
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.
|
||||
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.
|
||||
if (properties.visibility === "unlisted") {
|
||||
console.info(`[ActivityPub] Skipping syndication for unlisted post: ${properties.url}`);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
try {
|
||||
const actorUrl = plugin._getActorUrl();
|
||||
|
||||
@@ -53,7 +53,7 @@ export async function cleanupTimeline(collections, retentionLimit) {
|
||||
|
||||
const removedUids = toDelete.map((item) => item.uid).filter(Boolean);
|
||||
|
||||
// Preserve items the user has interacted with (liked, bookmarked, boosted). // [patch] ap-interactions-cleanup-preserve
|
||||
// Preserve items the user has interacted with (liked, bookmarked, boosted).
|
||||
// Deleting them would silently remove entries from the Favourites/Bookmarks pages.
|
||||
let interactedUids = new Set();
|
||||
if (removedUids.length > 0 && collections.ap_interactions) {
|
||||
|
||||
Reference in New Issue
Block a user