Merge remote-tracking branch 'upstream/main'

This commit is contained in:
svemagie
2026-03-22 15:51:46 +01:00
7 changed files with 135 additions and 8 deletions

View File

@@ -48,6 +48,39 @@ index.js ← Plugin entry, route registration, syndicat
│ ├── server-blocks.js ← Server-level domain blocking
│ ├── followed-tags.js ← Hashtag follow/unfollow storage
│ └── messages.js ← Direct message storage
├── lib/mastodon/ ← Mastodon Client API (Phanpy/Elk/Moshidon/Fedilab compatibility)
│ ├── router.js ← Main router: body parsers, CORS, token resolution, sub-routers
│ ├── backfill-timeline.js ← Startup backfill: posts collection → ap_timeline
│ ├── entities/ ← Mastodon JSON entity serializers
│ │ ├── account.js ← Account entity (local + remote, with stats cache enrichment)
│ │ ├── status.js ← Status entity (published-based cursor IDs, own-post detection)
│ │ ├── notification.js ← Notification entity
│ │ ├── sanitize.js ← HTML sanitization for API responses
│ │ ├── relationship.js ← Relationship entity
│ │ ├── media.js ← Media attachment entity
│ │ └── instance.js ← Instance info entity
│ ├── helpers/
│ │ ├── pagination.js ← Published-date cursor pagination (NOT ObjectId-based)
│ │ ├── id-mapping.js ← Deterministic account IDs: sha256(actorUrl).slice(0,24)
│ │ ├── interactions.js ← Like/boost/bookmark via Fedify AP activities
│ │ ├── resolve-account.js ← Remote account resolution via Fedify WebFinger + actor fetch
│ │ ├── account-cache.js ← In-memory LRU cache for account stats (500 entries, 1h TTL)
│ │ └── enrich-accounts.js ← Batch-enrich embedded account stats in timeline responses
│ ├── middleware/
│ │ ├── cors.js ← CORS for browser-based SPA clients
│ │ ├── token-required.js ← Bearer token → ap_oauth_tokens lookup
│ │ ├── scope-required.js ← OAuth scope validation
│ │ └── error-handler.js ← JSON error responses for API routes
│ └── routes/
│ ├── oauth.js ← OAuth2 server: app registration, authorize, token, revoke
│ ├── accounts.js ← Account lookup, relationships, follow/unfollow, statuses
│ ├── statuses.js ← Status CRUD, context/thread, favourite, boost, bookmark
│ ├── timelines.js ← Home/public/hashtag timelines with account enrichment
│ ├── notifications.js ← Notification listing with type filtering
│ ├── search.js ← Account/status/hashtag search with remote resolution
│ ├── instance.js ← Instance info, nodeinfo, custom emoji, preferences
│ ├── media.js ← Media upload (stub)
│ └── stubs.js ← 25+ stub endpoints preventing client errors
├── lib/controllers/ ← Express route handlers (admin UI)
│ ├── dashboard.js, reader.js, compose.js, profile.js, profile.remote.js
│ ├── public-profile.js ← Public profile page (HTML fallback for actor URL)
@@ -67,7 +100,7 @@ index.js ← Plugin entry, route registration, syndicat
│ ├── my-profile.js ← Self-profile view
│ ├── resolve.js ← Actor/post resolution endpoint
│ ├── authorize-interaction.js ← Remote interaction authorization
│ ├── federation-mgmt.js ← Federation management (server blocks)
│ ├── federation-mgmt.js ← Federation management (server blocks, moderation overview)
│ └── federation-delete.js ← Account deletion / federation cleanup
├── views/ ← Nunjucks templates
│ ├── activitypub-*.njk ← Page templates
@@ -78,7 +111,7 @@ index.js ← Plugin entry, route registration, syndicat
│ ├── reader-infinite-scroll.js ← Alpine.js components (infinite scroll, new posts banner, read tracking)
│ ├── reader-tabs.js ← Alpine.js tab persistence
│ └── icon.svg ← Plugin icon
└── locales/en.json ← i18n strings
└── locales/{en,de,es,fr,...}.json ← i18n strings (15 locales)
```
## Data Flow
@@ -90,6 +123,8 @@ Inbound: Remote inbox POST → Fedify → inbox-listeners.js → ap_inbox_queue
Reply forwarding: inbox-listeners.js checks if reply is to our post → ctx.forwardActivity() → follower inboxes
Reader: Followed account posts → Create inbox → timeline-store → ap_timeline → reader UI
Explore: Public Mastodon API → fetchMastodonTimeline() → mapMastodonToItem() → explore UI
Mastodon: Client (Phanpy/Elk/Moshidon) → /api/v1/* → ap_timeline + Fedify → JSON responses
POST /api/v1/statuses → Micropub pipeline → content file + ap_timeline + AP syndication
All views (reader, explore, tag timeline, hashtag explore, API endpoints) share a single
processing pipeline via item-processing.js:
@@ -118,9 +153,12 @@ processing pipeline via item-processing.js:
| `ap_explore_tabs` | Saved explore instances | `instance` (unique), `label` |
| `ap_reports` | Outbound Flag activities | `actorUrl`, `reportedAt` |
| `ap_pending_follows` | Follow requests awaiting approval | `actorUrl` (unique), `receivedAt` |
| `ap_blocked_servers` | Blocked server domains | `domain` (unique) |
| `ap_blocked_servers` | Blocked server domains | `hostname` (unique) |
| `ap_key_freshness` | Remote actor key verification timestamps | `actorUrl` (unique), `lastVerifiedAt` |
| `ap_inbox_queue` | Persistent async inbox queue | `activityId`, `status`, `enqueuedAt` |
| `ap_oauth_apps` | Mastodon API client registrations | `clientId` (unique), `clientSecret`, `redirectUris` |
| `ap_oauth_tokens` | OAuth2 authorization codes + access tokens | `code` (unique sparse), `accessToken` (unique sparse) |
| `ap_markers` | Read position markers (Mastodon API) | `userId`, `timeline` |
## Critical Patterns and Gotchas
@@ -361,6 +399,33 @@ The `visibility` field is stored on `ap_timeline` documents for future filtering
`lib/key-refresh.js` tracks when remote actor keys were last verified in `ap_key_freshness`. `touchKeyFreshness()` is called for every inbound activity. This allows skipping redundant key re-fetches for actors we've recently verified, reducing network round-trips.
### 34. Mastodon Client API — Architecture (v3.0.0+)
The Mastodon Client API is mounted at `/` (domain root) via `Indiekit.addEndpoint()` to serve `/api/v1/*`, `/api/v2/*`, and `/oauth/*` endpoints that Mastodon-compatible clients expect.
**Key design decisions:**
- **Published-date pagination** — Status IDs are `encodeCursor(published)` (ms since epoch), NOT MongoDB ObjectIds. This ensures chronological timeline sort regardless of insertion order (backfilled posts get new ObjectIds but retain original published dates).
- **Status lookup** — `findTimelineItemById()` decodes cursor → published date → MongoDB lookup. Must try both `"2026-03-21T15:33:50.000Z"` (with ms) and `"2026-03-21T15:33:50Z"` (without) because stored dates vary.
- **Own-post detection** — `setLocalIdentity(publicationUrl, handle)` called at init. `serializeAccount()` compares `author.url === publicationUrl` to pass `isLocal: true`.
- **Account enrichment** — Phanpy never calls `/accounts/:id` for timeline authors. `enrichAccountStats()` batch-resolves unique authors via Fedify after serialization, cached in memory (500 entries, 1h TTL).
- **OAuth for native apps** — Android Custom Tabs block 302 redirects to custom URI schemes (`moshidon-android-auth://`, `fedilab://`). Use HTML page with JS `window.location` redirect instead.
- **OAuth token storage** — Auth code documents MUST NOT set `accessToken: null` — use field absence. MongoDB sparse unique indexes skip absent fields but enforce uniqueness on explicit `null`.
- **Route ordering** — `/accounts/relationships` and `/accounts/familiar_followers` MUST be defined BEFORE `/accounts/:id` in Express, otherwise `:id` matches "relationships" as a parameter.
- **Unsigned fallback** — `lookupWithSecurity()` tries authenticated (signed) GET first, falls back to unsigned if it fails. Some servers (tags.pub) reject signed GETs with 400.
- **Backfill** — `backfill-timeline.js` runs on startup, converts Micropub posts → `ap_timeline` format with content synthesis (bookmarks → "Bookmarked: URL"), hashtag extraction, and absolute URL resolution.
### 35. Mastodon API — Content Processing
When creating posts via `POST /api/v1/statuses`:
- Bare URLs are linkified to `<a>` tags
- `@user@domain` mentions are converted to profile links with `h-card` markup
- Mentions are extracted into `mentions[]` array with name and URL
- Hashtags are extracted from content text and merged with Micropub categories
- Content is stored in `ap_timeline` immediately (visible in Mastodon API)
- Content file is created via Micropub pipeline (visible on website after Eleventy rebuild)
- Relative media URLs are resolved to absolute using the publication URL
## Date Handling Convention
**All dates MUST be stored as ISO 8601 strings.** This is mandatory across all Indiekit plugins.
@@ -442,6 +507,27 @@ On restart, `refollow:pending` entries are reset to `import` to prevent stale cl
| `GET` | `{mount}/api/ap-url?post={url}` | Resolve blog post URL → AP object URL (for "Also on Fediverse" widget) | No |
| `GET` | `{mount}/users/:identifier` | Public profile page (HTML fallback) | No |
| `GET` | `/*` (root) | Content negotiation (AP clients only) | No |
| | **Mastodon Client API (mounted at `/`)** | |
| `POST` | `/api/v1/apps` | Register OAuth client | No |
| `GET` | `/oauth/authorize` | Authorization page | IndieAuth |
| `POST` | `/oauth/authorize` | Process authorization | IndieAuth |
| `POST` | `/oauth/token` | Token exchange | No |
| `POST` | `/oauth/revoke` | Revoke token | No |
| `GET` | `/api/v1/accounts/verify_credentials` | Current user | Bearer |
| `GET` | `/api/v1/accounts/lookup` | Account lookup (with Fedify remote resolution) | Bearer |
| `GET` | `/api/v1/accounts/relationships` | Follow/block/mute state | Bearer |
| `GET` | `/api/v1/accounts/:id` | Account details (with remote AP collection counts) | Bearer |
| `GET` | `/api/v1/accounts/:id/statuses` | Account posts | Bearer |
| `POST` | `/api/v1/accounts/:id/follow,unfollow` | Follow/unfollow via Fedify | Bearer |
| `POST` | `/api/v1/accounts/:id/block,unblock,mute,unmute` | Moderation | Bearer |
| `GET` | `/api/v1/timelines/home,public,tag/:hashtag` | Timelines (published-date sort) | Bearer |
| `GET/POST` | `/api/v1/statuses` | Get/create status (via Micropub pipeline) | Bearer |
| `GET` | `/api/v1/statuses/:id/context` | Thread (ancestors + descendants) | Bearer |
| `POST` | `/api/v1/statuses/:id/favourite,reblog,bookmark` | Interactions via Fedify | Bearer |
| `GET` | `/api/v1/notifications` | Notifications with type filtering | Bearer |
| `GET` | `/api/v2/search` | Search with remote resolution | Bearer |
| `GET` | `/api/v1/domain_blocks` | Blocked server domains | Bearer |
| `GET` | `/api/v1/instance`, `/api/v2/instance` | Instance info | No |
## Dependencies

View File

@@ -1,6 +1,6 @@
# @svemagie/indiekit-endpoint-activitypub
ActivityPub federation endpoint for [Indiekit](https://getindiekit.com), built on [Fedify](https://fedify.dev) 2.0. Makes your IndieWeb site a full fediverse actor — discoverable, followable, and interactive from Mastodon, Misskey, Pixelfed, and any ActivityPub-compatible platform.
ActivityPub federation endpoint for [Indiekit](https://getindiekit.com), built on [Fedify](https://fedify.dev) 2.0. Makes your IndieWeb site a full fediverse actor — discoverable, followable, and interactive from Mastodon, Misskey, Pixelfed, and any ActivityPub-compatible platform. Includes a Mastodon-compatible Client API so you can use Phanpy, Elk, Moshidon, Fedilab, and other Mastodon clients with your own AP instance.
This is a fork of [@rmdes/indiekit-endpoint-activitypub](https://github.com/rmdes/indiekit-endpoint-activitypub) by [Ricardo Mendes](https://rmendes.net) ([@rick@rmendes.net](https://rmendes.net)), adding direct message (DM) support.
@@ -110,6 +110,23 @@ Private ActivityPub messages (messages addressed only to your actor, with no `as
- OpenTelemetry tracing for federation activity
- Real-time activity inspection
**Mastodon Client API** *(v3.0.0+)*
- Full Mastodon REST API compatibility — use Phanpy, Elk, Moshidon, Fedilab, or any Mastodon-compatible client
- OAuth2 with PKCE (S256) — app registration, authorization, token exchange
- HTML+JS redirect for native Android apps (Chrome Custom Tabs block 302 to custom URI schemes)
- Home, public, and hashtag timelines with chronological published-date pagination
- Status creation via Micropub pipeline — posts flow through Indiekit → content file → AP syndication
- URL auto-linkification and @mention extraction in posted content
- Thread context (ancestors + descendants)
- Remote profile resolution via Fedify WebFinger with follower/following/post counts from AP collections
- Account stats enrichment — embedded account data in timeline responses includes real counts
- Favourite, boost, bookmark interactions federated via Fedify AP activities
- Notifications with type filtering
- Search across accounts, statuses, and hashtags with remote resolution
- Domain blocks API
- Timeline backfill from posts collection on startup (bookmarks, likes, reposts get synthesized content)
- In-memory account stats cache (500 entries, 1h TTL) for performance
**Admin UI**
- Dashboard with follower/following counts and recent activity
- Profile editor (name, bio, avatar, header, profile links with rel="me" verification)
@@ -117,6 +134,7 @@ Private ActivityPub messages (messages addressed only to your actor, with no `as
- Featured tags (hashtag collection)
- Activity log (inbound/outbound)
- Follower and following lists with source tracking
- Federation management page with moderation overview (blocked servers, blocked accounts, muted)
## Requirements

View File

@@ -855,11 +855,11 @@ export default class ActivityPubEndpoint {
);
// Resolve the remote actor to get their inbox
// Use authenticated document loader for servers requiring Authorized Fetch
// lookupWithSecurity handles signed→unsigned fallback automatically
const documentLoader = await ctx.getDocumentLoader({
identifier: handle,
});
const remoteActor = await lookupWithSecurity(ctx,actorUrl, {
const remoteActor = await lookupWithSecurity(ctx, actorUrl, {
documentLoader,
});
if (!remoteActor) {

View File

@@ -60,7 +60,8 @@ export function resolveController(mountPath, plugin) {
let object;
try {
object = await lookupWithSecurity(ctx,lookupInput, { documentLoader });
// lookupWithSecurity handles signed→unsigned fallback automatically
object = await lookupWithSecurity(ctx, lookupInput, { documentLoader });
} catch (error) {
console.warn(
`[resolve] lookupObject failed for "${query}":`,

View File

@@ -8,6 +8,8 @@
*/
import { remoteActorId } from "./id-mapping.js";
import { remoteActorId } from "./id-mapping.js";
const CACHE_TTL_MS = 60 * 60 * 1000; // 1 hour
const MAX_ENTRIES = 500;

