mirror of
https://github.com/svemagie/indiekit-endpoint-activitypub.git
synced 2026-04-02 15:44:58 +02:00
Merge remote-tracking branch 'upstream/main'
This commit is contained in:
92
CLAUDE.md
92
CLAUDE.md
@@ -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
|
||||
|
||||
|
||||
20
README.md
20
README.md
@@ -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
|
||||
|
||||
|
||||
4
index.js
4
index.js
@@ -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) {
|
||||
|
||||
@@ -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}":`,
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 } },
|
||||
|
||||
Reference in New Issue
Block a user