feat: outbound Delete, visibility addressing, CW/sensitive, polls, Flag reports (v2.10.0)

- Outbound Delete: broadcastDelete() + POST /admin/federation/delete route
- Visibility: unlisted + followers-only addressing via defaultVisibility config
- Content Warning: outbound sensitive flag + summary as CW text
- Polls: inbound Question/poll parsing with progress bar rendering
- Flag: inbound report handler with ap_reports collection + Reports tab
- Includes DM support files from v2.9.x (messages controller, storage, templates)
- Includes coverage audit and high-impact gaps implementation plan

Confab-Link: http://localhost:8080/sessions/cc343b15-8d10-43cd-a48f-ca912eb79b83
This commit is contained in:
Ricardo
2026-03-14 08:51:44 +01:00
parent a266b6d9ba
commit 1dc42ad5e5
25 changed files with 4780 additions and 18 deletions

168
index.js
View File

@@ -78,6 +78,14 @@ import {
} from "./lib/controllers/tabs.js";
import { hashtagExploreApiController } from "./lib/controllers/hashtag-explore.js";
import { publicProfileController } from "./lib/controllers/public-profile.js";
import {
messagesController,
messageComposeController,
submitMessageController,
markAllMessagesReadController,
clearAllMessagesController,
deleteMessageController,
} from "./lib/controllers/messages.js";
import { authorizeInteractionController } from "./lib/controllers/authorize-interaction.js";
import { myProfileController } from "./lib/controllers/my-profile.js";
import {
@@ -89,6 +97,7 @@ import { startBatchRefollow } from "./lib/batch-refollow.js";
import { logActivity } from "./lib/activity-log.js";
import { scheduleCleanup } from "./lib/timeline-cleanup.js";
import { runSeparateMentionsMigration } from "./lib/migrations/separate-mentions.js";
import { deleteFederationController } from "./lib/controllers/federation-delete.js";
const defaults = {
mountPath: "/activitypub",
@@ -110,6 +119,7 @@ const defaults = {
notificationRetentionDays: 30,
debugDashboard: false,
debugPassword: "",
defaultVisibility: "public", // "public" | "unlisted" | "followers"
};
export default class ActivityPubEndpoint {
@@ -143,6 +153,11 @@ export default class ActivityPubEndpoint {
text: "activitypub.notifications.title",
requiresDatabase: true,
},
{
href: `${this.options.mountPath}/admin/reader/messages`,
text: "activitypub.messages.title",
requiresDatabase: true,
},
{
href: `${this.options.mountPath}/admin/reader/moderation`,
text: "activitypub.moderation.title",
@@ -252,6 +267,12 @@ export default class ActivityPubEndpoint {
router.post("/admin/reader/notifications/mark-read", markAllNotificationsReadController(mp));
router.post("/admin/reader/notifications/clear", clearAllNotificationsController(mp));
router.post("/admin/reader/notifications/delete", deleteNotificationController(mp));
router.get("/admin/reader/messages", messagesController(mp));
router.get("/admin/reader/messages/compose", messageComposeController(mp, this));
router.post("/admin/reader/messages/compose", submitMessageController(mp, this));
router.post("/admin/reader/messages/mark-read", markAllMessagesReadController(mp));
router.post("/admin/reader/messages/clear", clearAllMessagesController(mp));
router.post("/admin/reader/messages/delete", deleteMessageController(mp));
router.get("/admin/reader/compose", composeController(mp, this));
router.post("/admin/reader/compose", submitComposeController(mp, this));
router.post("/admin/reader/like", likeController(mp, this));
@@ -290,6 +311,7 @@ export default class ActivityPubEndpoint {
router.post("/admin/refollow/pause", refollowPauseController(mp, this));
router.post("/admin/refollow/resume", refollowResumeController(mp, this));
router.get("/admin/refollow/status", refollowStatusController(mp));
router.post("/admin/federation/delete", deleteFederationController(mp, this));
return router;
}
@@ -357,6 +379,7 @@ export default class ActivityPubEndpoint {
post.properties,
actorUrl,
self._publicationUrl,
{ visibility: self.options.defaultVisibility },
);
const object = activity.object || activity;
@@ -451,6 +474,7 @@ export default class ActivityPubEndpoint {
{
replyToActorUrl: replyToActor?.url,
replyToActorHandle: replyToActor?.handle,
visibility: self.options.defaultVisibility,
},
);
@@ -852,6 +876,97 @@ export default class ActivityPubEndpoint {
}
}
/**
* Send Delete activity to all followers for a removed post.
* Mirrors broadcastActorUpdate() pattern: batch delivery with shared inbox dedup.
* @param {string} postUrl - Full URL of the deleted post
*/
async broadcastDelete(postUrl) {
if (!this._federation) return;
try {
const { Delete } = await import("@fedify/fedify/vocab");
const handle = this.options.actor.handle;
const ctx = this._federation.createContext(
new URL(this._publicationUrl),
{ handle, publicationUrl: this._publicationUrl },
);
const del = new Delete({
actor: ctx.getActorUri(handle),
object: new URL(postUrl),
});
const followers = await this._collections.ap_followers
.find({})
.project({ actorUrl: 1, inbox: 1, sharedInbox: 1 })
.toArray();
const inboxMap = new Map();
for (const f of followers) {
const key = f.sharedInbox || f.inbox;
if (key && !inboxMap.has(key)) {
inboxMap.set(key, f);
}
}
const uniqueRecipients = [...inboxMap.values()];
const BATCH_SIZE = 25;
const BATCH_DELAY_MS = 5000;
let delivered = 0;
let failed = 0;
console.info(
`[ActivityPub] Broadcasting Delete for ${postUrl} to ${uniqueRecipients.length} ` +
`unique inboxes (${followers.length} followers)`,
);
for (let i = 0; i < uniqueRecipients.length; i += BATCH_SIZE) {
const batch = uniqueRecipients.slice(i, i + BATCH_SIZE);
const recipients = batch.map((f) => ({
id: new URL(f.actorUrl),
inboxId: new URL(f.inbox || f.sharedInbox),
endpoints: f.sharedInbox
? { sharedInbox: new URL(f.sharedInbox) }
: undefined,
}));
try {
await ctx.sendActivity(
{ identifier: handle },
recipients,
del,
{ preferSharedInbox: true },
);
delivered += batch.length;
} catch (error) {
failed += batch.length;
console.warn(
`[ActivityPub] Delete batch ${Math.floor(i / BATCH_SIZE) + 1} failed: ${error.message}`,
);
}
if (i + BATCH_SIZE < uniqueRecipients.length) {
await new Promise((resolve) => setTimeout(resolve, BATCH_DELAY_MS));
}
}
console.info(
`[ActivityPub] Delete broadcast complete for ${postUrl}: ${delivered} delivered, ${failed} failed`,
);
await logActivity(this._collections.ap_activities, {
direction: "outbound",
type: "Delete",
actorUrl: this._publicationUrl,
objectUrl: postUrl,
summary: `Sent Delete for ${postUrl} to ${delivered} inboxes`,
}).catch(() => {});
} catch (error) {
console.warn("[ActivityPub] broadcastDelete failed:", error.message);
}
}
/**
* Build the full actor URL from config.
* @returns {string}
@@ -885,8 +1000,12 @@ export default class ActivityPubEndpoint {
Indiekit.addCollection("ap_blocked");
Indiekit.addCollection("ap_interactions");
Indiekit.addCollection("ap_followed_tags");
// Message collections
Indiekit.addCollection("ap_messages");
// Explore tab collections
Indiekit.addCollection("ap_explore_tabs");
// Reports collection
Indiekit.addCollection("ap_reports");
// Store collection references (posts resolved lazily)
const indiekitCollections = Indiekit.collections;
@@ -906,8 +1025,12 @@ export default class ActivityPubEndpoint {
ap_blocked: indiekitCollections.get("ap_blocked"),
ap_interactions: indiekitCollections.get("ap_interactions"),
ap_followed_tags: indiekitCollections.get("ap_followed_tags"),
// Message collections
ap_messages: indiekitCollections.get("ap_messages"),
// Explore tab collections
ap_explore_tabs: indiekitCollections.get("ap_explore_tabs"),
// Reports collection
ap_reports: indiekitCollections.get("ap_reports"),
get posts() {
return indiekitCollections.get("posts");
},
@@ -993,6 +1116,35 @@ export default class ActivityPubEndpoint {
);
}
// Message indexes
this._collections.ap_messages.createIndex(
{ uid: 1 },
{ unique: true, background: true },
);
this._collections.ap_messages.createIndex(
{ published: -1 },
{ background: true },
);
this._collections.ap_messages.createIndex(
{ read: 1 },
{ background: true },
);
this._collections.ap_messages.createIndex(
{ conversationId: 1, published: -1 },
{ background: true },
);
this._collections.ap_messages.createIndex(
{ direction: 1 },
{ background: true },
);
// TTL index for message cleanup (reuse notification retention)
if (notifRetention > 0) {
this._collections.ap_messages.createIndex(
{ createdAt: 1 },
{ expireAfterSeconds: notifRetention * 86_400 },
);
}
// Muted collection — sparse unique indexes (allow multiple null values)
this._collections.ap_muted
.dropIndex("url_1")
@@ -1054,6 +1206,22 @@ export default class ActivityPubEndpoint {
{ order: 1 },
{ background: true },
);
// ap_reports indexes
if (notifRetention > 0) {
this._collections.ap_reports.createIndex(
{ createdAt: 1 },
{ expireAfterSeconds: notifRetention * 86_400 },
);
}
this._collections.ap_reports.createIndex(
{ reporterUrl: 1 },
{ background: true },
);
this._collections.ap_reports.createIndex(
{ reportedUrls: 1 },
{ background: true },
);
} catch {
// Index creation failed — collections not yet available.
// Indexes already exist from previous startups; non-fatal.