View File

@@ -11,6 +11,7 @@ import { accountId, remoteActorId } from "../helpers/id-mapping.js";
import { getActorUrlFromId } from "../helpers/account-cache.js";
import { buildPaginationQuery, parseLimit, setPaginationHeaders } from "../helpers/pagination.js";
import { resolveRemoteAccount } from "../helpers/resolve-account.js";
import { getActorUrlFromId } from "../helpers/account-cache.js";
const router = express.Router(); // eslint-disable-line new-cap
@@ -731,6 +732,10 @@ async function resolveActorUrl(id, collections) {
return profile.url;
}
// Check account cache reverse lookup (populated by resolveRemoteAccount)
const cachedUrl = getActorUrlFromId(id);
if (cachedUrl) return cachedUrl;
// Check followers
const followers = await collections.ap_followers.find({}).toArray();
for (const f of followers) {

View File

@@ -105,7 +105,22 @@ router.get("/api/v1/timelines/public", async (req, res, next) => {
visibility: "public",
};
// Only original posts (exclude boosts from public timeline unless local=true)
// Local timeline: only posts from the local instance author
if (req.query.local === "true") {
const profile = await collections.ap_profile.findOne({});
if (profile?.url) {
baseFilter["author.url"] = profile.url;
}
}
// Remote-only: exclude local author posts
if (req.query.remote === "true") {
const profile = await collections.ap_profile.findOne({});
if (profile?.url) {
baseFilter["author.url"] = { $ne: profile.url };
}
}
if (req.query.only_media === "true") {
baseFilter.$or = [
{ "photo.0": { $exists: true } },