merge: upstream c1a6f7e — Fedify 2.1.0, 5 FEPs, security/perf audit, v3.9.x

Upstream commits merged (0820067..c1a6f7e):
- Fedify 2.1.0 upgrade (FEP-5feb, FEP-f1d5/0151, FEP-4f05 Tombstone,
  FEP-3b86 Activity Intents, FEP-8fcf Collection Sync)
- Comprehensive security/perf audit: XSS/CSRF fixes, OAuth scopes,
  rate limiting, secret hashing, token expiry/rotation, SSRF fix
- Architecture refactoring: syndicator.js, batch-broadcast.js,
  init-indexes.js, federation-actions.js; index.js -35%
- CSS split into 15 feature-scoped files + reader-interactions.js
- Mastodon API status creation: content-warning field, linkify fix

Fork-specific resolutions:
- syndicator.js: added addTimelineItem mirror for own Micropub posts
- syndicator.js: fixed missing await on jf2ToAS2Activity (async fn)
- statuses.js: kept DM path, pin/unpin routes, edit post route,
  processStatusContent (used by edit), addTimelineItem/lookupWithSecurity/
  addNotification imports
- compose.js: kept addNotification + added federation-actions.js imports
- enrich-accounts.js: kept cache-first approach for avatar updates
- ap-notification-card.njk: kept DM lock icon (🔒) for isDirect mentions
This commit is contained in:
svemagie
2026-03-27 09:30:34 +01:00
60 changed files with 5672 additions and 1833 deletions
+2
View File
@@ -1 +1,3 @@
node_modules
.playwright-cli/
.playwright-mcp/
+76 -15
View File
@@ -14,15 +14,19 @@ An Indiekit plugin that adds full ActivityPub federation via [Fedify](https://fe
## Architecture Overview
```
index.js ← Plugin entry, route registration, syndicator
index.js ← Plugin entry, route registration, lifecycle orchestration
├── lib/federation-setup.js ← Fedify Federation instance, dispatchers, collections
├── lib/federation-bridge.js ← Express ↔ Fedify request/response bridge
├── lib/federation-actions.js ← Facade for controller federation access (context creation, actor resolution)
├── lib/inbox-listeners.js ← Fedify inbox listener registration + reply forwarding
├── lib/inbox-handlers.js ← Async inbox activity handlers (Create, Like, Announce, etc.)
├── lib/inbox-queue.js ← Persistent MongoDB-backed async inbox processing queue
├── lib/outbox-failure.js ← Outbox delivery failure handling (410 cleanup, 404 strikes, strike reset)
├── lib/batch-broadcast.js ← Shared batch delivery to followers (dedup, batching, logging)
├── lib/jf2-to-as2.js ← JF2 → ActivityStreams conversion (plain JSON + Fedify vocab)
├── lib/syndicator.js ← Indiekit syndicator factory (JF2→AS2, mention resolution, delivery)
├── lib/kv-store.js ← MongoDB-backed KvStore for Fedify (get/set/delete/list)
├── lib/init-indexes.js ← MongoDB index creation (idempotent startup)
├── lib/activity-log.js ← Activity logging to ap_activities
├── lib/item-processing.js ← Unified item processing pipeline (moderation, quotes, interactions, rendering)
├── lib/timeline-store.js ← Timeline item extraction + sanitization
@@ -117,14 +121,15 @@ index.js ← Plugin entry, route registration, syndicat
## Data Flow
```
Outbound: Indiekit post → syndicator.syndicate() → jf2ToAS2Activity() → ctx.sendActivity() → follower inboxes
Outbound: Indiekit post → syndicator.js syndicate() → jf2ToAS2Activity() → ctx.sendActivity() → follower inboxes
Broadcast (Update/Delete) → batch-broadcast.js → deduplicated shared inbox delivery
Delivery failure → outbox-failure.js → 410: full cleanup | 404: strike system → eventual cleanup
Inbound: Remote inbox POST → Fedify → inbox-listeners.js → ap_inbox_queue → inbox-handlers.js → MongoDB
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
POST /api/v1/statuses → Micropub pipeline → content file → Eleventy rebuild → syndication → AP delivery
All views (reader, explore, tag timeline, hashtag explore, API endpoints) share a single
processing pipeline via item-processing.js:
@@ -156,6 +161,7 @@ processing pipeline via item-processing.js:
| `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_tombstones` | Tombstone records for soft-deleted posts (FEP-4f05) | `url` (unique) |
| `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` |
@@ -214,12 +220,11 @@ Express 5 removed the `"back"` magic keyword from `response.redirect()`. It's tr
JSON-LD compaction collapses single-element arrays to plain objects. Mastodon's `update_account_fields` checks `attachment.is_a?(Array)` and silently skips if it's not an array. `sendFedifyResponse()` in `federation-bridge.js` forces `attachment` to always be an array.
### 10. WORKAROUND: Endpoints `as:Endpoints` Type Stripping
### 10. REMOVED: Endpoints `as:Endpoints` Type Stripping (Fixed in Fedify 2.1.0)
**File:** `lib/federation-bridge.js` (in `sendFedifyResponse()`)
**Upstream issue:** [fedify#576](https://github.com/fedify-dev/fedify/issues/576) — FIXED in Fedify 2.1.0
**Workaround:** `delete json.endpoints.type` strips the invalid `"type": "as:Endpoints"` from actor JSON.
**Remove when:** Upgrading to Fedify ≥ 2.1.0.
**Previous workaround** in `federation-bridge.js`**REMOVED**.
Fedify 2.1.0 now omits the invalid `"type": "as:Endpoints"` from serialized actor JSON. No workaround needed.
### 11. KNOWN ISSUE: PropertyValue Attachment Type Validation
@@ -415,16 +420,17 @@ The Mastodon Client API is mounted at `/` (domain root) via `Indiekit.addEndpoin
- **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
### 35. Mastodon API — Content Processing (v3.9.4+)
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
- Content is provided to Micropub as `{ text, html }` with pre-linkified URLs (Micropub's markdown-it doesn't have `linkify: true`)
- `@user@domain` mentions are preserved as plain text — the AP syndicator resolves them via WebFinger for federation delivery
- Content warnings use `content-warning` field (not `summary`) to match the native reader and AP syndicator expectations
- No `ap_timeline` entry is created — the post appears in the timeline after the syndication round-trip (Eleventy rebuild → syndication webhook → AP delivery → inbox)
- A minimal Mastodon Status object is returned immediately to the client for UI feedback
- `mp-syndicate-to` is set to the AP syndicator UID (posts from Mastodon clients syndicate to fediverse only)
**Previous behavior (pre-3.9.4):** The handler created an `ap_timeline` entry immediately and used `processStatusContent()` to linkify URLs with hardcoded `/@username` patterns. This caused: (1) posts appearing in timeline before syndication, (2) broken mention URLs for non-Mastodon servers, (3) links lost in the Micropub content file.
## Date Handling Convention
@@ -542,6 +548,22 @@ On restart, `refollow:pending` entries are reset to `import` to prevent stale cl
| `unfurl.js` | Open Graph metadata extraction for link previews |
| `express` | Route handling (peer: Indiekit provides it) |
## Standards Compliance
| FEP | Name | Status | Implementation |
|-----|------|--------|----------------|
| FEP-8b32 | Object Integrity Proofs | Full | Fedify signs all outbound activities with Ed25519 |
| FEP-521a | Multiple key pairs (Multikey) | Full | RSA for HTTP Signatures + Ed25519 for OIP |
| FEP-fe34 | Origin-based security | Full | `lookupWithSecurity()` in `lookup-helpers.js` |
| FEP-8fcf | Collection Sync | Outbound | `syncCollection: true` on `sendActivity()` — receiving side NOT implemented |
| FEP-5feb | Search indexing consent | Full | `indexable: true`, `discoverable: true` on actor in `federation-setup.js` |
| FEP-f1d5 | Enhanced NodeInfo | Full | `setNodeInfoDispatcher()` in `federation-setup.js` |
| FEP-4f05 | Soft delete / Tombstone | Full | `lib/storage/tombstones.js` + 410 in `contentNegotiationRoutes` |
| FEP-3b86 | Activity Intents | Full | WebFinger links + `authorize-interaction.js` intent routing |
| FEP-044f | Quote posts | Full | `quoteUrl` extraction + `ap-quote-embed.njk` rendering |
| FEP-c0e0 | Emoji reactions | Vocab only | Fedify provides `EmojiReact` class, no UI in plugin |
| FEP-5711 | Conversation threads | Vocab only | Fedify provides threading vocab |
## Configuration Options
```javascript
@@ -619,6 +641,45 @@ curl -s "https://rmendes.net/nodeinfo/2.1" | jq .
- `@_followback@tags.pub` does not send Follow activities back despite accepting ours
- Both suggest tags.pub's outbound delivery is broken — zero inbound requests from `activitypub-bot` user-agent have been observed
### 37. Unverified Delete Activities (Fedify 2.1.0+)
`onUnverifiedActivity()` in `federation-setup.js` handles Delete activities from actors whose signing keys return 404/410. When an account is permanently deleted, the remote server sends a Delete activity but the actor's key endpoint is gone, so HTTP Signature verification fails. The handler checks `reason.type === "keyFetchError"` with status 404/410, cleans up the actor's data (followers, timeline items, notifications), and returns 202 Accepted.
### 38. FEP-8fcf Collection Synchronization — Outbound Only
We pass `syncCollection: true` to Fedify's `sendActivity()` for outbound activities, which attaches `Collection-Synchronization` headers with partial follower digests (XOR'd SHA-256 hashes). However, the **receiving side** (parsing inbound headers, digest comparison, reconciliation) is NOT implemented by Fedify or by us. Remote servers that send Collection-Synchronization headers to us will have them ignored. Full FEP-8fcf compliance would require a `/followers-sync` endpoint and a reconciliation scheduler.
## Form Handling Convention
Two form patterns are used in this plugin. New forms should follow the appropriate pattern.
### Pattern 1: Traditional POST (data mutation forms)
Used for: compose, profile editor, migration alias, notification mark-read/clear.
- Standard `<form method="POST" action="...">`
- CSRF via `<input type="hidden" name="_csrf" value="...">`
- Server processes, then redirects (PRG pattern)
- Success/error feedback via Indiekit's notification banner system
- Uses Indiekit form macros (`input`, `textarea`, `button`) where available
### Pattern 2: Alpine.js Fetch (in-page CRUD operations)
Used for: moderation add/remove keyword/server, tab management, federation actions.
- Alpine.js `@submit.prevent` or `@click` handlers
- CSRF via `X-CSRF-Token` header in `fetch()` call
- Inline error display with `x-show="error"` and `role="alert"`
- Optimistic UI with rollback on failure
- No page reload — DOM updates in place
### Rules
- Do NOT mix patterns on the same page (one pattern per form)
- All forms MUST include CSRF protection (hidden field OR header)
- Error feedback: Pattern 1 uses redirect + banner, Pattern 2 uses inline `x-show="error"`
- Success feedback: Pattern 1 uses redirect + banner, Pattern 2 uses inline DOM update or element removal
## CSS Conventions
The reader CSS (`assets/reader.css`) uses Indiekit's theme custom properties for automatic dark mode support:
+43 -10
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. Includes a Mastodon-compatible Client API so you can use Phanpy, Elk, Moshidon, Fedilab, and other Mastodon clients with your own AP instance.
ActivityPub federation endpoint for [Indiekit](https://getindiekit.com), built on [Fedify](https://fedify.dev) 2.1. 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.
@@ -119,13 +119,17 @@ Private ActivityPub messages (messages addressed only to your actor, with no `as
- 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
- Account stats enrichment — cached account data applied immediately; uncached accounts resolved in background
- 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
- OAuth2 scope enforcement — read/write scope validation on all API routes
- Rate limiting — configurable limits on API, auth, and app registration endpoints
- Access token expiry (1 hour) with refresh token rotation (90 days)
- PKCE (S256) and CSRF protection on authorization flow
**Admin UI**
- Dashboard with follower/following counts and recent activity
@@ -136,10 +140,39 @@ Private ActivityPub messages (messages addressed only to your actor, with no `as
- Follower and following lists with source tracking
- Federation management page with moderation overview (blocked servers, blocked accounts, muted)
**Standards Compliance**
Core protocols and Fediverse Enhancement Proposals (FEPs) supported:
| Standard | Name | Status | Provider |
|----------|------|--------|----------|
| [ActivityPub](https://www.w3.org/TR/activitypub/) | W3C ActivityPub | Full (server-to-server) | Fedify 2.1 |
| [ActivityStreams 2.0](https://www.w3.org/TR/activitystreams-core/) | W3C Activity Streams | Full | Fedify 2.1 |
| [HTTP Signatures](https://datatracker.ietf.org/doc/html/draft-cavage-http-signatures) | draft-cavage HTTP Signatures | Full | Fedify 2.1 |
| [RFC 9421](https://www.rfc-editor.org/rfc/rfc9421) | HTTP Message Signatures | Full (with Accept-Signature negotiation) | Fedify 2.1 |
| [WebFinger](https://www.rfc-editor.org/rfc/rfc7033) | RFC 7033 WebFinger | Full | Fedify 2.1 |
| [NodeInfo 2.1](https://nodeinfo.diaspora.software/) | Server metadata discovery | Full (enriched) | Plugin |
| [FEP-8b32](https://w3id.org/fep/8b32) | Object Integrity Proofs (Ed25519) | Full | Fedify 2.1 |
| [FEP-521a](https://w3id.org/fep/521a) | Multiple key pairs (Multikey) | Full | Fedify 2.1 |
| [FEP-fe34](https://w3id.org/fep/fe34) | Origin-based security model | Full | Fedify 2.1 + Plugin |
| [FEP-8fcf](https://w3id.org/fep/8fcf) | Followers collection synchronization | Outbound only | Fedify 2.1 |
| [FEP-5feb](https://w3id.org/fep/5feb) | Search indexing consent | Full (`indexable`, `discoverable`) | Plugin |
| [FEP-f1d5](https://w3id.org/fep/f1d5) | Enhanced NodeInfo 2.1 | Full (metadata, staff accounts) | Plugin |
| [FEP-4f05](https://w3id.org/fep/4f05) | Soft delete with Tombstone | Full (410 + Tombstone JSON-LD) | Plugin |
| [FEP-3b86](https://w3id.org/fep/3b86) | Activity Intents | Full (Follow, Create, Like, Announce) | Plugin |
| [FEP-044f](https://w3id.org/fep/044f) | Quote posts | Full (Mastodon, Misskey, Fedibird formats) | Fedify 2.1 + Plugin |
| [FEP-c0e0](https://w3id.org/fep/c0e0) | Emoji reactions (EmojiReact) | Vocab support (no UI) | Fedify 2.1 |
| [FEP-5711](https://w3id.org/fep/5711) | Conversation threads | Vocab support | Fedify 2.1 |
| [Linked Data Signatures](https://w3c-dvcg.github.io/ld-signatures/) | RsaSignature2017 (legacy) | Full (outbound signing) | Fedify 2.1 |
**Status key:** *Full* = complete implementation, *Outbound only* = sending side only, *Vocab support* = types available but no dedicated UI/logic.
**Provider key:** *Fedify 2.1* = handled by the Fedify framework, *Plugin* = implemented in this plugin, *Fedify 2.1 + Plugin* = framework provides primitives, plugin wires them together.
## Requirements
- [Indiekit](https://getindiekit.com) v1.0.0-beta.25+
- [Fedify](https://fedify.dev) 2.0+ (bundled as dependency)
- [Fedify](https://fedify.dev) 2.1+ (bundled as dependency)
- Node.js >= 22
- MongoDB (used by Indiekit)
- Redis (recommended for production delivery queue; in-process queue available for development)
@@ -371,6 +404,10 @@ The plugin creates these collections automatically:
| `ap_blocked_servers` | Blocked server domains (instance-level blocks) |
| `ap_key_freshness` | Tracks when remote actor keys were last verified |
| `ap_inbox_queue` | Persistent async inbox processing queue |
| `ap_tombstones` | Tombstone records for soft-deleted posts (FEP-4f05) |
| `ap_oauth_apps` | Mastodon API client app registrations |
| `ap_oauth_tokens` | OAuth2 authorization codes and access tokens |
| `ap_markers` | Read position markers for Mastodon API clients |
## Supported Post Types
@@ -390,7 +427,7 @@ Categories are converted to `Hashtag` tags (nested paths like `on/art/music` are
## Fedify Workarounds and Implementation Notes
This plugin uses [Fedify](https://fedify.dev) 2.0 but carries several workarounds for issues in Fedify or its Express integration. These are documented here so they can be revisited when Fedify upgrades.
This plugin uses [Fedify](https://fedify.dev) 2.1 but carries several workarounds for issues in Fedify or its Express integration. These are documented here so they can be revisited when Fedify upgrades.
### Custom Express Bridge (instead of `@fedify/express`)
@@ -412,14 +449,11 @@ Mastodon's `update_account_fields` checks `attachment.is_a?(Array)` and silently
**Revisit when:** Fedify adds an option to preserve arrays during JSON-LD serialization, or Mastodon fixes their array check.
### Endpoints `as:Endpoints` Type Stripping
### Endpoints `as:Endpoints` Type Stripping — REMOVED
**File:** `lib/federation-bridge.js` (in `sendFedifyResponse()`)
**Upstream issue:** [fedify#576](https://github.com/fedify-dev/fedify/issues/576) — FIXED in Fedify 2.1.0
Fedify serializes the `endpoints` object with `"type": "as:Endpoints"`, which is not a valid ActivityStreams type. browser.pub rejects this. The bridge strips the `type` field from the `endpoints` object before sending.
**Remove when:** Upgrading to Fedify ≥ 2.1.0.
This workaround has been removed. Fedify 2.1.0 now omits the invalid `"type": "as:Endpoints"` from serialized actor JSON.
### PropertyValue Attachment Type (Known Issue)
@@ -466,7 +500,6 @@ This is not a bug — Fedify requires explicit opt-in for signed fetches. But it
- **Single actor** — One fediverse identity per Indiekit instance
- **No Authorized Fetch enforcement** — `.authorize()` disabled on actor dispatcher (see workarounds above)
- **No image upload in reader** — Compose form is text-only
- **No custom emoji rendering** — Custom emoji shortcodes display as text
- **In-process queue without Redis** — Activities may be lost on restart
- **Existing DMs before this fork** — Notifications received before upgrading to this fork lack `isDirect`/`senderActorUrl` and won't appear in the Direct tab (resend or patch manually in MongoDB)
- **No read receipts** — Outbound DMs are stored locally but the recipient receives no read-receipt activity
+144
View File
@@ -0,0 +1,144 @@
/**
* ActivityPub Reader Styles
* Card-based layout inspired by Phanpy/Elk
* Uses Indiekit CSS custom properties for automatic dark mode support
*/
/* ==========================================================================
Breadcrumb Navigation
========================================================================== */
.ap-breadcrumb {
display: flex;
align-items: center;
gap: var(--space-xs);
margin-bottom: var(--space-m);
font-size: var(--font-size-s);
color: var(--color-on-offset);
}
.ap-breadcrumb a {
color: var(--color-primary-on-background);
text-decoration: none;
}
.ap-breadcrumb a:hover {
text-decoration: underline;
}
.ap-breadcrumb__separator {
color: var(--color-on-offset);
}
.ap-breadcrumb__current {
color: var(--color-on-background);
font-weight: 600;
}
/* ==========================================================================
Fediverse Lookup
========================================================================== */
.ap-lookup {
display: flex;
gap: var(--space-xs);
margin-bottom: var(--space-m);
}
.ap-lookup__input {
border: var(--border-width-thin) solid var(--color-outline);
border-radius: var(--border-radius-small);
background: var(--color-offset);
box-sizing: border-box;
color: var(--color-on-background);
font-family: inherit;
font-size: var(--font-size-m);
padding: var(--space-s) var(--space-m);
width: 100%;
}
.ap-lookup__input::placeholder {
color: var(--color-on-offset);
}
.ap-lookup__input:focus {
outline: 2px solid var(--color-primary);
outline-offset: -1px;
border-color: var(--color-primary);
}
.ap-lookup__btn {
padding: var(--space-s) var(--space-m);
border: var(--border-width-thin) solid var(--color-primary);
border-radius: var(--border-radius-small);
background: var(--color-primary);
color: var(--color-on-primary);
font-size: var(--font-size-m);
font-family: inherit;
font-weight: 600;
cursor: pointer;
white-space: nowrap;
}
.ap-lookup__btn:hover {
opacity: 0.9;
}
/* ==========================================================================
Tab Navigation
========================================================================== */
.ap-tabs {
border-bottom: var(--border-width-thin) solid var(--color-outline);
display: flex;
gap: var(--space-xs);
margin-bottom: var(--space-m);
overflow-x: auto;
-webkit-overflow-scrolling: touch;
}
.ap-tab {
border-bottom: var(--border-width-thick) solid transparent;
color: var(--color-on-offset);
font-size: var(--font-size-m);
padding: var(--space-s) var(--space-m);
text-decoration: none;
transition:
color 0.2s ease,
border-color 0.2s ease;
white-space: nowrap;
}
.ap-tab:hover {
color: var(--color-on-background);
}
.ap-tab--active {
border-bottom-color: var(--color-primary);
color: var(--color-primary-on-background);
font-weight: 600;
}
.ap-tab__count {
background: var(--color-offset-variant);
border-radius: var(--border-radius-large);
font-size: var(--font-size-xs);
font-weight: 600;
margin-left: var(--space-xs);
padding: 1px 6px;
}
.ap-tab--active .ap-tab__count {
background: var(--color-primary);
color: var(--color-on-primary, var(--color-neutral99));
}
/* ==========================================================================
Timeline Layout
========================================================================== */
.ap-timeline {
display: flex;
flex-direction: column;
gap: var(--space-m);
}
+377
View File
@@ -0,0 +1,377 @@
/* ==========================================================================
Item Card — Base
========================================================================== */
.ap-card {
background: var(--color-offset);
border: var(--border-width-thin) solid var(--color-outline);
border-left: 3px solid var(--color-outline);
border-radius: var(--border-radius-small);
overflow: hidden;
padding: var(--space-m);
box-shadow: 0 1px 2px hsl(var(--tint-neutral) 10% / 0.04);
transition:
box-shadow 0.2s ease,
border-color 0.2s ease;
}
.ap-card:hover {
border-color: var(--color-outline-variant);
border-left-color: var(--color-outline-variant);
box-shadow: 0 2px 8px hsl(var(--tint-neutral) 10% / 0.08);
}
/* ==========================================================================
Item Card — Post Type Differentiation
========================================================================== */
/* Notes: default purple-ish accent (the most common type) */
.ap-card--note {
border-left-color: var(--color-purple45);
}
.ap-card--note:hover {
border-left-color: var(--color-purple45);
}
/* Articles: green accent (long-form content stands out) */
.ap-card--article {
border-left-color: var(--color-green50);
}
.ap-card--article:hover {
border-left-color: var(--color-green50);
}
/* Boosts: yellow accent (shared content) */
.ap-card--boost {
border-left-color: var(--color-yellow50);
}
.ap-card--boost:hover {
border-left-color: var(--color-yellow50);
}
/* Replies: blue accent (via primary color) */
.ap-card--reply {
border-left-color: var(--color-primary);
}
.ap-card--reply:hover {
border-left-color: var(--color-primary);
}
/* ==========================================================================
Boost Header
========================================================================== */
.ap-card__boost {
color: var(--color-on-offset);
font-size: var(--font-size-s);
margin-bottom: var(--space-s);
padding-bottom: var(--space-xs);
}
.ap-card__boost a {
color: var(--color-on-offset);
font-weight: 600;
text-decoration: none;
}
.ap-card__boost a:hover {
color: var(--color-on-background);
text-decoration: underline;
}
/* ==========================================================================
Reply Context
========================================================================== */
.ap-card__reply-to {
color: var(--color-on-offset);
font-size: var(--font-size-s);
margin-bottom: var(--space-s);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.ap-card__reply-to a {
color: var(--color-primary-on-background);
text-decoration: none;
}
.ap-card__reply-to a:hover {
text-decoration: underline;
}
/* ==========================================================================
Author Header
========================================================================== */
.ap-card__author {
align-items: center;
display: flex;
gap: var(--space-s);
margin-bottom: var(--space-s);
}
.ap-card__avatar-wrap {
flex-shrink: 0;
height: 44px;
position: relative;
width: 44px;
}
.ap-card__avatar {
border: var(--border-width-thin) solid var(--color-outline);
border-radius: 50%;
height: 44px;
object-fit: cover;
width: 44px;
}
.ap-card__avatar-wrap > img {
position: absolute;
inset: 0;
z-index: 1;
}
.ap-card__avatar--default {
align-items: center;
background: var(--color-offset-variant);
color: var(--color-on-offset);
display: inline-flex;
font-size: 1.1em;
font-weight: 600;
justify-content: center;
}
.ap-card__author-info {
display: flex;
flex-direction: column;
flex: 1;
gap: 1px;
min-width: 0;
}
.ap-card__author-name {
font-size: 0.95em;
font-weight: 600;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.ap-card__author-name a {
color: inherit;
text-decoration: none;
}
.ap-card__author-name a:hover {
text-decoration: underline;
}
.ap-card__bot-badge {
display: inline-block;
font-size: 0.6rem;
font-weight: 700;
line-height: 1;
padding: 0.15em 0.35em;
margin-left: 0.3em;
border: var(--border-width-thin) solid var(--color-on-offset);
border-radius: var(--border-radius-small);
color: var(--color-on-offset);
vertical-align: middle;
text-transform: uppercase;
letter-spacing: 0.03em;
}
.ap-card__author-handle {
color: var(--color-on-offset);
font-size: var(--font-size-s);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.ap-card__timestamp {
color: var(--color-on-offset);
flex-shrink: 0;
font-size: var(--font-size-s);
}
.ap-card__edited {
font-size: var(--font-size-xs);
margin-left: 0.2em;
}
.ap-card__visibility {
font-size: var(--font-size-xs);
margin-left: 0.3em;
opacity: 0.7;
}
.ap-card__timestamp-link {
color: inherit;
text-decoration: none;
display: flex;
align-items: center;
gap: 0;
}
.ap-card__timestamp-link:hover {
text-decoration: underline;
color: var(--color-primary-on-background);
}
/* ==========================================================================
Post Title (Articles)
========================================================================== */
.ap-card__title {
font-size: var(--font-size-l);
font-weight: 600;
line-height: var(--line-height-tight);
margin-bottom: var(--space-s);
}
.ap-card__title a {
color: inherit;
text-decoration: none;
}
.ap-card__title a:hover {
text-decoration: underline;
}
/* ==========================================================================
Content
========================================================================== */
.ap-card__content {
color: var(--color-on-background);
line-height: calc(4 / 3 * 1em);
margin-bottom: var(--space-s);
overflow-wrap: break-word;
word-break: break-word;
}
.ap-card__content a {
color: var(--color-primary-on-background);
}
.ap-card__content p {
margin-bottom: var(--space-xs);
}
.ap-card__content p:last-child {
margin-bottom: 0;
}
.ap-card__content blockquote {
border-left: var(--border-width-thickest) solid var(--color-outline);
margin: var(--space-s) 0;
padding-left: var(--space-m);
}
.ap-card__content pre {
background: var(--color-offset-variant);
border-radius: var(--border-radius-small);
overflow-x: auto;
padding: var(--space-s);
}
.ap-card__content code {
background: var(--color-offset-variant);
border-radius: var(--border-radius-small);
font-size: 0.9em;
padding: 1px 4px;
}
.ap-card__content pre code {
background: none;
padding: 0;
}
.ap-card__content img {
border-radius: var(--border-radius-small);
height: auto;
max-width: 100%;
}
/* @mentions — keep inline, style as subtle links */
.ap-card__content .h-card {
display: inline;
}
.ap-card__content .h-card a,
.ap-card__content a.u-url.mention {
display: inline;
color: var(--color-on-offset);
text-decoration: none;
white-space: nowrap;
}
.ap-card__content .h-card a span,
.ap-card__content a.u-url.mention span {
display: inline;
}
.ap-card__content .h-card a:hover,
.ap-card__content a.u-url.mention:hover {
color: var(--color-primary-on-background);
text-decoration: underline;
}
/* Hashtag mentions — keep inline, subtle styling */
.ap-card__content a.mention.hashtag {
display: inline;
color: var(--color-on-offset);
text-decoration: none;
white-space: nowrap;
}
.ap-card__content a.mention.hashtag span {
display: inline;
}
.ap-card__content a.mention.hashtag:hover {
color: var(--color-primary-on-background);
text-decoration: underline;
}
/* Mastodon's invisible/ellipsis spans for long URLs */
.ap-card__content .invisible {
display: none;
}
.ap-card__content .ellipsis::after {
content: "…";
}
/* ==========================================================================
Content Warning
========================================================================== */
.ap-card__cw {
margin-bottom: var(--space-s);
}
.ap-card__cw-toggle {
background: var(--color-offset-variant);
border: var(--border-width-thin) solid var(--color-outline);
border-radius: var(--border-radius-small);
color: var(--color-on-background);
cursor: pointer;
display: block;
font-size: var(--font-size-s);
padding: var(--space-s) var(--space-m);
text-align: left;
transition: background 0.2s ease;
width: 100%;
}
.ap-card__cw-toggle:hover {
background: var(--color-offset-variant-darker);
}
+169
View File
@@ -0,0 +1,169 @@
/* ==========================================================================
Compose Form
========================================================================== */
.ap-compose__context {
background: var(--color-offset);
border-left: var(--border-width-thickest) solid var(--color-primary);
border-radius: var(--border-radius-small);
margin-bottom: var(--space-m);
padding: var(--space-m);
}
.ap-compose__context-label {
color: var(--color-on-offset);
font-size: var(--font-size-s);
margin-bottom: var(--space-xs);
}
.ap-compose__context-author a {
font-weight: 600;
text-decoration: none;
}
.ap-compose__context-text {
border: 0;
font-size: var(--font-size-s);
line-height: var(--line-height-loose);
margin: var(--space-xs) 0;
padding: 0;
}
.ap-compose__context-link {
color: var(--color-on-offset);
font-size: var(--font-size-s);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.ap-compose__form {
display: flex;
flex-direction: column;
gap: var(--space-m);
}
.ap-compose__editor {
position: relative;
}
.ap-compose__textarea {
background: var(--color-background);
border: var(--border-width-thick) solid var(--color-outline);
border-radius: var(--border-radius-small);
color: var(--color-on-background);
font-family: inherit;
font-size: var(--font-size-m);
line-height: var(--line-height-prose);
padding: var(--space-s);
resize: vertical;
width: 100%;
}
.ap-compose__textarea:focus {
border-color: var(--color-primary);
outline: var(--border-width-thick) solid var(--color-primary);
outline-offset: -2px;
}
.ap-compose__cw {
display: flex;
flex-direction: column;
gap: var(--space-xs);
}
.ap-compose__cw-toggle {
cursor: pointer;
display: flex;
align-items: center;
gap: var(--space-xs);
font-size: var(--font-size-s);
color: var(--color-on-offset);
}
.ap-compose__cw-input {
border: var(--border-width-thin) solid var(--color-outline);
border-radius: var(--border-radius-small);
background: var(--color-offset);
color: var(--color-on-background);
font: inherit;
font-size: var(--font-size-s);
padding: var(--space-s);
width: 100%;
}
.ap-compose__cw-input:focus {
border-color: var(--color-primary);
outline: none;
}
.ap-compose__visibility {
border: var(--border-width-thin) solid var(--color-outline);
border-radius: var(--border-radius-small);
display: flex;
flex-wrap: wrap;
gap: var(--space-s) var(--space-m);
padding: var(--space-m);
}
.ap-compose__visibility legend {
font-weight: 600;
}
.ap-compose__visibility-option {
cursor: pointer;
display: flex;
align-items: center;
gap: var(--space-xs);
font-size: var(--font-size-s);
}
.ap-compose__syndication {
border: var(--border-width-thin) solid var(--color-outline);
border-radius: var(--border-radius-small);
display: flex;
flex-direction: column;
gap: var(--space-xs);
padding: var(--space-m);
}
.ap-compose__syndication legend {
font-weight: 600;
}
.ap-compose__syndication-target {
cursor: pointer;
display: flex;
gap: var(--space-xs);
}
.ap-compose__actions {
align-items: center;
display: flex;
gap: var(--space-m);
}
.ap-compose__submit {
background: var(--color-primary);
border: 0;
border-radius: var(--border-radius-small);
color: var(--color-on-primary, var(--color-neutral99));
cursor: pointer;
font-size: var(--font-size-m);
font-weight: 600;
padding: var(--space-s) var(--space-l);
}
.ap-compose__submit:hover {
opacity: 0.9;
}
.ap-compose__cancel {
color: var(--color-on-offset);
text-decoration: none;
}
.ap-compose__cancel:hover {
color: var(--color-on-background);
text-decoration: underline;
}
+94
View File
@@ -0,0 +1,94 @@
/* ==========================================================================
Dark Mode Overrides
Softens saturated colors that are uncomfortable on dark backgrounds.
Uses Indiekit's existing light-variant tokens (red80, green90, yellow90)
which are designed for dark surfaces.
========================================================================== */
@media (prefers-color-scheme: dark) {
/* --- Action button hover states: softer colors, more visible tinted backgrounds --- */
.ap-card__action--reply:hover {
background: color-mix(in srgb, var(--color-primary) 18%, transparent);
color: var(--color-primary-on-background);
}
.ap-card__action--boost:hover {
background: color-mix(in srgb, var(--color-green50) 18%, transparent);
color: var(--color-green90);
}
.ap-card__action--like:hover {
background: color-mix(in srgb, var(--color-red45) 18%, transparent);
color: var(--color-red80);
}
.ap-card__action--save:hover {
background: color-mix(in srgb, var(--color-primary) 18%, transparent);
color: var(--color-primary-on-background);
}
/* --- Active interaction states --- */
.ap-card__action--like.ap-card__action--active {
background: color-mix(in srgb, var(--color-red45) 18%, transparent);
color: var(--color-red80);
}
.ap-card__action--boost.ap-card__action--active {
background: color-mix(in srgb, var(--color-green50) 18%, transparent);
color: var(--color-green90);
}
.ap-card__action--save.ap-card__action--active {
background: color-mix(in srgb, var(--color-primary) 18%, transparent);
color: var(--color-primary-on-background);
}
/* --- Post-type left border accents: desaturated for dark surfaces --- */
.ap-card--note,
.ap-card--note:hover {
border-left-color: var(--color-purple90);
}
.ap-card--article,
.ap-card--article:hover {
border-left-color: var(--color-green90);
}
.ap-card--boost,
.ap-card--boost:hover {
border-left-color: var(--color-yellow90);
}
.ap-card--reply,
.ap-card--reply:hover {
border-left-color: var(--color-primary-on-background);
}
/* --- Notification unread glow: toned down --- */
.ap-notification--unread {
border-color: var(--color-yellow90);
box-shadow: 0 0 6px 0 color-mix(in srgb, var(--color-yellow50) 15%, transparent);
}
/* --- Post detail highlight ring: softened --- */
.ap-post-detail__main .ap-card {
border-color: color-mix(in srgb, var(--color-primary) 50%, transparent);
box-shadow: 0 0 0 1px color-mix(in srgb, var(--color-primary) 50%, transparent);
}
/* --- Card shadows: use light tint instead of black --- */
.ap-card {
box-shadow: 0 1px 2px hsl(var(--tint-neutral) 90% / 0.04);
}
.ap-card:hover {
box-shadow: 0 2px 8px hsl(var(--tint-neutral) 90% / 0.06);
}
/* --- Tab badge federated: soften purple --- */
.ap-tab__badge--federated {
color: var(--color-purple90);
background: color-mix(in srgb, var(--color-purple45) 18%, transparent);
}
}
+530
View File
@@ -0,0 +1,530 @@
/* ==========================================================================
Explore Page
========================================================================== */
.ap-explore-header {
margin-bottom: var(--space-m);
}
.ap-explore-header__title {
font-size: var(--font-size-xl);
margin: 0 0 var(--space-xs);
}
.ap-explore-header__desc {
color: var(--color-on-offset);
font-size: var(--font-size-s);
margin: 0;
}
.ap-explore-form {
background: var(--color-offset);
border: var(--border-width-thin) solid var(--color-outline);
border-radius: var(--border-radius-small);
margin-bottom: var(--space-m);
padding: var(--space-m);
}
.ap-explore-form__row {
align-items: center;
display: flex;
gap: var(--space-s);
flex-wrap: wrap;
}
.ap-explore-form__input {
border: var(--border-width-thin) solid var(--color-outline);
border-radius: var(--border-radius-small);
box-sizing: border-box;
font-size: var(--font-size-m);
min-width: 0;
padding: var(--space-xs) var(--space-s);
width: 100%;
}
.ap-explore-form__scope {
display: flex;
gap: var(--space-s);
}
.ap-explore-form__scope-label {
align-items: center;
cursor: pointer;
display: flex;
font-size: var(--font-size-s);
gap: var(--space-xs);
}
.ap-explore-form__btn {
background: var(--color-primary);
border: none;
border-radius: var(--border-radius-small);
color: var(--color-on-primary);
cursor: pointer;
font-size: var(--font-size-s);
padding: var(--space-xs) var(--space-m);
white-space: nowrap;
}
.ap-explore-form__btn:hover {
opacity: 0.85;
}
.ap-explore-error {
background: color-mix(in srgb, var(--color-error) 10%, transparent);
border: var(--border-width-thin) solid var(--color-error);
border-radius: var(--border-radius-small);
color: var(--color-error);
margin-bottom: var(--space-m);
padding: var(--space-s) var(--space-m);
}
@media (max-width: 640px) {
.ap-explore-form__row {
flex-direction: column;
align-items: stretch;
}
.ap-explore-form__btn {
width: 100%;
}
}
/* ---------- Autocomplete dropdown ---------- */
.ap-explore-autocomplete {
flex: 1;
min-width: 0;
position: relative;
}
.ap-explore-autocomplete__dropdown {
background: var(--color-background);
border: var(--border-width-thin) solid var(--color-outline);
border-radius: var(--border-radius-small);
box-shadow: 0 4px 12px hsl(var(--tint-neutral) 10% / 0.15);
left: 0;
max-height: 320px;
overflow-y: auto;
position: absolute;
right: 0;
top: 100%;
z-index: 100;
}
.ap-explore-autocomplete__item {
align-items: center;
background: none;
border: none;
color: var(--color-on-background);
cursor: pointer;
display: flex;
font-family: inherit;
font-size: var(--font-size-s);
gap: var(--space-s);
padding: var(--space-s) var(--space-m);
text-align: left;
width: 100%;
}
.ap-explore-autocomplete__item:hover,
.ap-explore-autocomplete__item--highlighted {
background: var(--color-offset);
}
.ap-explore-autocomplete__domain {
flex-shrink: 0;
font-weight: 600;
}
.ap-explore-autocomplete__meta {
color: var(--color-on-offset);
display: flex;
flex: 1;
gap: var(--space-xs);
min-width: 0;
}
.ap-explore-autocomplete__software {
background: color-mix(in srgb, var(--color-primary) 12%, transparent);
border-radius: var(--border-radius-small);
font-size: var(--font-size-xs);
padding: 1px 6px;
white-space: nowrap;
}
.ap-explore-autocomplete__mau {
font-size: var(--font-size-xs);
white-space: nowrap;
}
.ap-explore-autocomplete__status {
flex-shrink: 0;
font-size: var(--font-size-s);
}
.ap-explore-autocomplete__checking {
opacity: 0.5;
}
/* ---------- Popular accounts autocomplete ---------- */
.ap-lookup-autocomplete {
flex: 1;
min-width: 0;
position: relative;
}
.ap-lookup-autocomplete__dropdown {
background: var(--color-background);
border: var(--border-width-thin) solid var(--color-outline);
border-radius: var(--border-radius-small);
box-shadow: 0 4px 12px hsl(var(--tint-neutral) 10% / 0.15);
left: 0;
max-height: 320px;
overflow-y: auto;
position: absolute;
right: 0;
top: 100%;
z-index: 100;
}
.ap-lookup-autocomplete__item {
align-items: center;
background: none;
border: none;
color: var(--color-on-background);
cursor: pointer;
display: flex;
font-family: inherit;
font-size: var(--font-size-s);
gap: var(--space-s);
padding: var(--space-s) var(--space-m);
text-align: left;
width: 100%;
}
.ap-lookup-autocomplete__item:hover,
.ap-lookup-autocomplete__item--highlighted {
background: var(--color-offset);
}
.ap-lookup-autocomplete__avatar {
border-radius: 50%;
flex-shrink: 0;
height: 28px;
object-fit: cover;
width: 28px;
}
.ap-lookup-autocomplete__info {
display: flex;
flex: 1;
flex-direction: column;
min-width: 0;
}
.ap-lookup-autocomplete__name {
font-weight: 600;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.ap-lookup-autocomplete__handle {
color: var(--color-on-offset);
font-size: var(--font-size-xs);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.ap-lookup-autocomplete__followers {
color: var(--color-on-offset);
flex-shrink: 0;
font-size: var(--font-size-xs);
white-space: nowrap;
}
/* ==========================================================================
Explore: Tabbed Design
========================================================================== */
/* Tab bar wrapper: enables position:relative for fade gradient overlay */
.ap-explore-tabs-container {
position: relative;
}
/* Tab bar with right-edge fade to indicate horizontal overflow */
.ap-explore-tabs-nav {
padding-right: var(--space-l);
position: relative;
}
.ap-explore-tabs-nav::after {
background: linear-gradient(to right, transparent, var(--color-background) 80%);
content: "";
height: 100%;
pointer-events: none;
position: absolute;
right: 0;
top: 0;
width: 40px;
}
/* Tab wrapper: holds tab button + reorder/close controls together */
.ap-tab-wrapper {
align-items: stretch;
display: inline-flex;
position: relative;
}
/* Show controls on hover or when the tab is active */
.ap-tab-controls {
align-items: center;
display: none;
gap: 1px;
}
.ap-tab-wrapper:hover .ap-tab-controls,
.ap-tab-wrapper:focus-within .ap-tab-controls {
display: flex;
}
/* Individual control buttons (↑ ↓ ×) */
.ap-tab-control {
background: none;
border: none;
color: var(--color-on-offset);
cursor: pointer;
font-size: var(--font-size-xs);
line-height: 1;
padding: 2px 4px;
}
.ap-tab-control:hover {
color: var(--color-on-background);
}
.ap-tab-control:disabled {
cursor: default;
opacity: 0.3;
}
.ap-tab-control--remove {
color: var(--color-on-offset);
font-size: var(--font-size-s);
}
.ap-tab-control--remove:hover {
color: var(--color-error);
}
/* Truncate long domain names in tab labels */
.ap-tab__label {
display: inline-block;
max-width: 150px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* Scope badges on instance tabs */
.ap-tab__badge {
border-radius: var(--border-radius-small);
font-size: 0.65em;
font-weight: 700;
letter-spacing: 0.02em;
margin-left: var(--space-xs);
padding: 1px 4px;
text-transform: uppercase;
vertical-align: middle;
}
.ap-tab__badge--local {
background: color-mix(in srgb, var(--color-primary) 15%, transparent);
color: var(--color-primary-on-background);
}
.ap-tab__badge--federated {
background: color-mix(in srgb, var(--color-purple45) 15%, transparent);
color: var(--color-purple45);
}
/* +# button for adding hashtag tabs */
.ap-tab--add {
font-family: monospace;
font-weight: 700;
letter-spacing: -0.05em;
}
/* Inline hashtag form that appears when +# is clicked */
.ap-tab-add-hashtag {
align-items: center;
display: inline-flex;
gap: var(--space-xs);
}
.ap-tab-hashtag-form {
align-items: center;
display: flex;
gap: var(--space-xs);
}
.ap-tab-hashtag-form__prefix {
color: var(--color-on-offset);
font-weight: 600;
}
.ap-tab-hashtag-form__input {
border: var(--border-width-thin) solid var(--color-outline);
border-radius: var(--border-radius-small);
font-family: inherit;
font-size: var(--font-size-s);
padding: 2px var(--space-s);
width: 8em;
}
.ap-tab-hashtag-form__input:focus {
border-color: var(--color-primary);
outline: 2px solid var(--color-primary);
outline-offset: -1px;
}
.ap-tab-hashtag-form__btn {
background: var(--color-primary);
border: none;
border-radius: var(--border-radius-small);
color: var(--color-on-primary);
cursor: pointer;
font-family: inherit;
font-size: var(--font-size-s);
padding: 2px var(--space-s);
white-space: nowrap;
}
.ap-tab-hashtag-form__btn:hover {
opacity: 0.85;
}
/* "Pin as tab" button in search results area */
.ap-explore-pin-bar {
margin-bottom: var(--space-s);
}
.ap-explore-pin-btn {
background: none;
border: var(--border-width-thin) solid var(--color-primary-on-background);
border-radius: var(--border-radius-small);
color: var(--color-primary-on-background);
cursor: pointer;
font-family: inherit;
font-size: var(--font-size-s);
padding: var(--space-xs) var(--space-m);
}
.ap-explore-pin-btn:hover {
background: color-mix(in srgb, var(--color-primary) 10%, transparent);
}
.ap-explore-pin-btn:disabled {
cursor: default;
opacity: 0.6;
}
/* Hashtag form row inside the search form */
.ap-explore-form__hashtag-row {
align-items: center;
display: flex;
flex-wrap: wrap;
gap: var(--space-xs);
margin-top: var(--space-s);
}
.ap-explore-form__hashtag-label {
color: var(--color-on-offset);
font-size: var(--font-size-s);
white-space: nowrap;
}
.ap-explore-form__hashtag-prefix {
color: var(--color-on-offset);
font-weight: 600;
}
.ap-explore-form__hashtag-hint {
color: var(--color-on-offset);
font-size: var(--font-size-xs);
flex-basis: 100%;
}
.ap-explore-form__input--hashtag {
max-width: 200px;
width: auto;
}
/* Tab panel containers */
.ap-explore-instance-panel,
.ap-explore-hashtag-panel {
min-height: 120px;
}
/* Loading state */
.ap-explore-tab-loading {
align-items: center;
color: var(--color-on-offset);
display: flex;
justify-content: center;
padding: var(--space-xl);
}
.ap-explore-tab-loading--more {
padding-block: var(--space-m);
}
.ap-explore-tab-loading__text {
font-size: var(--font-size-s);
}
/* Error state */
.ap-explore-tab-error {
align-items: center;
display: flex;
flex-direction: column;
gap: var(--space-s);
padding: var(--space-xl);
}
.ap-explore-tab-error__message {
color: var(--color-error);
font-size: var(--font-size-s);
margin: 0;
}
.ap-explore-tab-error__retry {
background: none;
border: var(--border-width-thin) solid var(--color-primary-on-background);
border-radius: var(--border-radius-small);
color: var(--color-primary-on-background);
cursor: pointer;
font-size: var(--font-size-s);
padding: var(--space-xs) var(--space-s);
}
.ap-explore-tab-error__retry:hover {
background: color-mix(in srgb, var(--color-primary) 10%, transparent);
}
/* Empty state */
.ap-explore-tab-empty {
color: var(--color-on-offset);
font-size: var(--font-size-s);
padding: var(--space-xl);
text-align: center;
}
/* Infinite scroll sentinel — zero height, invisible */
.ap-tab-sentinel {
height: 1px;
visibility: hidden;
}
+436
View File
@@ -0,0 +1,436 @@
/* ==========================================================================
Post Detail View — Thread Layout
========================================================================== */
.ap-post-detail__back {
margin-bottom: var(--space-m);
}
.ap-post-detail__back-link {
color: var(--color-primary-on-background);
font-size: var(--font-size-s);
text-decoration: none;
}
.ap-post-detail__back-link:hover {
text-decoration: underline;
}
.ap-post-detail__not-found {
background: var(--color-offset);
border-radius: var(--border-radius-small);
color: var(--color-on-offset);
padding: var(--space-l);
text-align: center;
}
.ap-post-detail__section-title {
color: var(--color-on-offset);
font-size: var(--font-size-s);
font-weight: 600;
margin: var(--space-m) 0 var(--space-s);
padding-bottom: var(--space-xs);
text-transform: uppercase;
letter-spacing: 0.05em;
}
/* Parent posts — indented with left border to show thread chain */
.ap-post-detail__parents {
border-left: 3px solid var(--color-outline);
margin-bottom: var(--space-s);
padding-left: var(--space-m);
}
.ap-post-detail__parent-item .ap-card {
opacity: 0.85;
}
/* Main post — highlighted */
.ap-post-detail__main {
margin-bottom: var(--space-m);
}
.ap-post-detail__main .ap-card {
border-color: var(--color-primary);
box-shadow: 0 0 0 1px var(--color-primary);
}
/* Replies — indented from the other side */
.ap-post-detail__replies {
margin-left: var(--space-l);
}
.ap-post-detail__reply-item {
border-left: 2px solid var(--color-outline);
padding-left: var(--space-m);
margin-bottom: var(--space-xs);
}
/* ==========================================================================
Tag Timeline Header
========================================================================== */
.ap-tag-header {
align-items: flex-start;
background: var(--color-offset);
border-bottom: var(--border-width-thin) solid var(--color-outline);
border-radius: var(--border-radius-small);
display: flex;
gap: var(--space-m);
justify-content: space-between;
margin-bottom: var(--space-m);
padding: var(--space-m);
}
.ap-tag-header__title {
font-size: var(--font-size-xl);
font-weight: 600;
margin: 0 0 var(--space-xs);
}
.ap-tag-header__count {
color: var(--color-on-offset);
font-size: var(--font-size-s);
margin: 0;
}
.ap-tag-header__actions {
align-items: center;
display: flex;
flex-shrink: 0;
gap: var(--space-s);
}
.ap-tag-header__follow-btn {
background: var(--color-primary);
border: none;
border-radius: var(--border-radius-small);
color: var(--color-on-primary, var(--color-neutral99));
cursor: pointer;
font-size: var(--font-size-s);
padding: var(--space-xs) var(--space-s);
}
.ap-tag-header__follow-btn:hover {
opacity: 0.85;
}
.ap-tag-header__unfollow-btn {
background: transparent;
border: var(--border-width-thin) solid var(--color-outline);
border-radius: var(--border-radius-small);
color: var(--color-on-background);
cursor: pointer;
font-size: var(--font-size-s);
padding: var(--space-xs) var(--space-s);
}
.ap-tag-header__unfollow-btn:hover {
border-color: var(--color-on-background);
}
.ap-tag-header__back {
color: var(--color-on-offset);
font-size: var(--font-size-s);
text-decoration: none;
}
.ap-tag-header__back:hover {
color: var(--color-on-background);
text-decoration: underline;
}
@media (max-width: 640px) {
.ap-tag-header {
flex-direction: column;
gap: var(--space-s);
}
.ap-tag-header__actions {
flex-wrap: wrap;
}
}
/* ==========================================================================
Reader Tools Bar (Explore link, etc.)
========================================================================== */
.ap-reader-tools {
display: flex;
gap: var(--space-s);
justify-content: flex-end;
margin-bottom: var(--space-s);
}
.ap-reader-tools__explore {
color: var(--color-on-offset);
font-size: var(--font-size-s);
text-decoration: none;
}
.ap-reader-tools__explore:hover {
color: var(--color-on-background);
text-decoration: underline;
}
/* Followed tags bar */
.ap-followed-tags {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--space-xs);
padding: var(--space-xs) 0;
margin-bottom: var(--space-s);
font-size: var(--font-size-s);
}
.ap-followed-tags__label {
color: var(--color-on-offset);
font-weight: 600;
}
/* ==========================================================================
New Posts Banner
========================================================================== */
.ap-new-posts-banner {
left: 0;
position: sticky;
right: 0;
top: 0;
z-index: 10;
}
.ap-new-posts-banner__btn {
background: var(--color-primary);
border: none;
border-radius: var(--border-radius-small);
color: var(--color-on-primary);
cursor: pointer;
display: block;
font-family: inherit;
font-size: var(--font-size-s);
margin: 0 auto var(--space-s);
padding: var(--space-xs) var(--space-m);
text-align: center;
width: auto;
}
.ap-new-posts-banner__btn:hover {
opacity: 0.9;
}
/* ==========================================================================
Read State
========================================================================== */
.ap-card--read {
opacity: 0.7;
transition: opacity 0.3s ease;
}
.ap-card--read:hover {
opacity: 1;
}
/* ==========================================================================
Unread Toggle
========================================================================== */
.ap-unread-toggle {
margin-left: auto;
}
.ap-unread-toggle--active {
background: color-mix(in srgb, var(--color-primary) 12%, transparent);
font-weight: 600;
}
/* ==========================================================================
Quote Embeds
========================================================================== */
.ap-quote-embed {
border: var(--border-width-thin) solid var(--color-outline);
border-radius: var(--border-radius-small);
margin-top: var(--space-s);
overflow: hidden;
transition: border-color 0.15s ease;
}
.ap-quote-embed:hover {
border-color: var(--color-outline-variant);
}
.ap-quote-embed--pending {
border-style: dashed;
}
.ap-quote-embed__link {
color: inherit;
display: block;
padding: var(--space-s) var(--space-m);
text-decoration: none;
}
.ap-quote-embed__link:hover {
background: color-mix(in srgb, var(--color-offset) 50%, transparent);
}
.ap-quote-embed__author {
align-items: center;
display: flex;
gap: var(--space-xs);
margin-bottom: var(--space-xs);
}
.ap-quote-embed__avatar {
border-radius: 50%;
flex-shrink: 0;
height: 24px;
object-fit: cover;
width: 24px;
}
.ap-quote-embed__avatar--default {
align-items: center;
background: var(--color-offset);
color: var(--color-on-offset);
display: inline-flex;
font-size: var(--font-size-xs);
font-weight: 600;
justify-content: center;
}
.ap-quote-embed__author-info {
flex: 1;
min-width: 0;
}
.ap-quote-embed__name {
font-size: var(--font-size-s);
font-weight: 600;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.ap-quote-embed__handle {
color: var(--color-on-offset);
font-size: var(--font-size-xs);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.ap-quote-embed__time {
color: var(--color-on-offset);
flex-shrink: 0;
font-size: var(--font-size-xs);
white-space: nowrap;
}
.ap-quote-embed__title {
font-size: var(--font-size-s);
font-weight: 600;
margin: 0 0 var(--space-xs);
}
.ap-quote-embed__content {
color: var(--color-on-background);
font-size: var(--font-size-s);
line-height: calc(4 / 3 * 1em);
max-height: calc(1.333em * 6);
overflow: hidden;
}
.ap-quote-embed__content a {
display: inline;
}
.ap-quote-embed__content a span {
display: inline;
}
.ap-quote-embed__content p {
margin: 0 0 var(--space-xs);
}
.ap-quote-embed__content p:last-child {
margin-bottom: 0;
}
.ap-quote-embed__media {
margin-top: var(--space-xs);
}
.ap-quote-embed__photo {
border-radius: var(--border-radius-small);
max-height: 160px;
max-width: 100%;
object-fit: cover;
}
/* ==========================================================================
Poll / Question
========================================================================== */
.ap-poll {
margin-top: var(--space-s);
}
.ap-poll__option {
position: relative;
padding: var(--space-xs) var(--space-s);
margin-bottom: var(--space-xs);
border-radius: var(--border-radius-small);
background: var(--color-offset);
overflow: hidden;
}
.ap-poll__bar {
position: absolute;
top: 0;
left: 0;
bottom: 0;
background: var(--color-primary);
opacity: 0.15;
border-radius: var(--border-radius-small);
}
.ap-poll__label {
position: relative;
font-size: var(--font-size-s);
color: var(--color-on-background);
}
.ap-poll__votes {
position: relative;
float: right;
font-size: var(--font-size-s);
font-weight: 600;
color: var(--color-on-offset);
}
.ap-poll__footer {
font-size: var(--font-size-xs);
color: var(--color-on-offset);
margin-top: var(--space-xs);
}
/* Hashtag tab sources info line */
.ap-hashtag-sources {
color: var(--color-on-offset);
font-size: var(--font-size-s);
margin: 0;
padding: var(--space-s) 0 var(--space-xs);
}
/* Custom emoji */
.ap-custom-emoji {
height: 1.2em;
width: auto;
vertical-align: middle;
display: inline;
margin: 0 0.05em;
}
+242
View File
@@ -0,0 +1,242 @@
/* ==========================================================================
Federation Management
========================================================================== */
.ap-federation__section {
margin-block-end: var(--space-l);
}
.ap-federation__section h2 {
margin-block-end: var(--space-s);
}
.ap-federation__stats-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(9rem, 1fr));
gap: var(--space-s);
}
.ap-federation__stat-card {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--space-xs);
padding: var(--space-s);
background: var(--color-offset);
border-radius: var(--border-radius-small);
text-align: center;
}
.ap-federation__stat-count {
font-size: var(--font-size-xl);
font-weight: 600;
color: var(--color-on-background);
}
.ap-federation__stat-label {
font-size: var(--font-size-s);
color: var(--color-on-offset);
word-break: break-word;
}
.ap-federation__actions-row {
display: flex;
flex-wrap: wrap;
gap: var(--space-s);
align-items: center;
}
.ap-federation__result {
margin-block-start: var(--space-xs);
color: var(--color-green50);
font-size: var(--font-size-s);
}
.ap-federation__error {
margin-block-start: var(--space-xs);
color: var(--color-red45);
font-size: var(--font-size-s);
}
.ap-federation__lookup-form {
display: flex;
gap: var(--space-s);
}
.ap-federation__lookup-input {
flex: 1;
min-width: 0;
padding: 0.5rem 0.75rem;
border: var(--border-width-thin) solid var(--color-outline);
border-radius: var(--border-radius-small);
font: inherit;
color: var(--color-on-background);
background: var(--color-background);
}
.ap-federation__json-view {
margin-block-start: var(--space-s);
padding: var(--space-m);
background: var(--color-offset);
border-radius: var(--border-radius-small);
font-family: monospace;
font-size: var(--font-size-s);
color: var(--color-on-background);
max-height: 24rem;
overflow: auto;
white-space: pre-wrap;
word-break: break-word;
}
.ap-federation__posts-list {
display: flex;
flex-direction: column;
gap: var(--space-xs);
}
.ap-federation__post-row {
display: flex;
justify-content: space-between;
align-items: center;
gap: var(--space-m);
padding: var(--space-s);
background: var(--color-offset);
border-radius: var(--border-radius-small);
}
.ap-federation__post-info {
display: flex;
flex-direction: column;
gap: var(--space-xs);
min-width: 0;
}
.ap-federation__post-title {
font-weight: 500;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.ap-federation__post-meta {
display: flex;
align-items: center;
gap: var(--space-xs);
font-size: var(--font-size-s);
color: var(--color-on-offset);
}
.ap-federation__post-actions {
display: flex;
gap: var(--space-xs);
flex-shrink: 0;
}
.ap-federation__post-btn {
padding: var(--space-xs) var(--space-s);
font-size: var(--font-size-s);
border: var(--border-width-thin) solid var(--color-outline);
border-radius: var(--border-radius-small);
background: var(--color-background);
color: var(--color-on-background);
cursor: pointer;
}
.ap-federation__post-btn:hover {
background: var(--color-offset);
}
.ap-federation__post-btn--danger {
color: var(--color-red45);
border-color: var(--color-red45);
}
.ap-federation__post-btn--danger:hover {
background: color-mix(in srgb, var(--color-red45) 10%, transparent);
}
.ap-federation__modal-overlay {
position: fixed;
inset: 0;
z-index: 1000;
display: flex;
align-items: center;
justify-content: center;
background: hsl(var(--tint-neutral) 10% / 0.5);
}
.ap-federation__modal {
width: min(90vw, 48rem);
max-height: 80vh;
display: flex;
flex-direction: column;
background: var(--color-background);
border-radius: var(--border-radius-small);
box-shadow: 0 4px 24px hsl(var(--tint-neutral) 10% / 0.2);
}
.ap-federation__modal-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: var(--space-s) var(--space-m);
border-block-end: var(--border-width-thin) solid var(--color-outline);
}
.ap-federation__modal-header h3 {
margin: 0;
font-size: var(--font-size-m);
}
.ap-federation__modal-close {
font-size: var(--font-size-xl);
line-height: 1;
padding: 0 var(--space-xs);
border: none;
background: none;
color: var(--color-on-offset);
cursor: pointer;
}
.ap-federation__modal .ap-federation__json-view {
margin: 0;
border-radius: 0 0 var(--border-radius-small) var(--border-radius-small);
flex: 1;
overflow: auto;
}
@media (max-width: 40rem) {
.ap-federation__post-row {
flex-direction: column;
align-items: flex-start;
}
.ap-federation__lookup-form {
flex-direction: column;
}
}
/* Follow request approve/reject actions */
.ap-follow-request {
margin-block-end: var(--space-m);
}
.ap-follow-request__actions {
display: flex;
gap: var(--space-s);
margin-block-start: var(--space-xs);
padding-inline-start: var(--space-l);
}
.ap-follow-request__form {
display: inline;
}
.button--danger {
background-color: var(--color-red45);
color: white;
}
.button--danger:hover {
background-color: var(--color-red35, #c0392b);
}
+236
View File
@@ -0,0 +1,236 @@
/* ==========================================================================
Tags
========================================================================== */
.ap-card__tags {
display: flex;
flex-wrap: wrap;
gap: var(--space-xs);
margin-bottom: var(--space-s);
}
.ap-card__tag {
background: var(--color-offset-variant);
border-radius: var(--border-radius-large);
color: var(--color-on-offset);
font-size: var(--font-size-s);
padding: 2px var(--space-xs);
text-decoration: none;
}
.ap-card__tag:hover {
background: var(--color-offset-variant-darker);
color: var(--color-on-background);
}
.ap-card__mention {
background: color-mix(in srgb, var(--color-primary) 12%, transparent);
border-radius: var(--border-radius-large);
color: var(--color-primary-on-background);
font-size: var(--font-size-s);
padding: 2px var(--space-xs);
text-decoration: none;
}
.ap-card__mention:hover {
background: color-mix(in srgb, var(--color-primary) 22%, transparent);
color: var(--color-primary-on-background);
}
.ap-card__mention--legacy {
cursor: default;
opacity: 0.7;
}
/* Hashtag stuffing collapse */
.ap-hashtag-overflow {
margin: var(--space-xs) 0;
font-size: var(--font-size-s);
}
.ap-hashtag-overflow summary {
cursor: pointer;
color: var(--color-on-offset);
list-style: none;
}
.ap-hashtag-overflow summary::before {
content: "▸ ";
}
.ap-hashtag-overflow[open] summary::before {
content: "▾ ";
}
.ap-hashtag-overflow p {
margin-top: var(--space-xs);
}
/* ==========================================================================
Interaction Buttons
========================================================================== */
.ap-card__actions {
border-top: var(--border-width-thin) solid var(--color-outline);
display: flex;
flex-wrap: wrap;
gap: 2px;
padding-top: var(--space-s);
}
.ap-card__action {
align-items: center;
background: transparent;
border: 0;
border-radius: var(--border-radius-small);
color: var(--color-on-offset);
cursor: pointer;
display: inline-flex;
font-size: var(--font-size-s);
gap: 0.3em;
min-height: 36px;
padding: 0.25em 0.6em;
text-decoration: none;
transition:
background-color 0.15s ease,
color 0.15s ease;
}
.ap-card__action:hover {
background: var(--color-offset-variant);
color: var(--color-on-background);
}
/* Color-coded hover states per action type */
.ap-card__action--reply:hover {
background: color-mix(in srgb, var(--color-primary) 12%, transparent);
color: var(--color-primary);
}
.ap-card__action--boost:hover {
background: color-mix(in srgb, var(--color-green50) 12%, transparent);
color: var(--color-green50);
}
.ap-card__action--like:hover {
background: color-mix(in srgb, var(--color-red45) 12%, transparent);
color: var(--color-red45);
}
.ap-card__action--link:hover {
background: var(--color-offset-variant);
color: var(--color-on-background);
}
.ap-card__action--save:hover {
background: color-mix(in srgb, var(--color-primary) 12%, transparent);
color: var(--color-primary);
}
/* Active interaction states */
.ap-card__action--like.ap-card__action--active {
background: color-mix(in srgb, var(--color-red45) 12%, transparent);
color: var(--color-red45);
}
.ap-card__action--boost.ap-card__action--active {
background: color-mix(in srgb, var(--color-green50) 12%, transparent);
color: var(--color-green50);
}
.ap-card__action--save.ap-card__action--active {
background: color-mix(in srgb, var(--color-primary) 12%, transparent);
color: var(--color-primary);
}
.ap-card__action:disabled {
cursor: wait;
opacity: 0.5;
}
/* Interaction counts */
.ap-card__count {
font-size: var(--font-size-xs);
color: inherit;
opacity: 0.7;
margin-left: 0.1em;
font-variant-numeric: tabular-nums;
}
/* Error message */
.ap-card__action-error {
color: var(--color-error);
font-size: var(--font-size-s);
width: 100%;
}
/* ==========================================================================
Pagination
========================================================================== */
.ap-pagination {
border-top: var(--border-width-thin) solid var(--color-outline);
display: flex;
gap: var(--space-m);
justify-content: space-between;
margin-top: var(--space-m);
padding-top: var(--space-m);
}
.ap-pagination a {
color: var(--color-primary-on-background);
text-decoration: none;
}
.ap-pagination a:hover {
text-decoration: underline;
}
/* Hidden once Alpine is active (JS replaces with infinite scroll) */
.ap-pagination--js-hidden {
/* Shown by default for no-JS fallback — Alpine hides via display:none */
}
/* ==========================================================================
Infinite Scroll / Load More
========================================================================== */
.ap-load-more {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--space-s);
padding: var(--space-m) 0;
}
.ap-load-more__sentinel {
height: 1px;
width: 100%;
}
.ap-load-more__btn {
background: var(--color-offset);
border: var(--border-width-thin) solid var(--color-outline);
border-radius: var(--border-radius-small);
color: var(--color-on-background);
cursor: pointer;
font-size: var(--font-size-s);
padding: var(--space-xs) var(--space-m);
transition: background 0.15s;
}
.ap-load-more__btn:hover:not(:disabled) {
background: var(--color-offset-variant);
}
.ap-load-more__btn:disabled {
cursor: wait;
opacity: 0.6;
}
.ap-load-more__done {
color: var(--color-on-offset);
font-size: var(--font-size-s);
margin: 0;
text-align: center;
}
+315
View File
@@ -0,0 +1,315 @@
/* ==========================================================================
Photo Gallery
========================================================================== */
.ap-card__gallery {
border-radius: var(--border-radius-small);
display: grid;
gap: 2px;
margin-bottom: var(--space-s);
overflow: hidden;
}
.ap-card__gallery-link {
appearance: none;
background: none;
border: 0;
cursor: pointer;
display: block;
padding: 0;
position: relative;
}
.ap-card__gallery img {
background: var(--color-offset-variant);
display: block;
height: 280px;
object-fit: cover;
width: 100%;
transition: filter 0.2s ease;
}
@media (max-width: 480px) {
.ap-card__gallery img {
height: 180px;
}
}
.ap-card__gallery-link:hover img {
filter: brightness(0.92);
}
.ap-card__gallery-link--more::after {
background: hsl(var(--tint-neutral) 10% / 0.5);
bottom: 0;
content: "";
left: 0;
position: absolute;
right: 0;
top: 0;
}
.ap-card__gallery-more {
color: var(--color-neutral99);
font-size: 1.5em;
font-weight: 600;
left: 50%;
position: absolute;
top: 50%;
transform: translate(-50%, -50%);
z-index: 1;
}
/* 1 photo */
.ap-card__gallery--1 {
grid-template-columns: 1fr;
}
.ap-card__gallery--1 img {
height: auto;
max-height: 500px;
}
/* 2 photos — side by side */
.ap-card__gallery--2 {
grid-template-columns: 1fr 1fr;
}
/* 3 photos — one large, two small */
.ap-card__gallery--3 {
grid-template-columns: 2fr 1fr;
grid-template-rows: 1fr 1fr;
}
.ap-card__gallery--3 img:first-child {
grid-row: 1 / 3;
height: 100%;
}
/* 4+ photos — 2x2 grid */
.ap-card__gallery--4 {
grid-template-columns: 1fr 1fr;
grid-template-rows: 1fr 1fr;
}
/* ==========================================================================
Photo Lightbox
========================================================================== */
[x-cloak] {
display: none !important;
}
.ap-lightbox {
align-items: center;
background: hsl(var(--tint-neutral) 10% / 0.92);
display: flex;
inset: 0;
justify-content: center;
position: fixed;
z-index: 9999;
}
.ap-lightbox__img {
max-height: 90vh;
max-width: 95vw;
object-fit: contain;
}
.ap-lightbox__close {
background: none;
border: 0;
color: white;
cursor: pointer;
font-size: 2rem;
line-height: 1;
padding: var(--space-s);
position: absolute;
right: var(--space-m);
top: var(--space-m);
}
.ap-lightbox__close:hover {
opacity: 0.7;
}
.ap-lightbox__prev,
.ap-lightbox__next {
background: none;
border: 0;
color: white;
cursor: pointer;
font-size: 3rem;
line-height: 1;
padding: var(--space-m);
position: absolute;
top: 50%;
transform: translateY(-50%);
}
.ap-lightbox__prev {
left: var(--space-s);
}
.ap-lightbox__next {
right: var(--space-s);
}
.ap-lightbox__prev:hover,
.ap-lightbox__next:hover {
opacity: 0.7;
}
.ap-lightbox__counter {
bottom: var(--space-m);
color: white;
font-size: var(--font-size-s);
left: 50%;
position: absolute;
transform: translateX(-50%);
}
/* ==========================================================================
Link Preview Card
========================================================================== */
.ap-link-previews {
margin-bottom: var(--space-s);
}
.ap-link-preview {
display: flex;
border: var(--border-width-thin) solid var(--color-outline);
border-radius: var(--border-radius-small);
overflow: hidden;
text-decoration: none;
color: inherit;
transition: border-color 0.2s ease;
}
.ap-link-preview:hover {
border-color: var(--color-primary);
}
.ap-link-preview__text {
flex: 1;
min-width: 0;
padding: var(--space-s) var(--space-m);
display: flex;
flex-direction: column;
justify-content: center;
gap: 0.2em;
}
.ap-link-preview__title {
font-weight: 600;
font-size: var(--font-size-s);
margin: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.ap-link-preview__desc {
font-size: var(--font-size-s);
color: var(--color-on-offset);
margin: 0;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.ap-link-preview__domain {
font-size: var(--font-size-xs);
color: var(--color-on-offset);
margin: 0;
display: flex;
align-items: center;
gap: 0.3em;
}
.ap-link-preview__favicon {
width: 14px;
height: 14px;
}
.ap-link-preview__image {
flex-shrink: 0;
width: 120px;
}
.ap-link-preview__image img {
display: block;
width: 100%;
height: 100%;
object-fit: cover;
}
/* ==========================================================================
Video Embed
========================================================================== */
.ap-card__video {
margin-bottom: var(--space-s);
}
.ap-card__video video {
border-radius: var(--border-radius-small);
max-height: 400px;
width: 100%;
}
/* ==========================================================================
Audio Player
========================================================================== */
.ap-card__audio {
margin-bottom: var(--space-s);
}
.ap-card__audio audio {
width: 100%;
}
/* Gallery items — positioned for ALT badge overlay */
.ap-card__gallery-item {
position: relative;
}
/* ALT text badges */
.ap-media__alt-badge {
position: absolute;
bottom: 0.5rem;
left: 0.5rem;
background: hsl(var(--tint-neutral) 10% / 0.7);
color: var(--color-neutral99);
font-size: 0.65rem;
font-weight: 700;
padding: 0.15rem 0.35rem;
border-radius: var(--border-radius-small);
border: none;
cursor: pointer;
text-transform: uppercase;
letter-spacing: 0.03em;
z-index: 1;
}
.ap-media__alt-badge:hover {
background: hsl(var(--tint-neutral) 10% / 0.9);
}
.ap-media__alt-text {
position: absolute;
bottom: 2.2rem;
left: 0.5rem;
right: 0.5rem;
background: hsl(var(--tint-neutral) 10% / 0.85);
color: var(--color-neutral99);
font-size: var(--font-size-s);
padding: 0.5rem;
border-radius: var(--border-radius-small);
max-height: 8rem;
overflow-y: auto;
z-index: 2;
}
+158
View File
@@ -0,0 +1,158 @@
/* ==========================================================================
Messages
========================================================================== */
.ap-messages__layout {
display: grid;
grid-template-columns: 240px 1fr;
gap: var(--space-m);
min-height: 300px;
}
.ap-messages__sidebar {
border-right: var(--border-width-thin) solid var(--color-outline);
display: flex;
flex-direction: column;
gap: 2px;
padding-right: var(--space-m);
overflow-y: auto;
max-height: 70vh;
}
.ap-messages__partner {
align-items: center;
border-radius: var(--border-radius-small);
color: var(--color-on-background);
display: flex;
gap: var(--space-s);
padding: var(--space-s);
text-decoration: none;
transition: background 0.15s ease;
}
.ap-messages__partner:hover {
background: var(--color-offset);
}
.ap-messages__partner--active {
background: var(--color-offset);
border-left: 3px solid var(--color-primary);
font-weight: var(--font-weight-bold);
}
.ap-messages__partner-avatar {
flex-shrink: 0;
height: 32px;
position: relative;
width: 32px;
}
.ap-messages__partner-avatar img {
border-radius: 50%;
height: 100%;
object-fit: cover;
position: absolute;
inset: 0;
width: 100%;
z-index: 1;
}
.ap-messages__partner-initial {
align-items: center;
background: var(--color-offset-variant);
border-radius: 50%;
color: var(--color-on-offset);
display: flex;
font-size: var(--font-size-s);
height: 100%;
justify-content: center;
width: 100%;
}
.ap-messages__partner-info {
display: flex;
flex-direction: column;
min-width: 0;
overflow: hidden;
}
.ap-messages__partner-name {
font-size: var(--font-size-s);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.ap-messages__partner-handle {
color: var(--color-on-offset);
font-size: var(--font-size-xs);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.ap-messages__content {
min-width: 0;
}
.ap-message--outbound {
border-left: 3px solid var(--color-primary);
}
.ap-message .ap-notification__time {
padding-right: var(--space-l);
}
.ap-message__direction {
color: var(--color-on-offset);
font-size: var(--font-size-s);
margin-right: var(--space-xs);
}
.ap-message__content {
color: var(--color-on-background);
font-size: var(--font-size-s);
line-height: 1.5;
margin-top: var(--space-xs);
}
.ap-message__content p {
margin: 0 0 var(--space-xs);
}
.ap-message__content p:last-child {
margin-bottom: 0;
}
/* Inline mention links in DM content (Mastodon wraps @user in span inside a link) */
.ap-message__content .h-card,
.ap-message__content a.mention,
.ap-message__content a span {
display: inline;
}
.ap-message__content a {
overflow-wrap: break-word;
}
@media (max-width: 640px) {
.ap-messages__layout {
grid-template-columns: 1fr;
}
.ap-messages__sidebar {
border-bottom: var(--border-width-thin) solid var(--color-outline);
border-right: none;
flex-direction: row;
max-height: none;
overflow-x: auto;
padding-bottom: var(--space-s);
padding-right: 0;
-webkit-overflow-scrolling: touch;
}
.ap-messages__partner {
flex-shrink: 0;
white-space: nowrap;
}
}
+119
View File
@@ -0,0 +1,119 @@
/* ==========================================================================
Moderation
========================================================================== */
.ap-moderation__section {
margin-bottom: var(--space-l);
}
.ap-moderation__section h2 {
font-size: var(--font-size-l);
margin-bottom: var(--space-s);
}
.ap-moderation__list {
list-style: none;
margin: 0;
padding: 0;
}
.ap-moderation__entry {
align-items: center;
border-bottom: var(--border-width-thin) solid var(--color-outline);
display: flex;
gap: var(--space-s);
justify-content: space-between;
padding: var(--space-s) 0;
}
.ap-moderation__entry a {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.ap-moderation__remove {
background: transparent;
border: var(--border-width-thin) solid var(--color-outline);
border-radius: var(--border-radius-small);
color: var(--color-on-offset);
cursor: pointer;
flex-shrink: 0;
font-size: var(--font-size-s);
padding: var(--space-xs) var(--space-s);
}
.ap-moderation__remove:hover {
border-color: var(--color-error);
color: var(--color-error);
}
.ap-moderation__add-form {
display: flex;
gap: var(--space-s);
}
.ap-moderation__input {
background: var(--color-background);
border: var(--border-width-thick) solid var(--color-outline);
border-radius: var(--border-radius-small);
color: var(--color-on-background);
flex: 1;
font-size: var(--font-size-m);
padding: var(--space-xs) var(--space-s);
}
.ap-moderation__add-btn {
background: var(--color-offset);
border: var(--border-width-thin) solid var(--color-outline);
border-radius: var(--border-radius-small);
color: var(--color-on-background);
cursor: pointer;
font-size: var(--font-size-m);
padding: var(--space-xs) var(--space-m);
}
.ap-moderation__add-btn:hover {
background: var(--color-offset-variant);
}
.ap-moderation__add-btn:disabled {
cursor: not-allowed;
opacity: 0.5;
}
.ap-moderation__error {
color: var(--color-error);
font-size: var(--font-size-s);
margin-top: var(--space-xs);
}
.ap-moderation__empty {
color: var(--color-on-offset);
font-size: var(--font-size-s);
font-style: italic;
}
.ap-moderation__hint {
color: var(--color-on-offset);
font-size: var(--font-size-s);
margin-bottom: var(--space-s);
}
.ap-moderation__filter-toggle {
display: flex;
gap: var(--space-m);
}
.ap-moderation__radio {
align-items: center;
cursor: pointer;
display: flex;
gap: var(--space-xs);
}
.ap-moderation__radio input {
accent-color: var(--color-primary);
cursor: pointer;
}
+191
View File
@@ -0,0 +1,191 @@
/* ==========================================================================
Notifications
========================================================================== */
/* Notifications Toolbar */
.ap-notifications__toolbar {
display: flex;
gap: var(--space-s);
margin-bottom: var(--space-m);
}
.ap-notifications__btn {
background: var(--color-offset);
border: var(--border-width-thin) solid var(--color-outline);
border-radius: var(--border-radius-small);
color: var(--color-on-background);
cursor: pointer;
font-size: var(--font-size-s);
padding: var(--space-xs) var(--space-m);
transition: all 0.2s ease;
}
.ap-notifications__btn:hover {
background: var(--color-offset-variant);
border-color: var(--color-outline-variant);
}
.ap-notifications__btn--danger {
color: var(--color-error);
}
.ap-notifications__btn--danger:hover {
border-color: var(--color-error);
}
.ap-notification {
align-items: flex-start;
background: var(--color-offset);
border: var(--border-width-thin) solid var(--color-outline);
border-radius: var(--border-radius-small);
display: flex;
gap: var(--space-s);
padding: var(--space-m);
position: relative;
}
.ap-notification--unread {
border-color: var(--color-yellow50);
box-shadow: 0 0 8px 0 hsl(var(--tint-yellow) 50% / 0.3);
}
.ap-notification__avatar-wrap {
flex-shrink: 0;
position: relative;
}
.ap-notification__avatar-wrap {
height: 40px;
width: 40px;
}
.ap-notification__avatar {
border: var(--border-width-thin) solid var(--color-outline);
border-radius: 50%;
height: 40px;
object-fit: cover;
width: 40px;
}
.ap-notification__avatar-wrap > img {
position: absolute;
inset: 0;
z-index: 1;
}
.ap-notification__avatar--default {
align-items: center;
background: var(--color-offset-variant);
color: var(--color-on-offset);
display: inline-flex;
font-size: 1.1em;
font-weight: 600;
justify-content: center;
}
.ap-notification__type-badge {
bottom: -2px;
font-size: 0.75em;
position: absolute;
right: -4px;
}
.ap-notification__body {
flex: 1;
min-width: 0;
}
.ap-notification__actor {
font-weight: 600;
}
.ap-notification__action {
color: var(--color-on-offset);
}
.ap-notification__target {
color: var(--color-on-offset);
display: block;
font-size: var(--font-size-s);
margin-top: var(--space-xs);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.ap-notification__excerpt {
background: var(--color-offset-variant);
border-radius: var(--border-radius-small);
font-size: var(--font-size-s);
margin-top: var(--space-xs);
padding: var(--space-xs) var(--space-s);
}
.ap-notification__time {
color: var(--color-on-offset);
flex-shrink: 0;
font-size: var(--font-size-xs);
}
.ap-notification__dismiss {
position: absolute;
right: var(--space-xs);
top: var(--space-xs);
}
.ap-notification__dismiss-btn {
background: transparent;
border: 0;
border-radius: var(--border-radius-small);
color: var(--color-on-offset);
cursor: pointer;
font-size: var(--font-size-m);
line-height: 1;
padding: 2px 6px;
transition: all 0.2s ease;
}
.ap-notification__dismiss-btn:hover {
background: var(--color-offset-variant);
color: var(--color-error);
}
.ap-notification__actions {
display: flex;
gap: var(--space-s);
margin-top: var(--space-s);
}
.ap-notification__reply-btn,
.ap-notification__thread-btn {
border: var(--border-width-thin) solid var(--color-outline);
border-radius: var(--border-radius-small);
color: var(--color-on-offset);
font-size: var(--font-size-s);
padding: var(--space-xs) var(--space-s);
text-decoration: none;
transition: all 0.2s ease;
}
.ap-notification__reply-btn:hover,
.ap-notification__thread-btn:hover {
background: var(--color-offset-variant);
border-color: var(--color-outline-variant);
color: var(--color-on-background);
}
.ap-notification__handle {
color: var(--color-on-offset);
font-size: var(--font-size-s);
margin-left: var(--space-xs);
}
.ap-notifications__btn--primary {
background: var(--color-primary);
color: var(--color-on-primary, #fff);
text-decoration: none;
}
.ap-notifications__btn--primary:hover {
opacity: 0.9;
}
+308
View File
@@ -0,0 +1,308 @@
/* ==========================================================================
Remote Profile
========================================================================== */
.ap-profile__header {
border-radius: var(--border-radius-small);
height: 200px;
margin-bottom: var(--space-m);
overflow: hidden;
}
.ap-profile__header-img {
height: 100%;
object-fit: cover;
width: 100%;
}
.ap-profile__info {
margin-bottom: var(--space-l);
}
.ap-profile__avatar-wrap {
height: 80px;
margin-bottom: var(--space-s);
position: relative;
width: 80px;
}
.ap-profile__avatar-wrap > img {
position: absolute;
inset: 0;
z-index: 1;
}
.ap-profile__avatar {
border: var(--border-width-thickest) solid var(--color-background);
border-radius: 50%;
height: 80px;
object-fit: cover;
width: 80px;
}
.ap-profile__avatar--placeholder {
align-items: center;
background: var(--color-offset-variant);
color: var(--color-on-offset);
display: flex;
font-size: 2em;
font-weight: 600;
justify-content: center;
}
.ap-profile__name {
font-size: var(--font-size-xl);
margin-bottom: var(--space-xs);
}
.ap-profile__handle {
color: var(--color-on-offset);
margin-bottom: var(--space-s);
}
.ap-profile__bio {
line-height: var(--line-height-prose);
margin-bottom: var(--space-s);
}
.ap-profile__bio a {
color: var(--color-primary-on-background);
}
/* Override upstream .mention { display: grid } for bio content */
.ap-profile__bio .h-card {
display: inline;
}
.ap-profile__bio .h-card a,
.ap-profile__bio a.u-url.mention {
display: inline;
white-space: nowrap;
}
.ap-profile__bio .h-card a span,
.ap-profile__bio a.u-url.mention span {
display: inline;
}
.ap-profile__bio a.mention.hashtag {
display: inline;
white-space: nowrap;
}
.ap-profile__bio a.mention.hashtag span {
display: inline;
}
/* Mastodon invisible/ellipsis spans for long URLs in bios */
.ap-profile__bio .invisible {
display: none;
}
.ap-profile__bio .ellipsis::after {
content: "…";
}
.ap-profile__actions {
display: flex;
flex-wrap: wrap;
gap: var(--space-s);
margin-top: var(--space-m);
}
.ap-profile__action {
background: transparent;
border: var(--border-width-thin) solid var(--color-outline);
border-radius: var(--border-radius-small);
color: var(--color-on-background);
cursor: pointer;
font-size: var(--font-size-s);
padding: var(--space-xs) var(--space-m);
text-decoration: none;
}
.ap-profile__action:hover {
background: var(--color-offset);
}
.ap-profile__action--follow.ap-profile__action--active {
background: var(--color-primary);
border-color: var(--color-primary);
color: var(--color-on-primary, var(--color-neutral99));
}
.ap-profile__action--danger:hover {
border-color: var(--color-error);
color: var(--color-error);
}
.ap-profile__posts {
margin-top: var(--space-l);
}
.ap-profile__posts h3 {
border-bottom: var(--border-width-thin) solid var(--color-outline);
font-size: var(--font-size-l);
margin-bottom: var(--space-m);
padding-bottom: var(--space-s);
}
/* ==========================================================================
My Profile — Admin Profile Header
========================================================================== */
.ap-my-profile {
border: var(--border-width-thin) solid var(--color-outline);
border-radius: var(--border-radius-small);
margin-bottom: var(--space-m);
overflow: hidden;
}
.ap-my-profile__header {
height: 160px;
overflow: hidden;
}
.ap-my-profile__header-img {
height: 100%;
object-fit: cover;
width: 100%;
}
.ap-my-profile__info {
padding: var(--space-m);
}
.ap-my-profile__avatar-wrap {
margin-bottom: var(--space-s);
margin-top: -40px;
}
.ap-my-profile__avatar {
border: 3px solid var(--color-background);
border-radius: 50%;
height: 72px;
object-fit: cover;
width: 72px;
}
.ap-my-profile__avatar--placeholder {
align-items: center;
background: var(--color-offset-variant);
color: var(--color-on-offset);
display: flex;
font-size: 1.8em;
font-weight: 600;
justify-content: center;
}
.ap-my-profile__name {
font-size: var(--font-size-xl);
margin-bottom: 0;
}
.ap-my-profile__handle {
color: var(--color-on-offset);
font-size: var(--font-size-s);
margin-bottom: var(--space-s);
}
.ap-my-profile__bio {
line-height: var(--line-height-prose);
margin-bottom: var(--space-s);
}
.ap-my-profile__bio a {
color: var(--color-primary-on-background);
}
/* Override upstream .mention { display: grid } for bio content */
.ap-my-profile__bio .h-card { display: inline; }
.ap-my-profile__bio .h-card a,
.ap-my-profile__bio a.u-url.mention { display: inline; white-space: nowrap; }
.ap-my-profile__bio .h-card a span,
.ap-my-profile__bio a.u-url.mention span { display: inline; }
.ap-my-profile__bio a.mention.hashtag { display: inline; white-space: nowrap; }
.ap-my-profile__bio a.mention.hashtag span { display: inline; }
.ap-my-profile__bio .invisible { display: none; }
.ap-my-profile__bio .ellipsis::after { content: "…"; }
.ap-my-profile__fields {
border: var(--border-width-thin) solid var(--color-outline);
border-radius: var(--border-radius-small);
margin: var(--space-s) 0;
overflow: hidden;
}
.ap-my-profile__field {
border-bottom: var(--border-width-thin) solid var(--color-outline);
display: grid;
grid-template-columns: 120px 1fr;
}
.ap-my-profile__field:last-child {
border-bottom: 0;
}
.ap-my-profile__field-name {
background: var(--color-offset);
color: var(--color-on-offset);
font-size: var(--font-size-s);
font-weight: 600;
padding: var(--space-xs) var(--space-s);
text-transform: uppercase;
letter-spacing: 0.03em;
}
.ap-my-profile__field-value {
font-size: var(--font-size-s);
overflow: hidden;
padding: var(--space-xs) var(--space-s);
text-overflow: ellipsis;
white-space: nowrap;
}
.ap-my-profile__field-value a {
color: var(--color-primary-on-background);
}
.ap-my-profile__stats {
display: flex;
gap: var(--space-m);
margin-bottom: var(--space-s);
}
.ap-my-profile__stat {
color: var(--color-on-offset);
font-size: var(--font-size-s);
text-decoration: none;
}
.ap-my-profile__stat:hover {
color: var(--color-on-background);
}
.ap-my-profile__stat strong {
color: var(--color-on-background);
font-weight: 600;
}
.ap-my-profile__edit {
border: var(--border-width-thin) solid var(--color-outline);
border-radius: var(--border-radius-small);
color: var(--color-on-background);
display: inline-block;
font-size: var(--font-size-s);
padding: var(--space-xs) var(--space-m);
text-decoration: none;
}
.ap-my-profile__edit:hover {
background: var(--color-offset);
border-color: var(--color-outline-variant);
}
/* When no header image, don't offset avatar */
.ap-my-profile__info:first-child .ap-my-profile__avatar-wrap {
margin-top: 0;
}
+33
View File
@@ -0,0 +1,33 @@
/* ==========================================================================
Responsive
========================================================================== */
@media (max-width: 640px) {
.ap-tabs {
gap: 0;
}
.ap-tab {
padding: var(--space-xs) var(--space-s);
}
.ap-card__gallery--3 {
grid-template-columns: 1fr 1fr;
grid-template-rows: auto auto;
}
.ap-card__gallery--3 img:first-child {
grid-column: 1 / 3;
grid-row: 1;
height: 200px;
}
.ap-card__actions {
gap: var(--space-xs);
}
.ap-card__action {
font-size: 0.75rem;
padding: var(--space-xs);
}
}
+74
View File
@@ -0,0 +1,74 @@
/* ==========================================================================
Skeleton Loaders
========================================================================== */
@keyframes ap-skeleton-shimmer {
0% { background-position: 200% 0; }
100% { background-position: -200% 0; }
}
.ap-skeleton {
background: linear-gradient(90deg,
var(--color-offset) 25%,
var(--color-background) 50%,
var(--color-offset) 75%);
background-size: 200% 100%;
animation: ap-skeleton-shimmer 1.5s ease-in-out infinite;
border-radius: var(--border-radius-small);
}
.ap-card--skeleton {
pointer-events: none;
}
.ap-card--skeleton .ap-card__author {
display: flex;
align-items: center;
gap: var(--space-s);
}
.ap-skeleton--avatar {
width: 2.5rem;
height: 2.5rem;
border-radius: 50%;
flex-shrink: 0;
}
.ap-skeleton-lines {
flex: 1;
display: flex;
flex-direction: column;
gap: 0.4rem;
}
.ap-skeleton--name {
height: 0.85rem;
width: 40%;
}
.ap-skeleton--handle {
height: 0.7rem;
width: 25%;
}
.ap-skeleton-body {
display: flex;
flex-direction: column;
gap: 0.5rem;
margin-top: var(--space-s);
}
.ap-skeleton--line {
height: 0.75rem;
width: 100%;
}
.ap-skeleton--short {
width: 60%;
}
.ap-skeleton-group {
display: flex;
flex-direction: column;
gap: var(--space-m);
}
+125
View File
@@ -0,0 +1,125 @@
/**
* Card interaction Alpine.js component.
* Handles like, boost, and save-for-later actions with optimistic UI and
* rollback on failure.
*
* Configured via data-* attributes on the container element (the <footer>):
* data-item-uid="..." canonical AP UID used for like/boost API calls
* data-item-url="..." display URL used for saveLater and links
* data-csrf-token="..."
* data-mount-path="..."
* data-liked="true|false"
* data-boosted="true|false"
* data-like-count="N" omit or empty string for null
* data-boost-count="N" omit or empty string for null
*/
document.addEventListener("alpine:init", () => {
Alpine.data("apCardInteraction", () => ({
liked: false,
boosted: false,
saved: false,
loading: false,
error: "",
likeCount: null,
boostCount: null,
// Stored from data attributes in init() — must use $root to guarantee
// we read from the x-data element, not a child element in event context.
_mountPath: "",
_csrfToken: "",
_itemUid: "",
_itemUrl: "",
init() {
const root = this.$root;
this.liked = root.dataset.liked === "true";
this.boosted = root.dataset.boosted === "true";
this._mountPath = root.dataset.mountPath || "";
this._csrfToken = root.dataset.csrfToken || "";
this._itemUid = root.dataset.itemUid || "";
this._itemUrl = root.dataset.itemUrl || "";
const lc = root.dataset.likeCount;
const bc = root.dataset.boostCount;
this.likeCount = lc != null && lc !== "" ? Number(lc) : null;
this.boostCount = bc != null && bc !== "" ? Number(bc) : null;
},
async saveLater() {
if (this.saved) return;
const itemUrl = this._itemUrl;
try {
const res = await fetch("/readlater/save", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
url: itemUrl,
title:
this.$root.closest("article")?.querySelector("p")?.textContent?.substring(0, 80) ||
itemUrl,
source: "activitypub",
}),
credentials: "same-origin",
});
if (res.ok) this.saved = true;
else this.error = "Failed to save";
} catch (e) {
this.error = e.message;
}
if (this.error) setTimeout(() => (this.error = ""), 3000);
},
async interact(action) {
if (this.loading) return;
this.loading = true;
this.error = "";
const itemUid = this._itemUid;
const csrfToken = this._csrfToken;
const basePath = this._mountPath;
const prev = {
liked: this.liked,
boosted: this.boosted,
boostCount: this.boostCount,
likeCount: this.likeCount,
};
if (action === "like") {
this.liked = true;
if (this.likeCount !== null) this.likeCount++;
} else if (action === "unlike") {
this.liked = false;
if (this.likeCount !== null && this.likeCount > 0) this.likeCount--;
} else if (action === "boost") {
this.boosted = true;
if (this.boostCount !== null) this.boostCount++;
} else if (action === "unboost") {
this.boosted = false;
if (this.boostCount !== null && this.boostCount > 0) this.boostCount--;
}
try {
const res = await fetch(basePath + "/admin/reader/" + action, {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-CSRF-Token": csrfToken,
},
body: JSON.stringify({ url: itemUid }),
});
const data = await res.json();
if (!data.success) {
this.liked = prev.liked;
this.boosted = prev.boosted;
this.boostCount = prev.boostCount;
this.likeCount = prev.likeCount;
this.error = data.error || "Failed";
}
} catch (e) {
this.liked = prev.liked;
this.boosted = prev.boosted;
this.boostCount = prev.boostCount;
this.likeCount = prev.likeCount;
this.error = e.message;
}
this.loading = false;
if (this.error) setTimeout(() => (this.error = ""), 3000);
},
}));
});
+319 -329
View File
@@ -1,5 +1,10 @@
/**
* ActivityPub Reader Styles
* ActivityPub Reader Styles — GENERATED FILE
*
* Do not edit this file directly. Edit the source files in assets/css/
* and regenerate with: cat assets/css/*.css > assets/reader.css
* (file order matters — see assets/css/ for the canonical source)
*
* Card-based layout inspired by Phanpy/Elk
* Uses Indiekit CSS custom properties for automatic dark mode support
*/
@@ -142,7 +147,6 @@
flex-direction: column;
gap: var(--space-m);
}
/* ==========================================================================
Item Card — Base
========================================================================== */
@@ -520,7 +524,6 @@
.ap-card__cw-toggle:hover {
background: var(--color-offset-variant-darker);
}
/* ==========================================================================
Photo Gallery
========================================================================== */
@@ -795,6 +798,47 @@
width: 100%;
}
/* Gallery items — positioned for ALT badge overlay */
.ap-card__gallery-item {
position: relative;
}
/* ALT text badges */
.ap-media__alt-badge {
position: absolute;
bottom: 0.5rem;
left: 0.5rem;
background: hsl(var(--tint-neutral) 10% / 0.7);
color: var(--color-neutral99);
font-size: 0.65rem;
font-weight: 700;
padding: 0.15rem 0.35rem;
border-radius: var(--border-radius-small);
border: none;
cursor: pointer;
text-transform: uppercase;
letter-spacing: 0.03em;
z-index: 1;
}
.ap-media__alt-badge:hover {
background: hsl(var(--tint-neutral) 10% / 0.9);
}
.ap-media__alt-text {
position: absolute;
bottom: 2.2rem;
left: 0.5rem;
right: 0.5rem;
background: hsl(var(--tint-neutral) 10% / 0.85);
color: var(--color-neutral99);
font-size: var(--font-size-s);
padding: 0.5rem;
border-radius: var(--border-radius-small);
max-height: 8rem;
overflow-y: auto;
z-index: 2;
}
/* ==========================================================================
Tags
========================================================================== */
@@ -1031,7 +1075,6 @@
margin: 0;
text-align: center;
}
/* ==========================================================================
Compose Form
========================================================================== */
@@ -1201,7 +1244,6 @@
color: var(--color-on-background);
text-decoration: underline;
}
/* ==========================================================================
Notifications
========================================================================== */
@@ -1393,7 +1435,6 @@
.ap-notifications__btn--primary:hover {
opacity: 0.9;
}
/* ==========================================================================
Messages
========================================================================== */
@@ -1552,7 +1593,6 @@
white-space: nowrap;
}
}
/* ==========================================================================
Remote Profile
========================================================================== */
@@ -1861,7 +1901,6 @@
.ap-my-profile__info:first-child .ap-my-profile__avatar-wrap {
margin-top: 0;
}
/* ==========================================================================
Moderation
========================================================================== */
@@ -1981,7 +2020,6 @@
accent-color: var(--color-primary);
cursor: pointer;
}
/* ==========================================================================
Skeleton Loaders
========================================================================== */
@@ -2056,7 +2094,6 @@
flex-direction: column;
gap: var(--space-m);
}
/* ==========================================================================
Responsive
========================================================================== */
@@ -2090,7 +2127,6 @@
padding: var(--space-xs);
}
}
/* ==========================================================================
Post Detail View — Thread Layout
========================================================================== */
@@ -2148,6 +2184,17 @@
box-shadow: 0 0 0 1px var(--color-primary);
}
/* Replies — indented from the other side */
.ap-post-detail__replies {
margin-left: var(--space-l);
}
.ap-post-detail__reply-item {
border-left: 2px solid var(--color-outline);
padding-left: var(--space-m);
margin-bottom: var(--space-xs);
}
/* ==========================================================================
Tag Timeline Header
========================================================================== */
@@ -2255,6 +2302,267 @@
text-decoration: underline;
}
/* Followed tags bar */
.ap-followed-tags {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--space-xs);
padding: var(--space-xs) 0;
margin-bottom: var(--space-s);
font-size: var(--font-size-s);
}
.ap-followed-tags__label {
color: var(--color-on-offset);
font-weight: 600;
}
/* ==========================================================================
New Posts Banner
========================================================================== */
.ap-new-posts-banner {
left: 0;
position: sticky;
right: 0;
top: 0;
z-index: 10;
}
.ap-new-posts-banner__btn {
background: var(--color-primary);
border: none;
border-radius: var(--border-radius-small);
color: var(--color-on-primary);
cursor: pointer;
display: block;
font-family: inherit;
font-size: var(--font-size-s);
margin: 0 auto var(--space-s);
padding: var(--space-xs) var(--space-m);
text-align: center;
width: auto;
}
.ap-new-posts-banner__btn:hover {
opacity: 0.9;
}
/* ==========================================================================
Read State
========================================================================== */
.ap-card--read {
opacity: 0.7;
transition: opacity 0.3s ease;
}
.ap-card--read:hover {
opacity: 1;
}
/* ==========================================================================
Unread Toggle
========================================================================== */
.ap-unread-toggle {
margin-left: auto;
}
.ap-unread-toggle--active {
background: color-mix(in srgb, var(--color-primary) 12%, transparent);
font-weight: 600;
}
/* ==========================================================================
Quote Embeds
========================================================================== */
.ap-quote-embed {
border: var(--border-width-thin) solid var(--color-outline);
border-radius: var(--border-radius-small);
margin-top: var(--space-s);
overflow: hidden;
transition: border-color 0.15s ease;
}
.ap-quote-embed:hover {
border-color: var(--color-outline-variant);
}
.ap-quote-embed--pending {
border-style: dashed;
}
.ap-quote-embed__link {
color: inherit;
display: block;
padding: var(--space-s) var(--space-m);
text-decoration: none;
}
.ap-quote-embed__link:hover {
background: color-mix(in srgb, var(--color-offset) 50%, transparent);
}
.ap-quote-embed__author {
align-items: center;
display: flex;
gap: var(--space-xs);
margin-bottom: var(--space-xs);
}
.ap-quote-embed__avatar {
border-radius: 50%;
flex-shrink: 0;
height: 24px;
object-fit: cover;
width: 24px;
}
.ap-quote-embed__avatar--default {
align-items: center;
background: var(--color-offset);
color: var(--color-on-offset);
display: inline-flex;
font-size: var(--font-size-xs);
font-weight: 600;
justify-content: center;
}
.ap-quote-embed__author-info {
flex: 1;
min-width: 0;
}
.ap-quote-embed__name {
font-size: var(--font-size-s);
font-weight: 600;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.ap-quote-embed__handle {
color: var(--color-on-offset);
font-size: var(--font-size-xs);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.ap-quote-embed__time {
color: var(--color-on-offset);
flex-shrink: 0;
font-size: var(--font-size-xs);
white-space: nowrap;
}
.ap-quote-embed__title {
font-size: var(--font-size-s);
font-weight: 600;
margin: 0 0 var(--space-xs);
}
.ap-quote-embed__content {
color: var(--color-on-background);
font-size: var(--font-size-s);
line-height: calc(4 / 3 * 1em);
max-height: calc(1.333em * 6);
overflow: hidden;
}
.ap-quote-embed__content a {
display: inline;
}
.ap-quote-embed__content a span {
display: inline;
}
.ap-quote-embed__content p {
margin: 0 0 var(--space-xs);
}
.ap-quote-embed__content p:last-child {
margin-bottom: 0;
}
.ap-quote-embed__media {
margin-top: var(--space-xs);
}
.ap-quote-embed__photo {
border-radius: var(--border-radius-small);
max-height: 160px;
max-width: 100%;
object-fit: cover;
}
/* ==========================================================================
Poll / Question
========================================================================== */
.ap-poll {
margin-top: var(--space-s);
}
.ap-poll__option {
position: relative;
padding: var(--space-xs) var(--space-s);
margin-bottom: var(--space-xs);
border-radius: var(--border-radius-small);
background: var(--color-offset);
overflow: hidden;
}
.ap-poll__bar {
position: absolute;
top: 0;
left: 0;
bottom: 0;
background: var(--color-primary);
opacity: 0.15;
border-radius: var(--border-radius-small);
}
.ap-poll__label {
position: relative;
font-size: var(--font-size-s);
color: var(--color-on-background);
}
.ap-poll__votes {
position: relative;
float: right;
font-size: var(--font-size-s);
font-weight: 600;
color: var(--color-on-offset);
}
.ap-poll__footer {
font-size: var(--font-size-xs);
color: var(--color-on-offset);
margin-top: var(--space-xs);
}
/* Hashtag tab sources info line */
.ap-hashtag-sources {
color: var(--color-on-offset);
font-size: var(--font-size-s);
margin: 0;
padding: var(--space-s) 0 var(--space-xs);
}
/* Custom emoji */
.ap-custom-emoji {
height: 1.2em;
width: auto;
vertical-align: middle;
display: inline;
margin: 0 0.05em;
}
/* ==========================================================================
Explore Page
========================================================================== */
@@ -2503,33 +2811,6 @@
white-space: nowrap;
}
/* Replies — indented from the other side */
.ap-post-detail__replies {
margin-left: var(--space-l);
}
.ap-post-detail__reply-item {
border-left: 2px solid var(--color-outline);
padding-left: var(--space-m);
margin-bottom: var(--space-xs);
}
/* Followed tags bar */
.ap-followed-tags {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--space-xs);
padding: var(--space-xs) 0;
margin-bottom: var(--space-s);
font-size: var(--font-size-s);
}
.ap-followed-tags__label {
color: var(--color-on-offset);
font-weight: 600;
}
/* ==========================================================================
Explore: Tabbed Design
========================================================================== */
@@ -2812,295 +3093,6 @@
height: 1px;
visibility: hidden;
}
/* ==========================================================================
New Posts Banner
========================================================================== */
.ap-new-posts-banner {
left: 0;
position: sticky;
right: 0;
top: 0;
z-index: 10;
}
.ap-new-posts-banner__btn {
background: var(--color-primary);
border: none;
border-radius: var(--border-radius-small);
color: var(--color-on-primary);
cursor: pointer;
display: block;
font-family: inherit;
font-size: var(--font-size-s);
margin: 0 auto var(--space-s);
padding: var(--space-xs) var(--space-m);
text-align: center;
width: auto;
}
.ap-new-posts-banner__btn:hover {
opacity: 0.9;
}
/* ==========================================================================
Read State
========================================================================== */
.ap-card--read {
opacity: 0.7;
transition: opacity 0.3s ease;
}
.ap-card--read:hover {
opacity: 1;
}
/* ==========================================================================
Unread Toggle
========================================================================== */
.ap-unread-toggle {
margin-left: auto;
}
.ap-unread-toggle--active {
background: color-mix(in srgb, var(--color-primary) 12%, transparent);
font-weight: 600;
}
/* ==========================================================================
Quote Embeds
========================================================================== */
.ap-quote-embed {
border: var(--border-width-thin) solid var(--color-outline);
border-radius: var(--border-radius-small);
margin-top: var(--space-s);
overflow: hidden;
transition: border-color 0.15s ease;
}
.ap-quote-embed:hover {
border-color: var(--color-outline-variant);
}
.ap-quote-embed--pending {
border-style: dashed;
}
.ap-quote-embed__link {
color: inherit;
display: block;
padding: var(--space-s) var(--space-m);
text-decoration: none;
}
.ap-quote-embed__link:hover {
background: color-mix(in srgb, var(--color-offset) 50%, transparent);
}
.ap-quote-embed__author {
align-items: center;
display: flex;
gap: var(--space-xs);
margin-bottom: var(--space-xs);
}
.ap-quote-embed__avatar {
border-radius: 50%;
flex-shrink: 0;
height: 24px;
object-fit: cover;
width: 24px;
}
.ap-quote-embed__avatar--default {
align-items: center;
background: var(--color-offset);
color: var(--color-on-offset);
display: inline-flex;
font-size: var(--font-size-xs);
font-weight: 600;
justify-content: center;
}
.ap-quote-embed__author-info {
flex: 1;
min-width: 0;
}
.ap-quote-embed__name {
font-size: var(--font-size-s);
font-weight: 600;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.ap-quote-embed__handle {
color: var(--color-on-offset);
font-size: var(--font-size-xs);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.ap-quote-embed__time {
color: var(--color-on-offset);
flex-shrink: 0;
font-size: var(--font-size-xs);
white-space: nowrap;
}
.ap-quote-embed__title {
font-size: var(--font-size-s);
font-weight: 600;
margin: 0 0 var(--space-xs);
}
.ap-quote-embed__content {
color: var(--color-on-background);
font-size: var(--font-size-s);
line-height: calc(4 / 3 * 1em);
max-height: calc(1.333em * 6);
overflow: hidden;
}
.ap-quote-embed__content a {
display: inline;
}
.ap-quote-embed__content a span {
display: inline;
}
.ap-quote-embed__content p {
margin: 0 0 var(--space-xs);
}
.ap-quote-embed__content p:last-child {
margin-bottom: 0;
}
.ap-quote-embed__media {
margin-top: var(--space-xs);
}
.ap-quote-embed__photo {
border-radius: var(--border-radius-small);
max-height: 160px;
max-width: 100%;
object-fit: cover;
}
/* Hashtag tab sources info line */
.ap-hashtag-sources {
color: var(--color-on-offset);
font-size: var(--font-size-s);
margin: 0;
padding: var(--space-s) 0 var(--space-xs);
}
/* Custom emoji */
.ap-custom-emoji {
height: 1.2em;
width: auto;
vertical-align: middle;
display: inline;
margin: 0 0.05em;
}
/* Gallery items — positioned for ALT badge overlay */
.ap-card__gallery-item {
position: relative;
}
/* ALT text badges */
.ap-media__alt-badge {
position: absolute;
bottom: 0.5rem;
left: 0.5rem;
background: hsl(var(--tint-neutral) 10% / 0.7);
color: var(--color-neutral99);
font-size: 0.65rem;
font-weight: 700;
padding: 0.15rem 0.35rem;
border-radius: var(--border-radius-small);
border: none;
cursor: pointer;
text-transform: uppercase;
letter-spacing: 0.03em;
z-index: 1;
}
.ap-media__alt-badge:hover {
background: hsl(var(--tint-neutral) 10% / 0.9);
}
.ap-media__alt-text {
position: absolute;
bottom: 2.2rem;
left: 0.5rem;
right: 0.5rem;
background: hsl(var(--tint-neutral) 10% / 0.85);
color: var(--color-neutral99);
font-size: var(--font-size-s);
padding: 0.5rem;
border-radius: var(--border-radius-small);
max-height: 8rem;
overflow-y: auto;
z-index: 2;
}
/* ==========================================================================
Poll / Question
========================================================================== */
.ap-poll {
margin-top: var(--space-s);
}
.ap-poll__option {
position: relative;
padding: var(--space-xs) var(--space-s);
margin-bottom: var(--space-xs);
border-radius: var(--border-radius-small);
background: var(--color-offset);
overflow: hidden;
}
.ap-poll__bar {
position: absolute;
top: 0;
left: 0;
bottom: 0;
background: var(--color-primary);
opacity: 0.15;
border-radius: var(--border-radius-small);
}
.ap-poll__label {
position: relative;
font-size: var(--font-size-s);
color: var(--color-on-background);
}
.ap-poll__votes {
position: relative;
float: right;
font-size: var(--font-size-s);
font-weight: 600;
color: var(--color-on-offset);
}
.ap-poll__footer {
font-size: var(--font-size-xs);
color: var(--color-on-offset);
margin-top: var(--space-xs);
}
/* ==========================================================================
Dark Mode Overrides
Softens saturated colors that are uncomfortable on dark backgrounds.
@@ -3195,7 +3187,6 @@
background: color-mix(in srgb, var(--color-purple45) 18%, transparent);
}
}
/* ==========================================================================
Federation Management
========================================================================== */
@@ -3438,4 +3429,3 @@
.button--danger:hover {
background-color: var(--color-red35, #c0392b);
}
+131 -695
View File
@@ -4,6 +4,7 @@ import { setupFederation, buildPersonActor } from "./lib/federation-setup.js";
import { createMastodonRouter } from "./lib/mastodon/router.js";
import { setLocalIdentity } from "./lib/mastodon/entities/status.js";
import { initRedisCache } from "./lib/redis-cache.js";
import { createIndexes } from "./lib/init-indexes.js";
import { lookupWithSecurity } from "./lib/lookup-helpers.js";
import {
needsDirectFollow,
@@ -16,8 +17,8 @@ import {
import {
jf2ToActivityStreams,
jf2ToAS2Activity,
parseMentions,
} from "./lib/jf2-to-as2.js";
import { createSyndicator } from "./lib/syndicator.js";
import { dashboardController } from "./lib/controllers/dashboard.js";
import {
readerController,
@@ -115,8 +116,7 @@ import {
} from "./lib/controllers/refollow.js";
import { startBatchRefollow } from "./lib/batch-refollow.js";
import { logActivity } from "./lib/activity-log.js";
import { resolveAuthor } from "./lib/resolve-author.js";
import { addTimelineItem } from "./lib/storage/timeline.js";
import { batchBroadcast } from "./lib/batch-broadcast.js";
import { scheduleCleanup } from "./lib/timeline-cleanup.js";
import { runSeparateMentionsMigration } from "./lib/migrations/separate-mentions.js";
import { loadBlockedServersToRedis } from "./lib/storage/server-blocks.js";
@@ -484,38 +484,6 @@ export default class ActivityPubEndpoint {
const router = express.Router(); // eslint-disable-line new-cap
const self = this;
// Intercept Micropub delete actions to broadcast Delete to fediverse.
// Wraps res.json to detect successful delete responses, then fires
// broadcastDelete asynchronously so remote servers remove the post.
router.use((req, res, next) => {
if (req.method !== "POST") return next();
if (!req.path.endsWith("/micropub")) return next();
const action = req.query?.action || req.body?.action;
if (action !== "delete") return next();
const postUrl = req.query?.url || req.body?.url;
if (!postUrl) return next();
const originalJson = res.json.bind(res);
res.json = function (body) {
// Fire broadcastDelete after successful delete (status 200)
if (res.statusCode === 200 && body?.success === "delete") {
console.info(
`[ActivityPub] Micropub delete detected for ${postUrl}, broadcasting Delete to followers`,
);
self.broadcastDelete(postUrl).catch((error) => {
console.warn(
`[ActivityPub] broadcastDelete after Micropub delete failed: ${error.message}`,
);
});
}
return originalJson(body);
};
return next();
});
// Let Fedify handle NodeInfo data (/nodeinfo/2.1)
// Only pass GET/HEAD requests — POST/PUT/DELETE must not go through
// Fedify here, because fromExpressRequest() consumes the body stream,
@@ -562,6 +530,20 @@ export default class ActivityPubEndpoint {
});
if (!post || post.properties?.deleted) {
// FEP-4f05: Serve Tombstone for deleted posts
const { getTombstone } = await import("./lib/storage/tombstones.js");
const tombstone = await getTombstone(self._collections, requestUrl);
if (tombstone) {
res.status(410).set("Content-Type", "application/activity+json").json({
"@context": "https://www.w3.org/ns/activitystreams",
type: "Tombstone",
id: requestUrl,
formerType: tombstone.formerType,
published: tombstone.published || undefined,
deleted: tombstone.deleted,
});
return;
}
return next();
}
@@ -594,284 +576,7 @@ export default class ActivityPubEndpoint {
* Syndicator — delivers posts to ActivityPub followers via Fedify.
*/
get syndicator() {
const self = this;
return {
name: "ActivityPub syndicator",
options: { checked: self.options.checked },
get info() {
const hostname = self._publicationUrl
? new URL(self._publicationUrl).hostname
: "example.com";
return {
checked: self.options.checked,
name: `@${self.options.actor.handle}@${hostname}`,
uid: self._publicationUrl || "https://example.com/",
service: {
name: "ActivityPub (Fediverse)",
photo: "/assets/@rmdes-indiekit-endpoint-activitypub/icon.svg",
url: self._publicationUrl || "https://example.com/",
},
};
},
async syndicate(properties) {
if (!self._federation) {
return undefined;
}
const visibility = String(properties?.visibility || "").toLowerCase();
if (visibility === "unlisted") {
console.info(
"[ActivityPub] Skipping federation for unlisted post: " +
(properties?.url || "unknown"),
);
await logActivity(self._collections.ap_activities, {
direction: "outbound",
type: "Syndicate",
actorUrl: self._publicationUrl,
objectUrl: properties?.url,
summary: "Syndication skipped: post visibility is unlisted",
}).catch(() => {});
return undefined;
}
try {
const actorUrl = self._getActorUrl();
const handle = self.options.actor.handle;
const ctx = self._federation.createContext(
new URL(self._publicationUrl),
{ handle, publicationUrl: self._publicationUrl },
);
// For replies, resolve the original post author for proper
// addressing (CC) and direct inbox delivery
let replyToActor = null;
if (properties["in-reply-to"]) {
try {
const remoteObject = await lookupWithSecurity(ctx,
new URL(properties["in-reply-to"]),
);
if (remoteObject && typeof remoteObject.getAttributedTo === "function") {
const author = await remoteObject.getAttributedTo();
const authorActor = Array.isArray(author) ? author[0] : author;
if (authorActor?.id) {
replyToActor = {
url: authorActor.id.href,
handle: authorActor.preferredUsername || null,
recipient: authorActor,
};
console.info(
`[ActivityPub] Reply to ${properties["in-reply-to"]} — resolved author: ${replyToActor.url}`,
);
}
}
} catch (error) {
console.warn(
`[ActivityPub] Could not resolve reply-to author for ${properties["in-reply-to"]}: ${error.message}`,
);
}
}
// Resolve @user@domain mentions in content via WebFinger
const contentText = properties.content?.html || properties.content || "";
const mentionHandles = parseMentions(contentText);
const resolvedMentions = [];
const mentionRecipients = [];
for (const { handle } of mentionHandles) {
try {
const mentionedActor = await lookupWithSecurity(ctx,
new URL(`acct:${handle}`),
);
if (mentionedActor?.id) {
resolvedMentions.push({
handle,
actorUrl: mentionedActor.id.href,
profileUrl: mentionedActor.url?.href || null,
});
mentionRecipients.push({
handle,
actorUrl: mentionedActor.id.href,
actor: mentionedActor,
});
console.info(
`[ActivityPub] Resolved mention @${handle}${mentionedActor.id.href}`,
);
}
} catch (error) {
console.warn(
`[ActivityPub] Could not resolve mention @${handle}: ${error.message}`,
);
// Still add with no actorUrl so it gets a fallback link
resolvedMentions.push({ handle, actorUrl: null });
}
}
const activity = await jf2ToAS2Activity(
properties,
actorUrl,
self._publicationUrl,
{
replyToActorUrl: replyToActor?.url,
replyToActorHandle: replyToActor?.handle,
visibility: self.options.defaultVisibility,
mentions: resolvedMentions,
},
);
if (!activity) {
await logActivity(self._collections.ap_activities, {
direction: "outbound",
type: "Syndicate",
actorUrl: self._publicationUrl,
objectUrl: properties.url,
summary: `Syndication skipped: could not convert post to AS2`,
});
return undefined;
}
// Count followers for logging
const followerCount =
await self._collections.ap_followers.countDocuments();
console.info(
`[ActivityPub] Sending ${activity.constructor?.name || "activity"} for ${properties.url} to ${followerCount} followers`,
);
// Send to followers via shared inboxes with collection sync (FEP-8fcf)
await ctx.sendActivity(
{ identifier: handle },
"followers",
activity,
{
preferSharedInbox: true,
syncCollection: true,
orderingKey: properties.url,
},
);
// For replies, also deliver to the original post author's inbox
// so their server can thread the reply under the original post
if (replyToActor?.recipient) {
try {
await ctx.sendActivity(
{ identifier: handle },
replyToActor.recipient,
activity,
{ orderingKey: properties.url },
);
console.info(
`[ActivityPub] Reply delivered to author: ${replyToActor.url}`,
);
} catch (error) {
console.warn(
`[ActivityPub] Failed to deliver reply to ${replyToActor.url}: ${error.message}`,
);
}
}
// Deliver to mentioned actors' inboxes (skip reply-to author, already delivered above)
for (const { handle: mHandle, actorUrl: mUrl, actor: mActor } of mentionRecipients) {
if (replyToActor?.url === mUrl) continue;
try {
await ctx.sendActivity(
{ identifier: handle },
mActor,
activity,
{ orderingKey: properties.url },
);
console.info(
`[ActivityPub] Mention delivered to @${mHandle}: ${mUrl}`,
);
} catch (error) {
console.warn(
`[ActivityPub] Failed to deliver mention to @${mHandle}: ${error.message}`,
);
}
}
// Determine activity type name
const typeName =
activity.constructor?.name || "Create";
const replyNote = replyToActor
? ` (reply to ${replyToActor.url})`
: "";
const mentionNote = mentionRecipients.length > 0
? ` (mentions: ${mentionRecipients.map(m => `@${m.handle}`).join(", ")})`
: "";
await logActivity(self._collections.ap_activities, {
direction: "outbound",
type: typeName,
actorUrl: self._publicationUrl,
objectUrl: properties.url,
targetUrl: properties["in-reply-to"] || undefined,
summary: `Sent ${typeName} for ${properties.url} to ${followerCount} followers${replyNote}${mentionNote}`,
});
console.info(
`[ActivityPub] Syndication queued: ${typeName} for ${properties.url}${replyNote}`,
);
// Mirror own Micropub-created posts into ap_timeline so the Mastodon
// Client API (context, statuses, etc.) can find them by ID.
if (typeName === "Create" && properties.url) {
try {
const postUrl = properties.url;
const rawHtml = properties.content?.html || (typeof properties.content === "string" ? properties.content : "") || "";
const now = new Date().toISOString();
const postType = properties["post-type"] || "note";
const asArray = (v) => Array.isArray(v) ? v : v ? [v] : [];
await addTimelineItem(self._collections, {
uid: postUrl,
url: postUrl,
type: postType,
content: { html: rawHtml, text: rawHtml.replace(/<[^>]*>/g, "") },
summary: properties["content-warning"] || properties.summary || "",
sensitive: !!(properties.sensitive || properties["post-status"] === "sensitive" || properties["content-warning"]),
visibility: properties.visibility || self.options.defaultVisibility || "public",
language: properties.lang || properties.language || null,
inReplyTo: properties["in-reply-to"] || null,
published: properties.published || now,
createdAt: now,
author: {
name: self.options.actor.name || handle,
url: actorUrl,
photo: self.options.actor.icon || "",
handle: `@${handle}`,
emojis: [],
bot: false,
},
photo: asArray(properties.photo),
video: asArray(properties.video),
audio: asArray(properties.audio),
category: asArray(properties.category),
counts: { replies: 0, boosts: 0, likes: 0 },
linkPreviews: [],
mentions: [],
emojis: [],
});
} catch (timelineError) {
console.warn("[ActivityPub] Failed to mirror syndicated post to ap_timeline:", timelineError.message);
}
}
return properties.url || undefined;
} catch (error) {
console.error("[ActivityPub] Syndication failed:", error.message);
await logActivity(self._collections.ap_activities, {
direction: "outbound",
type: "Syndicate",
actorUrl: self._publicationUrl,
objectUrl: properties.url,
summary: `Syndication failed: ${error.message}`,
}).catch(() => {});
return undefined;
}
},
};
return createSyndicator(this);
}
/**
@@ -1270,9 +975,6 @@ export default class ActivityPubEndpoint {
{ handle, publicationUrl: this._publicationUrl },
);
// Build the full actor object (same as what the dispatcher serves).
// Note: ctx.getActor() only exists on RequestContext, not the base
// Context returned by createContext(), so we use the shared helper.
const actor = await buildPersonActor(
ctx,
handle,
@@ -1289,86 +991,17 @@ export default class ActivityPubEndpoint {
object: actor,
});
// Fetch followers and deduplicate by shared inbox so each remote
// server only gets one delivery (same as preferSharedInbox but
// gives us control over batching).
const followers = await this._collections.ap_followers
.find({})
.project({ actorUrl: 1, inbox: 1, sharedInbox: 1 })
.toArray();
// Group by shared inbox (or direct inbox if none)
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 Update(Person) to ${uniqueRecipients.length} ` +
`unique inboxes (${followers.length} followers) in batches of ${BATCH_SIZE}`,
);
for (let i = 0; i < uniqueRecipients.length; i += BATCH_SIZE) {
const batch = uniqueRecipients.slice(i, i + BATCH_SIZE);
// Build Fedify-compatible Recipient objects:
// extractInboxes() reads: recipient.id, recipient.inboxId,
// recipient.endpoints?.sharedInbox
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,
update,
{ preferSharedInbox: true },
);
delivered += batch.length;
} catch (error) {
failed += batch.length;
console.warn(
`[ActivityPub] Batch ${Math.floor(i / BATCH_SIZE) + 1} failed: ${error.message}`,
);
}
// Stagger batches so remote servers don't all re-fetch at once
if (i + BATCH_SIZE < uniqueRecipients.length) {
await new Promise((resolve) => setTimeout(resolve, BATCH_DELAY_MS));
}
}
console.info(
`[ActivityPub] Update(Person) broadcast complete: ` +
`${delivered} delivered, ${failed} failed`,
);
await logActivity(this._collections.ap_activities, {
direction: "outbound",
type: "Update",
actorUrl: this._publicationUrl,
await batchBroadcast({
federation: this._federation,
collections: this._collections,
publicationUrl: this._publicationUrl,
handle,
activity: update,
label: "Update(Person)",
objectUrl: this._getActorUrl(),
summary: `Sent Update(Person) to ${delivered}/${uniqueRecipients.length} inboxes`,
}).catch(() => {});
});
} catch (error) {
console.error(
"[ActivityPub] broadcastActorUpdate failed:",
error.message,
);
console.error("[ActivityPub] broadcastActorUpdate failed:", error.message);
}
}
@@ -1393,76 +1026,106 @@ export default class ActivityPubEndpoint {
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,
await batchBroadcast({
federation: this._federation,
collections: this._collections,
publicationUrl: this._publicationUrl,
handle,
activity: del,
label: "Delete",
objectUrl: postUrl,
summary: `Sent Delete for ${postUrl} to ${delivered} inboxes`,
}).catch(() => {});
});
} catch (error) {
console.warn("[ActivityPub] broadcastDelete failed:", error.message);
}
}
/**
* Called by post-content.js when a Micropub delete succeeds.
* Broadcasts an ActivityPub Delete activity to all followers.
* @param {string} url - Full URL of the deleted post
*/
async delete(url) {
// Record tombstone for FEP-4f05
try {
const { addTombstone } = await import("./lib/storage/tombstones.js");
const postsCol = this._collections.posts;
const post = postsCol ? await postsCol.findOne({ "properties.url": url }) : null;
await addTombstone(this._collections, {
url,
formerType: post?.properties?.["post-type"] === "article" ? "Article" : "Note",
published: post?.properties?.published || null,
deleted: new Date().toISOString(),
});
} catch (error) {
console.warn(`[ActivityPub] Tombstone creation failed for ${url}: ${error.message}`);
}
await this.broadcastDelete(url).catch((err) =>
console.warn(`[ActivityPub] broadcastDelete failed for ${url}: ${err.message}`)
);
}
/**
* Called by post-content.js when a Micropub update succeeds.
* Broadcasts an ActivityPub Update activity for the post to all followers.
* @param {object} properties - JF2 post properties (must include url)
*/
async update(properties) {
await this.broadcastPostUpdate(properties).catch((err) =>
console.warn(`[ActivityPub] broadcastPostUpdate failed for ${properties?.url}: ${err.message}`)
);
}
/**
* Send an Update activity to all followers for a modified post.
* Mirrors broadcastDelete() pattern: batch delivery with shared inbox dedup.
* @param {object} properties - JF2 post properties
*/
async broadcastPostUpdate(properties) {
if (!this._federation) return;
try {
const { Update } = await import("@fedify/fedify/vocab");
const actorUrl = this._getActorUrl();
const handle = this.options.actor.handle;
const ctx = this._federation.createContext(
new URL(this._publicationUrl),
{ handle, publicationUrl: this._publicationUrl },
);
const createActivity = jf2ToAS2Activity(
properties,
actorUrl,
this._publicationUrl,
{ visibility: this.options.defaultVisibility },
);
if (!createActivity) {
console.warn(`[ActivityPub] broadcastPostUpdate: could not convert post to AS2 for ${properties?.url}`);
return;
}
const noteObject = await createActivity.getObject();
const activity = new Update({
actor: ctx.getActorUri(handle),
object: noteObject,
});
await batchBroadcast({
federation: this._federation,
collections: this._collections,
publicationUrl: this._publicationUrl,
handle,
activity,
label: "Update(Note)",
objectUrl: properties.url,
});
} catch (error) {
console.warn("[ActivityPub] broadcastPostUpdate failed:", error.message);
}
}
/**
* Build the full actor URL from config.
* @returns {string}
@@ -1514,6 +1177,8 @@ export default class ActivityPubEndpoint {
Indiekit.addCollection("ap_oauth_apps");
Indiekit.addCollection("ap_oauth_tokens");
Indiekit.addCollection("ap_markers");
// Tombstones for soft-deleted posts (FEP-4f05)
Indiekit.addCollection("ap_tombstones");
// Store collection references (posts resolved lazily)
const indiekitCollections = Indiekit.collections;
@@ -1551,249 +1216,18 @@ export default class ActivityPubEndpoint {
ap_oauth_apps: indiekitCollections.get("ap_oauth_apps"),
ap_oauth_tokens: indiekitCollections.get("ap_oauth_tokens"),
ap_markers: indiekitCollections.get("ap_markers"),
ap_tombstones: indiekitCollections.get("ap_tombstones"),
get posts() {
return indiekitCollections.get("posts");
},
_publicationUrl: this._publicationUrl,
};
// Create indexes — wrapped in try-catch because collection references
// may be undefined if MongoDB hasn't finished connecting yet.
// Indexes are idempotent; they'll be created on next successful startup.
try {
// TTL index for activity cleanup (MongoDB handles expiry automatically)
const retentionDays = this.options.activityRetentionDays;
if (retentionDays > 0) {
this._collections.ap_activities.createIndex(
{ receivedAt: 1 },
{ expireAfterSeconds: retentionDays * 86_400 },
);
}
// Performance indexes for inbox handlers and batch refollow
this._collections.ap_followers.createIndex(
{ actorUrl: 1 },
{ unique: true, background: true },
);
this._collections.ap_following.createIndex(
{ actorUrl: 1 },
{ unique: true, background: true },
);
this._collections.ap_following.createIndex(
{ source: 1 },
{ background: true },
);
this._collections.ap_activities.createIndex(
{ objectUrl: 1 },
{ background: true },
);
this._collections.ap_activities.createIndex(
{ type: 1, actorUrl: 1, objectUrl: 1 },
{ background: true },
);
// Reader indexes (timeline, notifications, moderation, interactions)
this._collections.ap_timeline.createIndex(
{ uid: 1 },
{ unique: true, background: true },
);
this._collections.ap_timeline.createIndex(
{ published: -1 },
{ background: true },
);
this._collections.ap_timeline.createIndex(
{ "author.url": 1 },
{ background: true },
);
this._collections.ap_timeline.createIndex(
{ type: 1, published: -1 },
{ background: true },
);
this._collections.ap_notifications.createIndex(
{ uid: 1 },
{ unique: true, background: true },
);
this._collections.ap_notifications.createIndex(
{ published: -1 },
{ background: true },
);
this._collections.ap_notifications.createIndex(
{ read: 1 },
{ background: true },
);
this._collections.ap_notifications.createIndex(
{ type: 1, published: -1 },
{ background: true },
);
// TTL index for notification cleanup
const notifRetention = this.options.notificationRetentionDays;
if (notifRetention > 0) {
this._collections.ap_notifications.createIndex(
{ createdAt: 1 },
{ expireAfterSeconds: notifRetention * 86_400 },
);
}
// 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")
.catch(() => {})
.then(() =>
this._collections.ap_muted.createIndex(
{ url: 1 },
{ unique: true, sparse: true, background: true },
),
)
.catch(() => {});
this._collections.ap_muted
.dropIndex("keyword_1")
.catch(() => {})
.then(() =>
this._collections.ap_muted.createIndex(
{ keyword: 1 },
{ unique: true, sparse: true, background: true },
),
)
.catch(() => {});
this._collections.ap_blocked.createIndex(
{ url: 1 },
{ unique: true, background: true },
);
this._collections.ap_interactions.createIndex(
{ objectUrl: 1, type: 1 },
{ unique: true, background: true },
);
this._collections.ap_interactions.createIndex(
{ type: 1 },
{ background: true },
);
// Followed hashtags — unique on tag (case-insensitive via normalization at write time)
this._collections.ap_followed_tags.createIndex(
{ tag: 1 },
{ unique: true, background: true },
);
// Tag filtering index on timeline
this._collections.ap_timeline.createIndex(
{ category: 1, published: -1 },
{ background: true },
);
// Explore tab indexes
// Compound unique on (type, domain, scope, hashtag) prevents duplicate tabs.
// ALL insertions must explicitly set all four fields (unused fields = null)
// because MongoDB treats missing fields differently from null in unique indexes.
this._collections.ap_explore_tabs.createIndex(
{ type: 1, domain: 1, scope: 1, hashtag: 1 },
{ unique: true, background: true },
);
// Order index for efficient sorting of tab bar
this._collections.ap_explore_tabs.createIndex(
{ 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 },
);
// Pending follow requests — unique on actorUrl
this._collections.ap_pending_follows.createIndex(
{ actorUrl: 1 },
{ unique: true, background: true },
);
this._collections.ap_pending_follows.createIndex(
{ requestedAt: -1 },
{ background: true },
);
// Server-level blocks
this._collections.ap_blocked_servers.createIndex(
{ hostname: 1 },
{ unique: true, background: true },
);
// Key freshness tracking
this._collections.ap_key_freshness.createIndex(
{ actorUrl: 1 },
{ unique: true, background: true },
);
// Inbox queue indexes
this._collections.ap_inbox_queue.createIndex(
{ status: 1, receivedAt: 1 },
{ background: true },
);
// TTL: auto-prune completed items after 24h
this._collections.ap_inbox_queue.createIndex(
{ processedAt: 1 },
{ expireAfterSeconds: 86_400, background: true },
);
// Mastodon Client API indexes
this._collections.ap_oauth_apps.createIndex(
{ clientId: 1 },
{ unique: true, background: true },
);
this._collections.ap_oauth_tokens.createIndex(
{ accessToken: 1 },
{ unique: true, sparse: true, background: true },
);
this._collections.ap_oauth_tokens.createIndex(
{ code: 1 },
{ unique: true, sparse: true, background: true },
);
this._collections.ap_markers.createIndex(
{ userId: 1, timeline: 1 },
{ unique: true, background: true },
);
} catch {
// Index creation failed — collections not yet available.
// Indexes already exist from previous startups; non-fatal.
}
// Create indexes (idempotent — safe on every startup)
createIndexes(this._collections, {
activityRetentionDays: this.options.activityRetentionDays,
notificationRetentionDays: this.options.notificationRetentionDays,
});
// Seed actor profile from config on first run
this._seedProfile().catch((error) => {
@@ -1898,6 +1332,7 @@ export default class ActivityPubEndpoint {
}, 10_000);
// Run one-time migrations (idempotent — safe to run on every startup)
console.info("[ActivityPub] Init: starting post-refollow setup");
runSeparateMentionsMigration(this._collections).then(({ skipped, updated }) => {
if (!skipped) {
console.log(`[ActivityPub] Migration separate-mentions: updated ${updated} timeline items`);
@@ -1944,6 +1379,7 @@ export default class ActivityPubEndpoint {
});
// Start async inbox queue processor (processes one item every 3s)
console.info("[ActivityPub] Init: starting inbox queue processor");
this._inboxProcessorInterval = startInboxProcessor(
this._collections,
() => this._federation?.createContext(new URL(this._publicationUrl), {
+98
View File
@@ -0,0 +1,98 @@
/**
* Shared batch broadcast for delivering activities to all followers.
* Deduplicates by shared inbox and delivers in batches with delay.
* @module batch-broadcast
*/
import { logActivity } from "./activity-log.js";
const BATCH_SIZE = 25;
const BATCH_DELAY_MS = 5000;
/**
* Broadcast an activity to all followers via batch delivery.
*
* @param {object} options
* @param {object} options.federation - Fedify Federation instance
* @param {object} options.collections - MongoDB collections (needs ap_followers, ap_activities)
* @param {string} options.publicationUrl - Our publication URL
* @param {string} options.handle - Our actor handle
* @param {object} options.activity - Fedify activity object to send
* @param {string} options.label - Human-readable label for logging (e.g. "Update(Person)")
* @param {string} [options.objectUrl] - URL of the object being broadcast about
*/
export async function batchBroadcast({
federation,
collections,
publicationUrl,
handle,
activity,
label,
objectUrl,
}) {
const ctx = federation.createContext(new URL(publicationUrl), {
handle,
publicationUrl,
});
const followers = await collections.ap_followers
.find({})
.project({ actorUrl: 1, inbox: 1, sharedInbox: 1 })
.toArray();
// Deduplicate by shared inbox
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()];
let delivered = 0;
let failed = 0;
console.info(
`[ActivityPub] Broadcasting ${label} to ${uniqueRecipients.length} ` +
`unique inboxes (${followers.length} followers) in batches of ${BATCH_SIZE}`,
);
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, activity, {
preferSharedInbox: true,
});
delivered += batch.length;
} catch (error) {
failed += batch.length;
console.warn(
`[ActivityPub] ${label} 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] ${label} broadcast complete: ${delivered} delivered, ${failed} failed`,
);
await logActivity(collections.ap_activities, {
direction: "outbound",
type: label.includes("(") ? label.split("(")[0] : label,
actorUrl: publicationUrl,
objectUrl: objectUrl || "",
summary: `Sent ${label} to ${delivered}/${uniqueRecipients.length} inboxes`,
}).catch(() => {});
}
+23 -9
View File
@@ -2,18 +2,21 @@
* Authorize Interaction controller — handles the remote follow / authorize
* interaction flow for ActivityPub federation.
*
* When a remote server (WordPress AP, Misskey, etc.) discovers our WebFinger
* subscribe template, it redirects the user here with ?uri={actorOrPostUrl}.
* Supports:
* - OStatus subscribe template (legacy remote follow via ?uri=...)
* - FEP-3b86 Activity Intents (via ?uri=...&intent=follow|create|like|announce)
*
* Flow:
* 1. Missing uri → render error page
* 2. Unauthenticated → redirect to login, then back here
* 3. Authenticated → redirect to the reader's remote profile page
* 3. Authenticated → route to appropriate page based on intent
*/
export function authorizeInteractionController(plugin) {
return async (req, res) => {
const uri = req.query.uri || req.query.acct;
const intent = req.query.intent || "";
if (!uri) {
return res.status(400).render("activitypub-authorize-interaction", {
title: "Authorize Interaction",
@@ -29,17 +32,28 @@ export function authorizeInteractionController(plugin) {
// then back to this page after auth
const session = req.session;
if (!session?.access_token) {
const returnUrl = `${plugin.options.mountPath}/authorize_interaction?uri=${encodeURIComponent(uri)}`;
const params = `uri=${encodeURIComponent(uri)}${intent ? `&intent=${intent}` : ""}`;
const returnUrl = `${plugin.options.mountPath}/authorize_interaction?${params}`;
return res.redirect(
`/session/login?redirect=${encodeURIComponent(returnUrl)}`,
);
}
// Authenticated — redirect to the remote profile viewer in our reader
// which already has follow/unfollow/like/boost functionality
const mp = plugin.options.mountPath;
const encodedUrl = encodeURIComponent(resource);
return res.redirect(
`${plugin.options.mountPath}/admin/reader/profile?url=${encodedUrl}`,
);
// Route based on intent (FEP-3b86)
switch (intent) {
case "follow":
return res.redirect(`${mp}/admin/reader/profile?url=${encodedUrl}`);
case "create":
return res.redirect(`${mp}/admin/reader/compose?replyTo=${encodedUrl}`);
case "like":
case "announce":
return res.redirect(`${mp}/admin/reader/post?url=${encodedUrl}`);
default:
// Default: resolve to remote profile page
return res.redirect(`${mp}/admin/reader/profile?url=${encodedUrl}`);
}
};
}
+5 -7
View File
@@ -7,6 +7,7 @@ import { getToken, validateToken } from "../csrf.js";
import { sanitizeContent } from "../timeline-store.js";
import { lookupWithSecurity } from "../lookup-helpers.js";
import { addNotification } from "../storage/notifications.js";
import { createContext, getHandle, isFederationReady } from "../federation-actions.js";
/**
* Fetch syndication targets from the Micropub config endpoint.
@@ -71,18 +72,15 @@ export function composeController(mountPath, plugin) {
: null;
// If not in timeline, try to look up remotely
if (!replyContext && plugin._federation) {
if (!replyContext && isFederationReady(plugin)) {
try {
const handle = plugin.options.actor.handle;
const ctx = plugin._federation.createContext(
new URL(plugin._publicationUrl),
{ handle, publicationUrl: plugin._publicationUrl },
);
const handle = getHandle(plugin);
const ctx = createContext(plugin);
// Use authenticated document loader for Authorized Fetch
const documentLoader = await ctx.getDocumentLoader({
identifier: handle,
});
const remoteObject = await lookupWithSecurity(ctx,new URL(replyTo), {
const remoteObject = await lookupWithSecurity(ctx, new URL(replyTo), {
documentLoader,
});
+8 -13
View File
@@ -5,6 +5,7 @@
import { validateToken } from "../csrf.js";
import { resolveAuthor } from "../resolve-author.js";
import { createContext, getHandle, getPublicationUrl, isFederationReady } from "../federation-actions.js";
/**
* POST /admin/reader/boost — send an Announce activity to followers.
@@ -28,7 +29,7 @@ export function boostController(mountPath, plugin) {
});
}
if (!plugin._federation) {
if (!isFederationReady(plugin)) {
return response.status(503).json({
success: false,
error: "Federation not initialized",
@@ -36,14 +37,11 @@ export function boostController(mountPath, plugin) {
}
const { Announce } = await import("@fedify/fedify/vocab");
const handle = plugin.options.actor.handle;
const ctx = plugin._federation.createContext(
new URL(plugin._publicationUrl),
{ handle, publicationUrl: plugin._publicationUrl },
);
const handle = getHandle(plugin);
const ctx = createContext(plugin);
const uuid = crypto.randomUUID();
const baseUrl = plugin._publicationUrl.replace(/\/$/, "");
const baseUrl = getPublicationUrl(plugin).replace(/\/$/, "");
const activityId = `${baseUrl}/activitypub/boosts/${uuid}`;
const publicAddress = new URL(
@@ -160,7 +158,7 @@ export function unboostController(mountPath, plugin) {
});
}
if (!plugin._federation) {
if (!isFederationReady(plugin)) {
return response.status(503).json({
success: false,
error: "Federation not initialized",
@@ -182,11 +180,8 @@ export function unboostController(mountPath, plugin) {
}
const { Announce, Undo } = await import("@fedify/fedify/vocab");
const handle = plugin.options.actor.handle;
const ctx = plugin._federation.createContext(
new URL(plugin._publicationUrl),
{ handle, publicationUrl: plugin._publicationUrl },
);
const handle = getHandle(plugin);
const ctx = createContext(plugin);
// Construct Undo(Announce)
const announce = new Announce({
+8 -13
View File
@@ -5,6 +5,7 @@
import { validateToken } from "../csrf.js";
import { resolveAuthor } from "../resolve-author.js";
import { createContext, getHandle, getPublicationUrl, isFederationReady } from "../federation-actions.js";
/**
* POST /admin/reader/like — send a Like activity to the post author.
@@ -30,7 +31,7 @@ export function likeController(mountPath, plugin) {
});
}
if (!plugin._federation) {
if (!isFederationReady(plugin)) {
return response.status(503).json({
success: false,
error: "Federation not initialized",
@@ -38,11 +39,8 @@ export function likeController(mountPath, plugin) {
}
const { Like } = await import("@fedify/fedify/vocab");
const handle = plugin.options.actor.handle;
const ctx = plugin._federation.createContext(
new URL(plugin._publicationUrl),
{ handle, publicationUrl: plugin._publicationUrl },
);
const handle = getHandle(plugin);
const ctx = createContext(plugin);
const documentLoader = await ctx.getDocumentLoader({
identifier: handle,
@@ -70,7 +68,7 @@ export function likeController(mountPath, plugin) {
// Generate a unique activity ID
const uuid = crypto.randomUUID();
const baseUrl = plugin._publicationUrl.replace(/\/$/, "");
const baseUrl = getPublicationUrl(plugin).replace(/\/$/, "");
const activityId = `${baseUrl}/activitypub/likes/${uuid}`;
// Construct and send Like activity
@@ -142,7 +140,7 @@ export function unlikeController(mountPath, plugin) {
});
}
if (!plugin._federation) {
if (!isFederationReady(plugin)) {
return response.status(503).json({
success: false,
error: "Federation not initialized",
@@ -165,11 +163,8 @@ export function unlikeController(mountPath, plugin) {
}
const { Like, Undo } = await import("@fedify/fedify/vocab");
const handle = plugin.options.actor.handle;
const ctx = plugin._federation.createContext(
new URL(plugin._publicationUrl),
{ handle, publicationUrl: plugin._publicationUrl },
);
const handle = getHandle(plugin);
const ctx = createContext(plugin);
const documentLoader = await ctx.getDocumentLoader({
identifier: handle,
+70
View File
@@ -0,0 +1,70 @@
/**
* Facade for federation operations used by controllers.
* Centralizes Fedify context creation and common patterns
* so controllers don't access plugin._federation directly.
* @module federation-actions
*/
import { lookupWithSecurity } from "./lookup-helpers.js";
/**
* Create a Fedify context from the plugin reference.
* @param {object} plugin - ActivityPubEndpoint instance
* @returns {object} Fedify Context
*/
export function createContext(plugin) {
const handle = plugin.options.actor.handle;
return plugin._federation.createContext(new URL(plugin._publicationUrl), {
handle,
publicationUrl: plugin._publicationUrl,
});
}
/**
* Get an authenticated document loader for signed HTTP fetches.
* @param {object} plugin - ActivityPubEndpoint instance
* @returns {Promise<object>} Fedify DocumentLoader
*/
export async function getAuthLoader(plugin) {
const ctx = createContext(plugin);
return ctx.getDocumentLoader({ identifier: plugin.options.actor.handle });
}
/**
* Resolve a remote actor with signed→unsigned fallback.
* @param {object} plugin - ActivityPubEndpoint instance
* @param {string|URL} target - Actor URL or acct: URI
* @param {object} [options] - Additional options for lookupWithSecurity
* @returns {Promise<object|null>} Resolved actor or null
*/
export async function resolveActor(plugin, target, options = {}) {
const ctx = createContext(plugin);
const documentLoader = await ctx.getDocumentLoader({
identifier: plugin.options.actor.handle,
});
const url = target instanceof URL ? target : new URL(target);
return lookupWithSecurity(ctx, url, { documentLoader, ...options });
}
/**
* Check if federation is initialized and ready.
* @param {object} plugin - ActivityPubEndpoint instance
* @returns {boolean}
*/
export function isFederationReady(plugin) {
return !!plugin._federation;
}
/** @returns {string} Our actor handle */
export function getHandle(plugin) {
return plugin.options.actor.handle;
}
/** @returns {string} Our publication URL */
export function getPublicationUrl(plugin) {
return plugin._publicationUrl;
}
/** @returns {object} MongoDB collections */
export function getCollections(plugin) {
return plugin._collections;
}
-6
View File
@@ -92,12 +92,6 @@ async function sendFedifyResponse(res, response, request) {
if (json.attachment && !Array.isArray(json.attachment)) {
json.attachment = [json.attachment];
}
// WORKAROUND: Fedify serializes endpoints with "type": "as:Endpoints"
// which is not a valid AS2 type. The endpoints object should be a plain
// object with just sharedInbox/proxyUrl etc. Strip the invalid type.
if (json.endpoints && json.endpoints.type) {
delete json.endpoints.type;
}
const patched = JSON.stringify(json);
res.setHeader("content-length", Buffer.byteLength(patched));
res.end(patched);
+94 -1
View File
@@ -303,10 +303,48 @@ export function setupFederation(options) {
// Add OStatus subscribe template so remote servers (WordPress AP, Misskey, etc.)
// can redirect users to our authorize_interaction page for remote follow.
federation.setWebFingerLinksDispatcher((_ctx, _resource) => {
const interactionBase = `${publicationUrl}${mountPath.replace(/^\//, "")}/authorize_interaction`;
return [
// OStatus subscribe template (legacy remote follow)
{
rel: "http://ostatus.org/schema/1.0/subscribe",
template: `${publicationUrl}${mountPath.replace(/^\//, "")}/authorize_interaction?uri={uri}`,
template: `${interactionBase}?uri={uri}`,
},
// FEP-3b86 Activity Intents — Follow
{
rel: "https://w3id.org/fep/3b86",
template: `${interactionBase}?uri={uri}&intent=follow`,
properties: {
"https://w3id.org/fep/3b86#intent":
"https://www.w3.org/ns/activitystreams#Follow",
},
},
// FEP-3b86 Activity Intents — Create (reply)
{
rel: "https://w3id.org/fep/3b86",
template: `${interactionBase}?uri={uri}&intent=create`,
properties: {
"https://w3id.org/fep/3b86#intent":
"https://www.w3.org/ns/activitystreams#Create",
},
},
// FEP-3b86 Activity Intents — Like
{
rel: "https://w3id.org/fep/3b86",
template: `${interactionBase}?uri={uri}&intent=like`,
properties: {
"https://w3id.org/fep/3b86#intent":
"https://www.w3.org/ns/activitystreams#Like",
},
},
// FEP-3b86 Activity Intents — Announce (boost)
{
rel: "https://w3id.org/fep/3b86",
template: `${interactionBase}?uri={uri}&intent=announce`,
properties: {
"https://w3id.org/fep/3b86#intent":
"https://www.w3.org/ns/activitystreams#Announce",
},
},
];
});
@@ -322,6 +360,43 @@ export function setupFederation(options) {
storeRawActivities,
});
// Handle Delete activities from actors whose signing keys are gone.
// When an account is deleted, the remote server sends Delete but the
// actor's key endpoint returns 404/410, so signature verification fails.
// Fedify 2.1.0 lets us inspect these instead of auto-rejecting.
inboxChain
.onUnverifiedActivity(async (_ctx, activity, reason) => {
// Handle Delete activities from actors whose signing keys are gone.
// When an account is deleted, the remote server sends Delete but the
// actor's key endpoint returns 404/410, so signature verification fails.
// Fedify 2.1.0 lets us inspect these instead of auto-rejecting.
if (reason.type === "keyFetchError") {
const status = reason.result?.status;
if (status === 404 || status === 410) {
const actorId = activity.actorId?.href;
if (actorId) {
const activityType = activity.constructor?.name || "";
if (activityType === "Delete") {
console.info(
`[ActivityPub] Processing unverified Delete from ${actorId} (key ${status})`,
);
try {
await collections.ap_followers.deleteOne({ actorUrl: actorId });
await collections.ap_timeline.deleteMany({ "author.url": actorId });
await collections.ap_notifications.deleteMany({ actorUrl: actorId });
console.info(`[ActivityPub] Cleaned up data for deleted actor ${actorId}`);
} catch (error) {
console.warn(`[ActivityPub] Cleanup for ${actorId} failed: ${error.message}`);
}
return new Response(null, { status: 202 });
}
}
}
}
// All other unverified activities: return null for default 401
return null;
});
// Enable authenticated fetches for the shared inbox.
// Without this, Fedify can't verify incoming HTTP Signatures from servers
// that require authorized fetch (e.g. hachyderm.io returns 401 on unsigned GETs).
@@ -354,17 +429,33 @@ export function setupFederation(options) {
? await collections.posts.countDocuments()
: 0;
const profile = await getProfile(collections);
return {
software: {
name: "indiekit",
version: softwareVersion,
repository: new URL("https://github.com/getindiekit/indiekit"),
homepage: new URL("https://getindiekit.com"),
},
protocols: ["activitypub"],
services: {
inbound: [],
outbound: [],
},
openRegistrations: false,
usage: {
users: { total: 1, activeMonth: 1, activeHalfyear: 1 },
localPosts: postsCount,
localComments: 0,
},
metadata: {
nodeName: profile.name || handle,
nodeDescription: profile.summary || "",
staffAccounts: [
`${publicationUrl}${mountPath.replace(/^\//, "")}/users/${handle}`,
],
},
};
});
@@ -796,6 +887,8 @@ export async function buildPersonActor(
featuredTags: ctx.getFeaturedTagsUri(identifier),
endpoints: new Endpoints({ sharedInbox: ctx.getInboxUri() }),
manuallyApprovesFollowers: profile.manuallyApprovesFollowers || false,
indexable: true,
discoverable: true,
};
if (profile.summary) {
+16 -10
View File
@@ -17,21 +17,20 @@ import { routeToHandler } from "./inbox-handlers.js";
*/
async function processNextItem(collections, ctx, handle) {
const { ap_inbox_queue } = collections;
if (!ap_inbox_queue) return;
if (!ap_inbox_queue) return false;
const item = await ap_inbox_queue.findOneAndUpdate(
{ status: "pending" },
{ $set: { status: "processing" } },
{ sort: { receivedAt: 1 }, returnDocument: "after" },
);
if (!item) return;
if (!item) return false;
try {
await routeToHandler(item, collections, ctx, handle);
await ap_inbox_queue.updateOne(
{ _id: item._id },
{ $set: { status: "completed", processedAt: new Date().toISOString() } },
);
// Delete completed items immediately — prevents unbounded collection growth
// that caused the inbox processor to hang on restart (95K+ documents).
await ap_inbox_queue.deleteOne({ _id: item._id });
} catch (error) {
const attempts = (item.attempts || 0) + 1;
await ap_inbox_queue.updateOne(
@@ -46,6 +45,8 @@ async function processNextItem(collections, ctx, handle) {
);
console.error(`[inbox-queue] Failed processing ${item.activityType} from ${item.actorUrl}: ${error.message}`);
}
return true;
}
/**
@@ -75,6 +76,9 @@ export async function enqueueActivity(collections, { activityType, actorUrl, obj
});
}
const BATCH_SIZE = 10;
const POLL_INTERVAL_MS = 1_000;
/**
* Start the background inbox processor.
* @param {object} collections - MongoDB collections
@@ -86,14 +90,16 @@ export function startInboxProcessor(collections, getCtx, handle) {
const intervalId = setInterval(async () => {
try {
const ctx = getCtx();
if (ctx) {
await processNextItem(collections, ctx, handle);
if (!ctx) return;
for (let i = 0; i < BATCH_SIZE; i++) {
const hadWork = await processNextItem(collections, ctx, handle);
if (!hadWork) break; // Queue empty, stop early
}
} catch (error) {
console.error("[inbox-queue] Processor error:", error.message);
}
}, 3_000); // Every 3 seconds
}, POLL_INTERVAL_MS);
console.info("[ActivityPub] Inbox queue processor started (3s interval)");
console.info(`[ActivityPub] Inbox queue processor started (${POLL_INTERVAL_MS}ms interval, batch size ${BATCH_SIZE})`);
return intervalId;
}
+257
View File
@@ -0,0 +1,257 @@
/**
* Create MongoDB indexes for all ActivityPub collections.
* Idempotent — safe to run on every startup.
* @module init-indexes
*
* @param {object} collections - MongoDB collections object
* @param {object} options
* @param {number} options.activityRetentionDays - TTL for ap_activities (0 = forever)
* @param {number} options.notificationRetentionDays - TTL for notifications (0 = forever)
*/
export function createIndexes(collections, options) {
const { activityRetentionDays, notificationRetentionDays } = options;
// Create indexes — wrapped in try-catch because collection references
// may be undefined if MongoDB hasn't finished connecting yet.
// Indexes are idempotent; they'll be created on next successful startup.
try {
// TTL index for activity cleanup (MongoDB handles expiry automatically)
const retentionDays = activityRetentionDays;
if (retentionDays > 0) {
collections.ap_activities.createIndex(
{ receivedAt: 1 },
{ expireAfterSeconds: retentionDays * 86_400 },
);
}
// Performance indexes for inbox handlers and batch refollow
collections.ap_followers.createIndex(
{ actorUrl: 1 },
{ unique: true, background: true },
);
collections.ap_following.createIndex(
{ actorUrl: 1 },
{ unique: true, background: true },
);
collections.ap_following.createIndex(
{ source: 1 },
{ background: true },
);
collections.ap_activities.createIndex(
{ objectUrl: 1 },
{ background: true },
);
collections.ap_activities.createIndex(
{ type: 1, actorUrl: 1, objectUrl: 1 },
{ background: true },
);
// Reader indexes (timeline, notifications, moderation, interactions)
collections.ap_timeline.createIndex(
{ uid: 1 },
{ unique: true, background: true },
);
collections.ap_timeline.createIndex(
{ published: -1 },
{ background: true },
);
collections.ap_timeline.createIndex(
{ "author.url": 1 },
{ background: true },
);
collections.ap_timeline.createIndex(
{ type: 1, published: -1 },
{ background: true },
);
collections.ap_notifications.createIndex(
{ uid: 1 },
{ unique: true, background: true },
);
collections.ap_notifications.createIndex(
{ published: -1 },
{ background: true },
);
collections.ap_notifications.createIndex(
{ read: 1 },
{ background: true },
);
collections.ap_notifications.createIndex(
{ type: 1, published: -1 },
{ background: true },
);
// TTL index for notification cleanup
const notifRetention = notificationRetentionDays;
if (notifRetention > 0) {
collections.ap_notifications.createIndex(
{ createdAt: 1 },
{ expireAfterSeconds: notifRetention * 86_400 },
);
}
// Message indexes
collections.ap_messages.createIndex(
{ uid: 1 },
{ unique: true, background: true },
);
collections.ap_messages.createIndex(
{ published: -1 },
{ background: true },
);
collections.ap_messages.createIndex(
{ read: 1 },
{ background: true },
);
collections.ap_messages.createIndex(
{ conversationId: 1, published: -1 },
{ background: true },
);
collections.ap_messages.createIndex(
{ direction: 1 },
{ background: true },
);
// TTL index for message cleanup (reuse notification retention)
if (notifRetention > 0) {
collections.ap_messages.createIndex(
{ createdAt: 1 },
{ expireAfterSeconds: notifRetention * 86_400 },
);
}
// Muted collection — sparse unique indexes (allow multiple null values)
collections.ap_muted
.dropIndex("url_1")
.catch(() => {})
.then(() =>
collections.ap_muted.createIndex(
{ url: 1 },
{ unique: true, sparse: true, background: true },
),
)
.catch(() => {});
collections.ap_muted
.dropIndex("keyword_1")
.catch(() => {})
.then(() =>
collections.ap_muted.createIndex(
{ keyword: 1 },
{ unique: true, sparse: true, background: true },
),
)
.catch(() => {});
collections.ap_blocked.createIndex(
{ url: 1 },
{ unique: true, background: true },
);
collections.ap_interactions.createIndex(
{ objectUrl: 1, type: 1 },
{ unique: true, background: true },
);
collections.ap_interactions.createIndex(
{ type: 1 },
{ background: true },
);
// Followed hashtags — unique on tag (case-insensitive via normalization at write time)
collections.ap_followed_tags.createIndex(
{ tag: 1 },
{ unique: true, background: true },
);
// Tag filtering index on timeline
collections.ap_timeline.createIndex(
{ category: 1, published: -1 },
{ background: true },
);
// Explore tab indexes
// Compound unique on (type, domain, scope, hashtag) prevents duplicate tabs.
// ALL insertions must explicitly set all four fields (unused fields = null)
// because MongoDB treats missing fields differently from null in unique indexes.
collections.ap_explore_tabs.createIndex(
{ type: 1, domain: 1, scope: 1, hashtag: 1 },
{ unique: true, background: true },
);
// Order index for efficient sorting of tab bar
collections.ap_explore_tabs.createIndex(
{ order: 1 },
{ background: true },
);
// ap_reports indexes
if (notifRetention > 0) {
collections.ap_reports.createIndex(
{ createdAt: 1 },
{ expireAfterSeconds: notifRetention * 86_400 },
);
}
collections.ap_reports.createIndex(
{ reporterUrl: 1 },
{ background: true },
);
collections.ap_reports.createIndex(
{ reportedUrls: 1 },
{ background: true },
);
// Pending follow requests — unique on actorUrl
collections.ap_pending_follows.createIndex(
{ actorUrl: 1 },
{ unique: true, background: true },
);
collections.ap_pending_follows.createIndex(
{ requestedAt: -1 },
{ background: true },
);
// Server-level blocks
collections.ap_blocked_servers.createIndex(
{ hostname: 1 },
{ unique: true, background: true },
);
// Key freshness tracking
collections.ap_key_freshness.createIndex(
{ actorUrl: 1 },
{ unique: true, background: true },
);
// Inbox queue indexes
collections.ap_inbox_queue.createIndex(
{ status: 1, receivedAt: 1 },
{ background: true },
);
// TTL: auto-prune completed items after 24h
collections.ap_inbox_queue.createIndex(
{ processedAt: 1 },
{ expireAfterSeconds: 86_400, background: true },
);
// Mastodon Client API indexes
collections.ap_oauth_apps.createIndex(
{ clientId: 1 },
{ unique: true, background: true },
);
collections.ap_oauth_tokens.createIndex(
{ accessToken: 1 },
{ unique: true, sparse: true, background: true },
);
collections.ap_oauth_tokens.createIndex(
{ code: 1 },
{ unique: true, sparse: true, background: true },
);
collections.ap_markers.createIndex(
{ userId: 1, timeline: 1 },
{ unique: true, background: true },
);
// Tombstone indexes (FEP-4f05)
collections.ap_tombstones?.createIndex(
{ url: 1 },
{ unique: true, background: true },
);
} catch {
// Index creation failed — collections not yet available.
// Indexes already exist from previous startups; non-fatal.
}
}
+22 -2
View File
@@ -268,14 +268,32 @@ export async function renderItemCards(items, request, templateData) {
return htmlParts.join("");
}
// ─── Moderation data cache ──────────────────────────────────────────────────
let _moderationCache = null;
let _moderationCacheAt = 0;
const MODERATION_CACHE_TTL = 30_000; // 30 seconds
/**
* Invalidate the moderation data cache.
* Call this from any write operation that changes muted/blocked data.
*/
export function invalidateModerationCache() {
_moderationCache = null;
_moderationCacheAt = 0;
}
/**
* Load moderation data from MongoDB collections.
* Convenience wrapper to reduce boilerplate in controllers.
* Results are cached in memory for 30 seconds to avoid redundant queries.
*
* @param {object} modCollections - { ap_muted, ap_blocked, ap_profile }
* @returns {Promise<object>} moderation data for postProcessItems()
*/
export async function loadModerationData(modCollections) {
if (_moderationCache && Date.now() - _moderationCacheAt < MODERATION_CACHE_TTL) {
return _moderationCache;
}
// Dynamic import to avoid circular dependency
const { getMutedUrls, getMutedKeywords, getBlockedUrls, getFilterMode } =
await import("./storage/moderation.js");
@@ -287,5 +305,7 @@ export async function loadModerationData(modCollections) {
getFilterMode(modCollections),
]);
return { mutedUrls, mutedKeywords, blockedUrls, filterMode };
_moderationCache = { mutedUrls, mutedKeywords, blockedUrls, filterMode };
_moderationCacheAt = Date.now();
return _moderationCache;
}
+3
View File
@@ -20,6 +20,9 @@ export function getCached(url) {
lookupCache.delete(url);
return null;
}
// Promote to end of Map (true LRU)
lookupCache.delete(url);
lookupCache.set(url, entry);
return entry.data;
}
+11 -2
View File
@@ -43,6 +43,16 @@ export async function backfillTimeline(collections) {
let inserted = 0;
let skipped = 0;
// Batch-fetch existing UIDs to avoid N+1 per-post queries
const allUids = allPosts
.map((p) => p.properties?.url)
.filter(Boolean);
const existingDocs = await ap_timeline
.find({ uid: { $in: allUids } })
.project({ uid: 1 })
.toArray();
const existingUids = new Set(existingDocs.map((d) => d.uid));
for (const post of allPosts) {
const props = post.properties;
if (!props?.url) {
@@ -53,8 +63,7 @@ export async function backfillTimeline(collections) {
const uid = props.url;
// Check if already in timeline (fast path to avoid unnecessary upserts)
const exists = await ap_timeline.findOne({ uid }, { projection: { _id: 1 } });
if (exists) {
if (existingUids.has(uid)) {
skipped++;
continue;
}
+19 -88
View File
@@ -1,105 +1,33 @@
/**
* XSS HTML sanitizer for Mastodon Client API responses.
* HTML sanitizer for Mastodon Client API responses.
*
* Strips dangerous HTML while preserving safe markup that
* Mastodon clients expect (links, paragraphs, line breaks,
* inline formatting, mentions, hashtags).
* Uses the sanitize-html library for robust XSS prevention.
* Preserves safe markup that Mastodon clients expect (links,
* paragraphs, line breaks, inline formatting, mentions, hashtags).
*/
/**
* Allowed HTML tags in Mastodon API content fields.
* Matches what Mastodon itself permits in status content.
*/
const ALLOWED_TAGS = new Set([
"a",
"br",
"p",
"span",
"strong",
"em",
"b",
"i",
"u",
"s",
"del",
"pre",
"code",
"blockquote",
"ul",
"ol",
"li",
]);
/**
* Allowed attributes per tag.
*/
const ALLOWED_ATTRS = {
a: new Set(["href", "rel", "class", "target"]),
span: new Set(["class"]),
};
import sanitizeHtmlLib from "sanitize-html";
/**
* Sanitize HTML content for safe inclusion in API responses.
*
* Strips all tags not in the allowlist and removes disallowed attributes.
* This is a lightweight sanitizer — for production, consider a
* battle-tested library like DOMPurify or sanitize-html.
*
* @param {string} html - Raw HTML string
* @returns {string} Sanitized HTML
*/
export function sanitizeHtml(html) {
if (!html || typeof html !== "string") return "";
return html.replace(/<\/?([a-z][a-z0-9]*)\b[^>]*>/gi, (match, tagName) => {
const tag = tagName.toLowerCase();
// Closing tag
if (match.startsWith("</")) {
return ALLOWED_TAGS.has(tag) ? `</${tag}>` : "";
}
// Opening tag — check if allowed
if (!ALLOWED_TAGS.has(tag)) return "";
// Self-closing br
if (tag === "br") return "<br>";
// Strip disallowed attributes
const allowedAttrs = ALLOWED_ATTRS[tag];
if (!allowedAttrs) return `<${tag}>`;
const attrs = [];
const attrRegex = /([a-z][a-z0-9-]*)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|(\S+)))?/gi;
let attrMatch;
while ((attrMatch = attrRegex.exec(match)) !== null) {
const attrName = attrMatch[1].toLowerCase();
if (attrName === tag) continue; // skip tag name itself
const attrValue = attrMatch[2] ?? attrMatch[3] ?? attrMatch[4] ?? "";
if (allowedAttrs.has(attrName)) {
// Block javascript: URIs in href
if (attrName === "href" && /^\s*javascript:/i.test(attrValue)) continue;
attrs.push(`${attrName}="${escapeAttr(attrValue)}"`);
}
}
return attrs.length > 0 ? `<${tag} ${attrs.join(" ")}>` : `<${tag}>`;
return sanitizeHtmlLib(html, {
allowedTags: [
"a", "br", "p", "span", "strong", "em", "b", "i", "u", "s",
"del", "pre", "code", "blockquote", "ul", "ol", "li",
],
allowedAttributes: {
a: ["href", "rel", "class", "target"],
span: ["class"],
},
allowedSchemes: ["http", "https", "mailto"],
});
}
/**
* Escape HTML attribute value.
* @param {string} value
* @returns {string}
*/
function escapeAttr(value) {
return value
.replace(/&/g, "&amp;")
.replace(/"/g, "&quot;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;");
}
/**
* Strip all HTML tags, returning plain text.
* @param {string} html
@@ -107,5 +35,8 @@ function escapeAttr(value) {
*/
export function stripHtml(html) {
if (!html || typeof html !== "string") return "";
return html.replace(/<[^>]*>/g, "").trim();
return sanitizeHtmlLib(html, {
allowedTags: [],
allowedAttributes: {},
}).trim();
}
+3
View File
@@ -56,6 +56,9 @@ export function getCachedAccountStats(actorUrl) {
return null;
}
// Promote to end of Map (true LRU)
cache.delete(actorUrl);
cache.set(actorUrl, entry);
return entry;
}
+41 -54
View File
@@ -2,19 +2,17 @@
* Enrich embedded account objects in serialized statuses with real
* follower/following/post counts from remote AP collections.
*
* Phanpy (and some other clients) never call /accounts/:id — they
* trust the account object embedded in each status. Without enrichment,
* these show 0/0/0 for all remote accounts.
*
* Uses the account stats cache to avoid redundant fetches. Only resolves
* unique authors with 0 counts that aren't already cached.
* Applies cached stats immediately. Uncached accounts are resolved
* in the background (fire-and-forget) and will be populated for
* subsequent requests.
*/
import { getCachedAccountStats } from "./account-cache.js";
import { resolveRemoteAccount } from "./resolve-account.js";
/**
* Enrich account objects in a list of serialized statuses.
* Resolves unique authors in parallel (max 5 concurrent).
* Applies cached stats synchronously. Uncached accounts are resolved
* in the background for future requests.
*
* @param {Array} statuses - Serialized Mastodon Status objects (mutated in place)
* @param {object} pluginOptions - Plugin options with federation context
@@ -23,65 +21,35 @@ import { resolveRemoteAccount } from "./resolve-account.js";
export async function enrichAccountStats(statuses, pluginOptions, baseUrl) {
if (!statuses?.length || !pluginOptions?.federation) return;
// Collect unique author URLs that need enrichment
const accountsToEnrich = new Map(); // url -> [account references]
const uncachedUrls = [];
for (const status of statuses) {
collectAccount(status.account, accountsToEnrich);
applyCachedOrCollect(status.account, uncachedUrls);
if (status.reblog?.account) {
collectAccount(status.reblog.account, accountsToEnrich);
applyCachedOrCollect(status.reblog.account, uncachedUrls);
}
}
if (accountsToEnrich.size === 0) return;
// Resolve in parallel with concurrency limit
const entries = [...accountsToEnrich.entries()];
const CONCURRENCY = 5;
for (let i = 0; i < entries.length; i += CONCURRENCY) {
const batch = entries.slice(i, i + CONCURRENCY);
await Promise.all(
batch.map(async ([url, accounts]) => {
try {
const resolved = await resolveRemoteAccount(url, pluginOptions, baseUrl);
if (resolved) {
for (const account of accounts) {
account.followers_count = resolved.followers_count;
account.following_count = resolved.following_count;
account.statuses_count = resolved.statuses_count;
if (resolved.created_at && account.created_at) {
account.created_at = resolved.created_at;
}
if (resolved.note) account.note = resolved.note;
if (resolved.fields?.length) account.fields = resolved.fields;
if (resolved.avatar && resolved.avatar !== account.avatar) {
account.avatar = resolved.avatar;
account.avatar_static = resolved.avatar;
}
if (resolved.header) {
account.header = resolved.header;
account.header_static = resolved.header;
}
}
}
} catch {
// Silently skip failed resolutions
}
}),
);
// Fire-and-forget background enrichment for uncached accounts.
// Next request will pick up the cached results.
if (uncachedUrls.length > 0) {
resolveInBackground(uncachedUrls, pluginOptions, baseUrl);
}
}
/**
* Collect an account reference for enrichment if it has 0 counts
* and isn't already cached.
* Apply cached stats to an account, or collect its URL for background resolution.
* @param {object} account - Account object to enrich
* @param {string[]} uncachedUrls - Array to collect uncached URLs into
*/
function collectAccount(account, map) {
function applyCachedOrCollect(account, uncachedUrls) {
if (!account?.url) return;
// Always check cache first — applies avatar + createdAt even for already-enriched accounts.
// avatarUrl is stored in the cache by resolveRemoteAccount so it survives across requests
// even when the timeline item's author.photo is empty (e.g. actor was on a Secure Mode
// server when the item was originally received).
const cached = getCachedAccountStats(account.url);
if (cached) {
account.followers_count = cached.followersCount || account.followers_count || 0;
@@ -98,9 +66,28 @@ function collectAccount(account, map) {
// Skip remote resolution if counts are already populated from some other source
if (account.followers_count > 0 || account.statuses_count > 0) return;
// Queue for remote resolution
if (!map.has(account.url)) {
map.set(account.url, []);
if (!uncachedUrls.includes(account.url)) {
uncachedUrls.push(account.url);
}
map.get(account.url).push(account);
}
/**
* Resolve accounts in background. Fire-and-forget — errors are silently ignored.
* resolveRemoteAccount() populates the account cache as a side effect.
* @param {string[]} urls - Actor URLs to resolve
* @param {object} pluginOptions - Plugin options
* @param {string} baseUrl - Server base URL
*/
function resolveInBackground(urls, pluginOptions, baseUrl) {
const unique = [...new Set(urls)];
const CONCURRENCY = 5;
(async () => {
for (let i = 0; i < unique.length; i += CONCURRENCY) {
const batch = unique.slice(i, i + CONCURRENCY);
await Promise.allSettled(
batch.map((url) => resolveRemoteAccount(url, pluginOptions, baseUrl)),
);
}
})().catch(() => {});
}
+31
View File
@@ -6,6 +6,7 @@
* /api/v1/*, /api/v2/*, /oauth/* at the domain root.
*/
import express from "express";
import rateLimit from "express-rate-limit";
import { corsMiddleware } from "./middleware/cors.js";
import { tokenRequired, optionalToken } from "./middleware/token-required.js";
import { errorHandler, notImplementedHandler } from "./middleware/error-handler.js";
@@ -21,6 +22,31 @@ import searchRouter from "./routes/search.js";
import mediaRouter from "./routes/media.js";
import stubsRouter from "./routes/stubs.js";
// Rate limiters for different endpoint categories
const apiLimiter = rateLimit({
windowMs: 5 * 60 * 1000, // 5 minutes
max: 300,
standardHeaders: true,
legacyHeaders: false,
message: { error: "Too many requests, please try again later" },
});
const authLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 30,
standardHeaders: true,
legacyHeaders: false,
message: { error: "Too many authentication attempts" },
});
const appRegistrationLimiter = rateLimit({
windowMs: 60 * 60 * 1000, // 1 hour
max: 25,
standardHeaders: true,
legacyHeaders: false,
message: { error: "Too many app registrations" },
});
/**
* Create the combined Mastodon API router.
*
@@ -46,6 +72,11 @@ export function createMastodonRouter({ collections, pluginOptions = {} }) {
router.use("/oauth/revoke", corsMiddleware);
router.use("/.well-known/oauth-authorization-server", corsMiddleware);
// ─── Rate limiting ─────────────────────────────────────────────────────
router.use("/api", apiLimiter);
router.use("/oauth/token", authLimiter);
router.use("/api/v1/apps", appRegistrationLimiter);
// ─── Inject collections + plugin options into req ───────────────────────
router.use("/api", (req, res, next) => {
req.app.locals.mastodonCollections = collections;
+14 -49
View File
@@ -16,13 +16,8 @@ const router = express.Router(); // eslint-disable-line new-cap
// ─── GET /api/v1/accounts/verify_credentials ─────────────────────────────────
router.get("/api/v1/accounts/verify_credentials", async (req, res, next) => {
router.get("/api/v1/accounts/verify_credentials", tokenRequired, scopeRequired("read", "read:accounts"), async (req, res, next) => {
try {
const token = req.mastodonToken;
if (!token) {
return res.status(401).json({ error: "The access token is invalid" });
}
const baseUrl = `${req.protocol}://${req.get("host")}`;
const collections = req.app.locals.mastodonCollections;
const pluginOptions = req.app.locals.mastodonPluginOptions || {};
@@ -62,7 +57,7 @@ router.get("/api/v1/accounts/verify_credentials", async (req, res, next) => {
// ─── GET /api/v1/preferences ─────────────────────────────────────────────────
router.get("/api/v1/preferences", (req, res) => {
router.get("/api/v1/preferences", tokenRequired, scopeRequired("read", "read:accounts"), (req, res) => {
res.json({
"posting:default:visibility": "public",
"posting:default:sensitive": false,
@@ -159,7 +154,7 @@ router.get("/api/v1/accounts/lookup", async (req, res, next) => {
// ─── GET /api/v1/accounts/relationships ──────────────────────────────────────
// MUST be before /accounts/:id to prevent Express matching "relationships" as :id
router.get("/api/v1/accounts/relationships", async (req, res, next) => {
router.get("/api/v1/accounts/relationships", tokenRequired, scopeRequired("read", "read:follows"), async (req, res, next) => {
try {
let ids = req.query["id[]"] || req.query.id || [];
if (!Array.isArray(ids)) ids = [ids];
@@ -225,7 +220,7 @@ router.get("/api/v1/accounts/relationships", async (req, res, next) => {
// ─── GET /api/v1/accounts/familiar_followers ─────────────────────────────────
// MUST be before /accounts/:id
router.get("/api/v1/accounts/familiar_followers", (req, res) => {
router.get("/api/v1/accounts/familiar_followers", tokenRequired, scopeRequired("read", "read:follows"), (req, res) => {
let ids = req.query["id[]"] || req.query.id || [];
if (!Array.isArray(ids)) ids = [ids];
res.json(ids.map((id) => ({ id, accounts: [] })));
@@ -233,7 +228,7 @@ router.get("/api/v1/accounts/familiar_followers", (req, res) => {
// ─── GET /api/v1/accounts/:id ────────────────────────────────────────────────
router.get("/api/v1/accounts/:id", async (req, res, next) => {
router.get("/api/v1/accounts/:id", tokenRequired, scopeRequired("read", "read:accounts"), async (req, res, next) => {
try {
const { id } = req.params;
const baseUrl = `${req.protocol}://${req.get("host")}`;
@@ -285,7 +280,7 @@ router.get("/api/v1/accounts/:id", async (req, res, next) => {
// ─── GET /api/v1/accounts/:id/statuses ──────────────────────────────────────
router.get("/api/v1/accounts/:id/statuses", async (req, res, next) => {
router.get("/api/v1/accounts/:id/statuses", tokenRequired, scopeRequired("read", "read:statuses"), async (req, res, next) => {
try {
const { id } = req.params;
const collections = req.app.locals.mastodonCollections;
@@ -376,7 +371,7 @@ router.get("/api/v1/accounts/:id/statuses", async (req, res, next) => {
// ─── GET /api/v1/accounts/:id/followers ─────────────────────────────────────
router.get("/api/v1/accounts/:id/followers", async (req, res, next) => {
router.get("/api/v1/accounts/:id/followers", tokenRequired, scopeRequired("read", "read:follows"), async (req, res, next) => {
try {
const { id } = req.params;
const collections = req.app.locals.mastodonCollections;
@@ -409,7 +404,7 @@ router.get("/api/v1/accounts/:id/followers", async (req, res, next) => {
// ─── GET /api/v1/accounts/:id/following ─────────────────────────────────────
router.get("/api/v1/accounts/:id/following", async (req, res, next) => {
router.get("/api/v1/accounts/:id/following", tokenRequired, scopeRequired("read", "read:follows"), async (req, res, next) => {
try {
const { id } = req.params;
const collections = req.app.locals.mastodonCollections;
@@ -442,13 +437,8 @@ router.get("/api/v1/accounts/:id/following", async (req, res, next) => {
// ─── POST /api/v1/accounts/:id/follow ───────────────────────────────────────
router.post("/api/v1/accounts/:id/follow", async (req, res, next) => {
router.post("/api/v1/accounts/:id/follow", tokenRequired, scopeRequired("write", "write:follows", "follow"), async (req, res, next) => {
try {
const token = req.mastodonToken;
if (!token) {
return res.status(401).json({ error: "The access token is invalid" });
}
const { id } = req.params;
const collections = req.app.locals.mastodonCollections;
const pluginOptions = req.app.locals.mastodonPluginOptions || {};
@@ -504,13 +494,8 @@ router.post("/api/v1/accounts/:id/follow", async (req, res, next) => {
// ─── POST /api/v1/accounts/:id/unfollow ─────────────────────────────────────
router.post("/api/v1/accounts/:id/unfollow", async (req, res, next) => {
router.post("/api/v1/accounts/:id/unfollow", tokenRequired, scopeRequired("write", "write:follows", "follow"), async (req, res, next) => {
try {
const token = req.mastodonToken;
if (!token) {
return res.status(401).json({ error: "The access token is invalid" });
}
const { id } = req.params;
const collections = req.app.locals.mastodonCollections;
const pluginOptions = req.app.locals.mastodonPluginOptions || {};
@@ -557,13 +542,8 @@ router.post("/api/v1/accounts/:id/unfollow", async (req, res, next) => {
// ─── POST /api/v1/accounts/:id/mute ────────────────────────────────────────
router.post("/api/v1/accounts/:id/mute", async (req, res, next) => {
router.post("/api/v1/accounts/:id/mute", tokenRequired, scopeRequired("write", "write:mutes", "follow"), async (req, res, next) => {
try {
const token = req.mastodonToken;
if (!token) {
return res.status(401).json({ error: "The access token is invalid" });
}
const { id } = req.params;
const collections = req.app.locals.mastodonCollections;
@@ -600,13 +580,8 @@ router.post("/api/v1/accounts/:id/mute", async (req, res, next) => {
// ─── POST /api/v1/accounts/:id/unmute ───────────────────────────────────────
router.post("/api/v1/accounts/:id/unmute", async (req, res, next) => {
router.post("/api/v1/accounts/:id/unmute", tokenRequired, scopeRequired("write", "write:mutes", "follow"), async (req, res, next) => {
try {
const token = req.mastodonToken;
if (!token) {
return res.status(401).json({ error: "The access token is invalid" });
}
const { id } = req.params;
const collections = req.app.locals.mastodonCollections;
@@ -639,13 +614,8 @@ router.post("/api/v1/accounts/:id/unmute", async (req, res, next) => {
// ─── POST /api/v1/accounts/:id/block ───────────────────────────────────────
router.post("/api/v1/accounts/:id/block", async (req, res, next) => {
router.post("/api/v1/accounts/:id/block", tokenRequired, scopeRequired("write", "write:blocks", "follow"), async (req, res, next) => {
try {
const token = req.mastodonToken;
if (!token) {
return res.status(401).json({ error: "The access token is invalid" });
}
const { id } = req.params;
const collections = req.app.locals.mastodonCollections;
@@ -682,13 +652,8 @@ router.post("/api/v1/accounts/:id/block", async (req, res, next) => {
// ─── POST /api/v1/accounts/:id/unblock ──────────────────────────────────────
router.post("/api/v1/accounts/:id/unblock", async (req, res, next) => {
router.post("/api/v1/accounts/:id/unblock", tokenRequired, scopeRequired("write", "write:blocks", "follow"), async (req, res, next) => {
try {
const token = req.mastodonToken;
if (!token) {
return res.status(401).json({ error: "The access token is invalid" });
}
const { id } = req.params;
const collections = req.app.locals.mastodonCollections;
+6 -4
View File
@@ -7,12 +7,14 @@
* PUT /api/v1/media/:id — update media metadata (description/focus)
*/
import express from "express";
import { tokenRequired } from "../middleware/token-required.js";
import { scopeRequired } from "../middleware/scope-required.js";
const router = express.Router(); // eslint-disable-line new-cap
// ─── POST /api/v2/media ─────────────────────────────────────────────────────
router.post("/api/v2/media", (req, res) => {
router.post("/api/v2/media", tokenRequired, scopeRequired("write", "write:media"), (req, res) => {
// Media upload requires multer/multipart handling + storage backend.
// For now, return 422 so clients show a user-friendly error.
res.status(422).json({
@@ -22,7 +24,7 @@ router.post("/api/v2/media", (req, res) => {
// ─── POST /api/v1/media (legacy) ────────────────────────────────────────────
router.post("/api/v1/media", (req, res) => {
router.post("/api/v1/media", tokenRequired, scopeRequired("write", "write:media"), (req, res) => {
res.status(422).json({
error: "Media uploads are not yet supported on this server",
});
@@ -30,13 +32,13 @@ router.post("/api/v1/media", (req, res) => {
// ─── GET /api/v1/media/:id ──────────────────────────────────────────────────
router.get("/api/v1/media/:id", (req, res) => {
router.get("/api/v1/media/:id", tokenRequired, scopeRequired("read", "read:statuses"), (req, res) => {
res.status(404).json({ error: "Record not found" });
});
// ─── PUT /api/v1/media/:id ──────────────────────────────────────────────────
router.put("/api/v1/media/:id", (req, res) => {
router.put("/api/v1/media/:id", tokenRequired, scopeRequired("write", "write:media"), (req, res) => {
res.status(404).json({ error: "Record not found" });
});
+6 -24
View File
@@ -10,6 +10,8 @@ import express from "express";
import { ObjectId } from "mongodb";
import { serializeNotification } from "../entities/notification.js";
import { buildPaginationQuery, parseLimit, setPaginationHeaders } from "../helpers/pagination.js";
import { tokenRequired } from "../middleware/token-required.js";
import { scopeRequired } from "../middleware/scope-required.js";
const router = express.Router(); // eslint-disable-line new-cap
@@ -29,13 +31,8 @@ const REVERSE_TYPE_MAP = {
// ─── GET /api/v1/notifications ──────────────────────────────────────────────
router.get("/api/v1/notifications", async (req, res, next) => {
router.get("/api/v1/notifications", tokenRequired, scopeRequired("read", "read:notifications"), async (req, res, next) => {
try {
const token = req.mastodonToken;
if (!token) {
return res.status(401).json({ error: "The access token is invalid" });
}
const collections = req.app.locals.mastodonCollections;
const baseUrl = `${req.protocol}://${req.get("host")}`;
const limit = parseLimit(req.query.limit);
@@ -105,13 +102,8 @@ router.get("/api/v1/notifications", async (req, res, next) => {
// ─── GET /api/v1/notifications/:id ──────────────────────────────────────────
router.get("/api/v1/notifications/:id", async (req, res, next) => {
router.get("/api/v1/notifications/:id", tokenRequired, scopeRequired("read", "read:notifications"), async (req, res, next) => {
try {
const token = req.mastodonToken;
if (!token) {
return res.status(401).json({ error: "The access token is invalid" });
}
const collections = req.app.locals.mastodonCollections;
const baseUrl = `${req.protocol}://${req.get("host")}`;
@@ -147,13 +139,8 @@ router.get("/api/v1/notifications/:id", async (req, res, next) => {
// ─── POST /api/v1/notifications/clear ───────────────────────────────────────
router.post("/api/v1/notifications/clear", async (req, res, next) => {
router.post("/api/v1/notifications/clear", tokenRequired, scopeRequired("write", "write:notifications"), async (req, res, next) => {
try {
const token = req.mastodonToken;
if (!token) {
return res.status(401).json({ error: "The access token is invalid" });
}
const collections = req.app.locals.mastodonCollections;
await collections.ap_notifications.deleteMany({});
res.json({});
@@ -164,13 +151,8 @@ router.post("/api/v1/notifications/clear", async (req, res, next) => {
// ─── POST /api/v1/notifications/:id/dismiss ─────────────────────────────────
router.post("/api/v1/notifications/:id/dismiss", async (req, res, next) => {
router.post("/api/v1/notifications/:id/dismiss", tokenRequired, scopeRequired("write", "write:notifications"), async (req, res, next) => {
try {
const token = req.mastodonToken;
if (!token) {
return res.status(401).json({ error: "The access token is invalid" });
}
const collections = req.app.locals.mastodonCollections;
let objectId;
+91 -18
View File
@@ -4,6 +4,7 @@
* Handles app registration, authorization, token exchange, and revocation.
*/
import crypto from "node:crypto";
import { getToken as getCsrfToken, validateToken as validateCsrf } from "../../csrf.js";
import express from "express";
const router = express.Router(); // eslint-disable-line new-cap
@@ -17,6 +18,29 @@ function randomHex(bytes) {
return crypto.randomBytes(bytes).toString("hex");
}
/**
* Escape HTML special characters to prevent XSS.
* @param {string} str - Untrusted string
* @returns {string} Safe HTML text
*/
function escapeHtml(str) {
return String(str ?? "")
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#x27;");
}
/**
* Hash a client secret for secure storage.
* @param {string} secret - Plaintext secret
* @returns {string} SHA-256 hex hash
*/
function hashSecret(secret) {
return crypto.createHash("sha256").update(secret).digest("hex");
}
/**
* Parse redirect_uris from request — accepts space-separated string or array.
* @param {string|string[]} value
@@ -57,7 +81,7 @@ router.post("/api/v1/apps", async (req, res, next) => {
const doc = {
clientId,
clientSecret,
clientSecretHash: hashSecret(clientSecret),
name: client_name || "",
redirectUris,
scopes: parsedScopes,
@@ -251,7 +275,7 @@ router.get("/oauth/authorize", async (req, res, next) => {
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Authorize ${appName}</title>
<title>Authorize ${escapeHtml(appName)}</title>
<style>
body { font-family: system-ui, sans-serif; max-width: 480px; margin: 2rem auto; padding: 0 1rem; }
h1 { font-size: 1.4rem; }
@@ -264,17 +288,18 @@ router.get("/oauth/authorize", async (req, res, next) => {
</style>
</head>
<body>
<h1>Authorize ${appName}</h1>
<p>${appName} wants to access your account with these permissions:</p>
<h1>Authorize ${escapeHtml(appName)}</h1>
<p>${escapeHtml(appName)} wants to access your account with these permissions:</p>
<div class="scopes">
${requestedScopes.map((s) => `<code>${s}</code>`).join("")}
${requestedScopes.map((s) => `<code>${escapeHtml(s)}</code>`).join("")}
</div>
<form method="POST" action="/oauth/authorize">
<input type="hidden" name="client_id" value="${client_id}">
<input type="hidden" name="redirect_uri" value="${resolvedRedirectUri}">
<input type="hidden" name="scope" value="${requestedScopes.join(" ")}">
<input type="hidden" name="code_challenge" value="${code_challenge || ""}">
<input type="hidden" name="code_challenge_method" value="${code_challenge_method || ""}">
<input type="hidden" name="_csrf" value="${escapeHtml(getCsrfToken(req.session))}">
<input type="hidden" name="client_id" value="${escapeHtml(client_id)}">
<input type="hidden" name="redirect_uri" value="${escapeHtml(resolvedRedirectUri)}">
<input type="hidden" name="scope" value="${escapeHtml(requestedScopes.join(" "))}">
<input type="hidden" name="code_challenge" value="${escapeHtml(code_challenge || "")}">
<input type="hidden" name="code_challenge_method" value="${escapeHtml(code_challenge_method || "")}">
<input type="hidden" name="response_type" value="code">
<div class="actions">
<button type="submit" name="decision" value="approve" class="approve">Authorize</button>
@@ -301,6 +326,36 @@ router.post("/oauth/authorize", async (req, res, next) => {
decision,
} = req.body;
// Validate CSRF token
if (!validateCsrf(req)) {
return res.status(403).json({
error: "invalid_request",
error_description: "Invalid or missing CSRF token",
});
}
const collections = req.app.locals.mastodonCollections;
// Re-validate redirect_uri against registered app URIs.
// The GET handler validates this, but POST body can be tampered.
const app = await collections.ap_oauth_apps.findOne({ clientId: client_id });
if (!app) {
return res.status(400).json({
error: "invalid_client",
error_description: "Client application not found",
});
}
if (
redirect_uri &&
redirect_uri !== "urn:ietf:wg:oauth:2.0:oob" &&
!app.redirectUris.includes(redirect_uri)
) {
return res.status(400).json({
error: "invalid_redirect_uri",
error_description: "Redirect URI not registered for this application",
});
}
// User denied
if (decision === "deny") {
if (redirect_uri && redirect_uri !== "urn:ietf:wg:oauth:2.0:oob") {
@@ -320,7 +375,6 @@ router.post("/oauth/authorize", async (req, res, next) => {
// Generate authorization code
const code = randomHex(32);
const collections = req.app.locals.mastodonCollections;
// Note: accessToken is NOT set here — it's added later during token exchange.
// The sparse unique index on accessToken skips documents where the field is
@@ -354,7 +408,7 @@ router.post("/oauth/authorize", async (req, res, next) => {
<body>
<h1>Authorization Code</h1>
<p>Copy this code and paste it into the application:</p>
<code>${code}</code>
<code>${escapeHtml(code)}</code>
</body>
</html>`);
}
@@ -390,7 +444,7 @@ router.post("/oauth/token", async (req, res, next) => {
const app = await collections.ap_oauth_apps.findOne({
clientId,
clientSecret,
clientSecretHash: hashSecret(clientSecret),
confidential: true,
});
@@ -410,6 +464,7 @@ router.post("/oauth/token", async (req, res, next) => {
accessToken,
createdAt: new Date(),
grantType: "client_credentials",
expiresAt: new Date(Date.now() + 3600 * 1000),
});
return res.json({
@@ -417,6 +472,7 @@ router.post("/oauth/token", async (req, res, next) => {
token_type: "Bearer",
scope: "read",
created_at: Math.floor(Date.now() / 1000),
expires_in: 3600,
});
}
@@ -447,7 +503,14 @@ router.post("/oauth/token", async (req, res, next) => {
const newRefreshToken = randomHex(64);
await collections.ap_oauth_tokens.updateOne(
{ _id: existing._id },
{ $set: { accessToken: newAccessToken, refreshToken: newRefreshToken } },
{
$set: {
accessToken: newAccessToken,
refreshToken: newRefreshToken,
expiresAt: new Date(Date.now() + 3600 * 1000),
refreshExpiresAt: new Date(Date.now() + 90 * 24 * 3600 * 1000),
},
},
);
return res.json({
@@ -456,6 +519,7 @@ router.post("/oauth/token", async (req, res, next) => {
scope: existing.scopes.join(" "),
created_at: Math.floor(existing.createdAt.getTime() / 1000),
refresh_token: newRefreshToken,
expires_in: 3600,
});
}
@@ -523,13 +587,21 @@ router.post("/oauth/token", async (req, res, next) => {
}
}
// Generate access token and refresh token.
// Clear expiresAt — it was set for the auth code, not the access token.
// Generate access token and refresh token with expiry.
const ACCESS_TOKEN_TTL = 3600 * 1000; // 1 hour
const REFRESH_TOKEN_TTL = 90 * 24 * 3600 * 1000; // 90 days
const accessToken = randomHex(64);
const refreshToken = randomHex(64);
await collections.ap_oauth_tokens.updateOne(
{ _id: grant._id },
{ $set: { accessToken, refreshToken, expiresAt: null } },
{
$set: {
accessToken,
refreshToken,
expiresAt: new Date(Date.now() + ACCESS_TOKEN_TTL),
refreshExpiresAt: new Date(Date.now() + REFRESH_TOKEN_TTL),
},
},
);
res.json({
@@ -538,6 +610,7 @@ router.post("/oauth/token", async (req, res, next) => {
scope: grant.scopes.join(" "),
created_at: Math.floor(grant.createdAt.getTime() / 1000),
refresh_token: refreshToken,
expires_in: 3600,
});
} catch (error) {
next(error);
@@ -622,7 +695,7 @@ function redirectToUri(res, originalUri, fullUrl) {
<html lang="en">
<head>
<meta charset="utf-8">
<meta http-equiv="refresh" content="0;url=${fullUrl}">
<meta http-equiv="refresh" content="0;url=${escapeHtml(fullUrl)}">
<title>Redirecting…</title>
</head>
<body>
+3 -1
View File
@@ -8,12 +8,14 @@ import { serializeStatus } from "../entities/status.js";
import { serializeAccount } from "../entities/account.js";
import { parseLimit } from "../helpers/pagination.js";
import { resolveRemoteAccount } from "../helpers/resolve-account.js";
import { tokenRequired } from "../middleware/token-required.js";
import { scopeRequired } from "../middleware/scope-required.js";
const router = express.Router(); // eslint-disable-line new-cap
// ─── GET /api/v2/search ─────────────────────────────────────────────────────
router.get("/api/v2/search", async (req, res, next) => {
router.get("/api/v2/search", tokenRequired, scopeRequired("read", "read:search"), async (req, res, next) => {
try {
const collections = req.app.locals.mastodonCollections;
const baseUrl = `${req.protocol}://${req.get("host")}`;
+77 -134
View File
@@ -29,12 +29,14 @@ import {
import { addTimelineItem } from "../../storage/timeline.js";
import { lookupWithSecurity } from "../../lookup-helpers.js";
import { addNotification } from "../../storage/notifications.js";
import { tokenRequired } from "../middleware/token-required.js";
import { scopeRequired } from "../middleware/scope-required.js";
const router = express.Router(); // eslint-disable-line new-cap
// ─── GET /api/v1/statuses/:id ───────────────────────────────────────────────
router.get("/api/v1/statuses/:id", async (req, res, next) => {
router.get("/api/v1/statuses/:id", tokenRequired, scopeRequired("read", "read:statuses"), async (req, res, next) => {
try {
const { id } = req.params;
const collections = req.app.locals.mastodonCollections;
@@ -58,7 +60,7 @@ router.get("/api/v1/statuses/:id", async (req, res, next) => {
// ─── GET /api/v1/statuses/:id/context ───────────────────────────────────────
router.get("/api/v1/statuses/:id/context", async (req, res, next) => {
router.get("/api/v1/statuses/:id/context", tokenRequired, scopeRequired("read", "read:statuses"), async (req, res, next) => {
try {
const { id } = req.params;
const collections = req.app.locals.mastodonCollections;
@@ -137,13 +139,8 @@ router.get("/api/v1/statuses/:id/context", async (req, res, next) => {
// Creates a post via the Micropub pipeline so it goes through the full flow:
// Micropub → content file → Eleventy build → syndication → AP federation.
router.post("/api/v1/statuses", async (req, res, next) => {
router.post("/api/v1/statuses", tokenRequired, scopeRequired("write", "write:statuses"), async (req, res, next) => {
try {
const token = req.mastodonToken;
if (!token) {
return res.status(401).json({ error: "The access token is invalid" });
}
const { application, publication } = req.app.locals;
const collections = req.app.locals.mastodonCollections;
const pluginOptions = req.app.locals.mastodonPluginOptions || {};
@@ -172,32 +169,42 @@ router.post("/api/v1/statuses", async (req, res, next) => {
}
}
// 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, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/(https?:\/\/[^\s<>&"')\]]+)/g, '<a href="$1">$1</a>')
.replace(/\n/g, "<br>");
const jf2 = {
type: "entry",
content: statusText || "",
content: { text: contentText, html: `<p>${contentHtml}</p>` },
};
if (inReplyTo) {
jf2["in-reply-to"] = inReplyTo;
}
if (spoilerText) {
jf2.summary = spoilerText;
}
if (sensitive === true || sensitive === "true") {
jf2.sensitive = "true";
}
if (visibility && visibility !== "public" && visibility !== "direct") {
jf2.visibility = visibility;
}
// Use content-warning (not summary) to match native reader behavior
if (spoilerText) {
jf2["content-warning"] = spoilerText;
jf2.sensitive = "true";
}
if (language) {
jf2["mp-language"] = language;
}
// ── Direct messages: bypass Micropub, send via native AP DM path ──────────
// Mastodon clients send visibility="direct" for DMs. These must NOT create
// a public blog post — instead send a Create/Note activity directly to the
@@ -358,101 +365,70 @@ router.post("/api/v1/statuses", async (req, res, next) => {
// Syndicate to AP only — 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;
jf2["mp-syndicate-to"] = [publicationUrl.replace(/\/$/, "") + "/"];
// Create post via Micropub pipeline (same functions the Micropub endpoint uses)
// postData.create() handles: normalization, post type detection, path rendering,
// mp-syndicate-to validated against configured syndicators, MongoDB posts collection
// Create post via Micropub pipeline (same internal functions)
const { postData } = await import("@indiekit/endpoint-micropub/lib/post-data.js");
const { postContent } = await import("@indiekit/endpoint-micropub/lib/post-content.js");
const data = await postData.create(application, publication, jf2);
// postContent.create() handles: template rendering, file creation in store
await postContent.create(publication, data);
const postUrl = data.properties.url;
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 handle = pluginOptions.handle || "user";
const actorUrl = profile?.url || `${publicationUrl}/users/${handle}`;
// Extract hashtags from status text and merge with any Micropub categories
const categories = data.properties.category || [];
const inlineHashtags = (statusText || "").match(/(?:^|\s)#([a-zA-Z_]\w*)/g);
if (inlineHashtags) {
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,
res.json({
id: String(Date.now()),
created_at: new Date().toISOString(),
content: `<p>${contentHtml}</p>`,
url: postUrl,
type: data.properties["post-type"] || "note",
content: processedContent,
summary: spoilerText || "",
sensitive: sensitive === true || sensitive === "true",
uri: postUrl,
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,
inReplyTo,
published: data.properties.published || now,
createdAt: now,
author: {
name: profile?.name || handle,
replies_count: 0,
reblogs_count: 0,
favourites_count: 0,
favourited: false,
reblogged: false,
bookmarked: false,
account: {
id: "owner",
username: handle,
acct: handle,
display_name: profile?.name || handle,
url: profile?.url || publicationUrl,
photo: profile?.icon || "",
handle: `@${handle}`,
avatar: profile?.icon || "",
avatar_static: profile?.icon || "",
header: "",
header_static: "",
followers_count: 0,
following_count: 0,
statuses_count: 0,
emojis: [],
bot: false,
fields: [],
},
photo: resolveMedia(data.properties.photo || []),
video: resolveMedia(data.properties.video || []),
audio: resolveMedia(data.properties.audio || []),
category: categories,
counts: { replies: 0, boosts: 0, likes: 0 },
linkPreviews: [],
mentions,
media_attachments: [],
mentions: extractMentions(contentText).map(m => ({
id: "0",
username: m.name.split("@")[1] || m.name,
acct: m.name.replace(/^@/, ""),
url: m.url,
})),
tags: [],
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) {
next(error);
}
@@ -462,13 +438,8 @@ router.post("/api/v1/statuses", async (req, res, next) => {
// Deletes via Micropub pipeline (removes content file + MongoDB post) and
// cleans up the ap_timeline entry.
router.delete("/api/v1/statuses/:id", async (req, res, next) => {
router.delete("/api/v1/statuses/:id", tokenRequired, scopeRequired("write", "write:statuses"), async (req, res, next) => {
try {
const token = req.mastodonToken;
if (!token) {
return res.status(401).json({ error: "The access token is invalid" });
}
const { application, publication } = req.app.locals;
const { id } = req.params;
const collections = req.app.locals.mastodonCollections;
@@ -650,27 +621,22 @@ router.put("/api/v1/statuses/:id", async (req, res, next) => {
// ─── GET /api/v1/statuses/:id/favourited_by ─────────────────────────────────
router.get("/api/v1/statuses/:id/favourited_by", async (req, res) => {
router.get("/api/v1/statuses/:id/favourited_by", tokenRequired, scopeRequired("read", "read:statuses"), async (req, res) => {
// Stub — we don't track who favourited remotely
res.json([]);
});
// ─── GET /api/v1/statuses/:id/reblogged_by ──────────────────────────────────
router.get("/api/v1/statuses/:id/reblogged_by", async (req, res) => {
router.get("/api/v1/statuses/:id/reblogged_by", tokenRequired, scopeRequired("read", "read:statuses"), async (req, res) => {
// Stub — we don't track who boosted remotely
res.json([]);
});
// ─── POST /api/v1/statuses/:id/favourite ────────────────────────────────────
router.post("/api/v1/statuses/:id/favourite", async (req, res, next) => {
router.post("/api/v1/statuses/:id/favourite", tokenRequired, scopeRequired("write", "write:favourites"), async (req, res, next) => {
try {
const token = req.mastodonToken;
if (!token) {
return res.status(401).json({ error: "The access token is invalid" });
}
const { item, collections, baseUrl } = await resolveStatusForInteraction(req);
if (!item) {
return res.status(404).json({ error: "Record not found" });
@@ -695,13 +661,8 @@ router.post("/api/v1/statuses/:id/favourite", async (req, res, next) => {
// ─── POST /api/v1/statuses/:id/unfavourite ──────────────────────────────────
router.post("/api/v1/statuses/:id/unfavourite", async (req, res, next) => {
router.post("/api/v1/statuses/:id/unfavourite", tokenRequired, scopeRequired("write", "write:favourites"), async (req, res, next) => {
try {
const token = req.mastodonToken;
if (!token) {
return res.status(401).json({ error: "The access token is invalid" });
}
const { item, collections, baseUrl } = await resolveStatusForInteraction(req);
if (!item) {
return res.status(404).json({ error: "Record not found" });
@@ -725,13 +686,8 @@ router.post("/api/v1/statuses/:id/unfavourite", async (req, res, next) => {
// ─── POST /api/v1/statuses/:id/reblog ───────────────────────────────────────
router.post("/api/v1/statuses/:id/reblog", async (req, res, next) => {
router.post("/api/v1/statuses/:id/reblog", tokenRequired, scopeRequired("write", "write:statuses"), async (req, res, next) => {
try {
const token = req.mastodonToken;
if (!token) {
return res.status(401).json({ error: "The access token is invalid" });
}
const { item, collections, baseUrl } = await resolveStatusForInteraction(req);
if (!item) {
return res.status(404).json({ error: "Record not found" });
@@ -755,13 +711,8 @@ router.post("/api/v1/statuses/:id/reblog", async (req, res, next) => {
// ─── POST /api/v1/statuses/:id/unreblog ─────────────────────────────────────
router.post("/api/v1/statuses/:id/unreblog", async (req, res, next) => {
router.post("/api/v1/statuses/:id/unreblog", tokenRequired, scopeRequired("write", "write:statuses"), async (req, res, next) => {
try {
const token = req.mastodonToken;
if (!token) {
return res.status(401).json({ error: "The access token is invalid" });
}
const { item, collections, baseUrl } = await resolveStatusForInteraction(req);
if (!item) {
return res.status(404).json({ error: "Record not found" });
@@ -785,13 +736,8 @@ router.post("/api/v1/statuses/:id/unreblog", async (req, res, next) => {
// ─── POST /api/v1/statuses/:id/bookmark ─────────────────────────────────────
router.post("/api/v1/statuses/:id/bookmark", async (req, res, next) => {
router.post("/api/v1/statuses/:id/bookmark", tokenRequired, scopeRequired("write", "write:bookmarks"), async (req, res, next) => {
try {
const token = req.mastodonToken;
if (!token) {
return res.status(401).json({ error: "The access token is invalid" });
}
const { item, collections, baseUrl } = await resolveStatusForInteraction(req);
if (!item) {
return res.status(404).json({ error: "Record not found" });
@@ -813,13 +759,8 @@ router.post("/api/v1/statuses/:id/bookmark", async (req, res, next) => {
// ─── POST /api/v1/statuses/:id/unbookmark ───────────────────────────────────
router.post("/api/v1/statuses/:id/unbookmark", async (req, res, next) => {
router.post("/api/v1/statuses/:id/unbookmark", tokenRequired, scopeRequired("write", "write:bookmarks"), async (req, res, next) => {
try {
const token = req.mastodonToken;
if (!token) {
return res.status(401).json({ error: "The access token is invalid" });
}
const { item, collections, baseUrl } = await resolveStatusForInteraction(req);
if (!item) {
return res.status(404).json({ error: "Record not found" });
@@ -1048,6 +989,7 @@ async function loadItemInteractions(collections, item) {
}
/**
* Process status content: linkify bare URLs and convert @mentions to links.
*
* Mastodon clients send plain text — the server is responsible for
@@ -1091,6 +1033,7 @@ function processStatusContent(content, rawText) {
}
/**
* Extract @user@domain mentions from text into mention objects.
*
* @param {string} text - Status text
+3 -6
View File
@@ -10,18 +10,15 @@ import { serializeStatus } from "../entities/status.js";
import { buildPaginationQuery, parseLimit, setPaginationHeaders } from "../helpers/pagination.js";
import { loadModerationData, applyModerationFilters } from "../../item-processing.js";
import { enrichAccountStats } from "../helpers/enrich-accounts.js";
import { tokenRequired } from "../middleware/token-required.js";
import { scopeRequired } from "../middleware/scope-required.js";
const router = express.Router(); // eslint-disable-line new-cap
// ─── GET /api/v1/timelines/home ─────────────────────────────────────────────
router.get("/api/v1/timelines/home", async (req, res, next) => {
router.get("/api/v1/timelines/home", tokenRequired, scopeRequired("read", "read:statuses"), async (req, res, next) => {
try {
const token = req.mastodonToken;
if (!token) {
return res.status(401).json({ error: "The access token is invalid" });
}
const collections = req.app.locals.mastodonCollections;
const baseUrl = `${req.protocol}://${req.get("host")}`;
const limit = parseLimit(req.query.limit);
+53 -34
View File
@@ -3,6 +3,8 @@
* @module og-unfurl
*/
import { lookup } from "node:dns/promises";
import { isIP } from "node:net";
import { unfurl } from "unfurl.js";
import { extractObjectData } from "./timeline-store.js";
import { lookupWithSecurity } from "./lookup-helpers.js";
@@ -45,45 +47,58 @@ function extractDomain(url) {
}
/**
* Check if a URL points to a private/reserved IP or localhost (SSRF protection)
* @param {string} url - URL to check
* @returns {boolean} True if URL targets a private network
* Check if an IP address is in a private/reserved range.
* @param {string} ip - IPv4 or IPv6 address
* @returns {boolean} True if private/reserved
*/
function isPrivateUrl(url) {
function isPrivateIP(ip) {
if (isIP(ip) === 4) {
const parts = ip.split(".").map(Number);
const [a, b] = parts;
if (a === 10) return true; // 10.0.0.0/8
if (a === 172 && b >= 16 && b <= 31) return true; // 172.16.0.0/12
if (a === 192 && b === 168) return true; // 192.168.0.0/16
if (a === 169 && b === 254) return true; // 169.254.0.0/16 (link-local)
if (a === 127) return true; // 127.0.0.0/8
if (a === 0) return true; // 0.0.0.0/8
}
if (isIP(ip) === 6) {
const lower = ip.toLowerCase();
if (lower.startsWith("fc") || lower.startsWith("fd")) return true; // ULA
if (lower.startsWith("fe80")) return true; // link-local
if (lower === "::1") return true; // loopback
}
return false;
}
/**
* Check if a URL resolves to a private/reserved IP (SSRF protection).
* Performs DNS resolution to defeat DNS rebinding attacks.
* @param {string} url - URL to check
* @returns {Promise<boolean>} True if URL targets a private network
*/
async function isPrivateResolved(url) {
try {
const urlObj = new URL(url);
const hostname = urlObj.hostname.toLowerCase();
// Block non-http(s) schemes
if (urlObj.protocol !== "http:" && urlObj.protocol !== "https:") {
return true;
}
// Block localhost variants
if (hostname === "localhost" || hostname === "127.0.0.1" || hostname === "::1" || hostname === "[::1]") {
return true;
}
const hostname = urlObj.hostname.toLowerCase().replace(/^\[|\]$/g, "");
// Block private IPv4 ranges
const ipv4Match = hostname.match(/^(\d+)\.(\d+)\.(\d+)\.(\d+)$/);
if (ipv4Match) {
const [, a, b] = ipv4Match.map(Number);
if (a === 10) return true; // 10.0.0.0/8
if (a === 172 && b >= 16 && b <= 31) return true; // 172.16.0.0/12
if (a === 192 && b === 168) return true; // 192.168.0.0/16
if (a === 169 && b === 254) return true; // 169.254.0.0/16 (link-local / cloud metadata)
if (a === 127) return true; // 127.0.0.0/8
if (a === 0) return true; // 0.0.0.0/8
}
// Block obvious localhost variants
if (hostname === "localhost") return true;
// Block IPv6 private ranges (basic check)
if (hostname.startsWith("[fc") || hostname.startsWith("[fd") || hostname.startsWith("[fe80")) {
return true;
}
// If hostname is already an IP, check directly (no DNS needed)
if (isIP(hostname)) return isPrivateIP(hostname);
return false;
// DNS resolution — check the resolved IP
const { address } = await lookup(hostname);
return isPrivateIP(address);
} catch {
return true; // Invalid URL, treat as private
return true; // DNS failure or invalid URL treat as private
}
}
@@ -115,14 +130,14 @@ function extractLinks(html) {
/**
* Check if URL is likely an ActivityPub object or media file
* @param {string} url - URL to check
* @returns {boolean} True if URL should be skipped
* @returns {Promise<boolean>} True if URL should be skipped
*/
function shouldSkipUrl(url) {
async function shouldSkipUrl(url) {
try {
const urlObj = new URL(url);
// SSRF protection — skip private/internal URLs
if (isPrivateUrl(url)) {
if (await isPrivateResolved(url)) {
return true;
}
@@ -158,9 +173,9 @@ export async function fetchLinkPreviews(html) {
const links = extractLinks(html);
// Filter links
const urlsToFetch = links
.filter((link) => {
// Filter links — async because shouldSkipUrl performs DNS resolution
const filterResults = await Promise.all(
links.map(async (link) => {
// Skip mention links (class="mention")
if (link.classes.includes("mention")) return false;
@@ -168,10 +183,14 @@ export async function fetchLinkPreviews(html) {
if (link.classes.includes("hashtag")) return false;
// Skip AP object URLs and media files
if (shouldSkipUrl(link.url)) return false;
if (await shouldSkipUrl(link.url)) return false;
return true;
})
}),
);
const urlsToFetch = links
.filter((_, index) => filterResults[index])
.map((link) => link.url)
.filter((url, index, self) => self.indexOf(url) === index) // Dedupe
.slice(0, MAX_PREVIEWS); // Cap at max
+11 -2
View File
@@ -3,6 +3,8 @@
* @module storage/moderation
*/
import { invalidateModerationCache } from "../item-processing.js";
/**
* Add a muted URL or keyword
* @param {object} collections - MongoDB collections
@@ -32,6 +34,7 @@ export async function addMuted(collections, { url, keyword }) {
const filter = url ? { url } : { keyword };
await ap_muted.updateOne(filter, { $setOnInsert: entry }, { upsert: true });
invalidateModerationCache();
return await ap_muted.findOne(filter);
}
@@ -55,7 +58,9 @@ export async function removeMuted(collections, { url, keyword }) {
throw new Error("Either url or keyword must be provided");
}
return await ap_muted.deleteOne(filter);
const result = await ap_muted.deleteOne(filter);
invalidateModerationCache();
return result;
}
/**
@@ -122,6 +127,7 @@ export async function addBlocked(collections, url) {
// Upsert to avoid duplicates
await ap_blocked.updateOne({ url }, { $setOnInsert: entry }, { upsert: true });
invalidateModerationCache();
return await ap_blocked.findOne({ url });
}
@@ -133,7 +139,9 @@ export async function addBlocked(collections, url) {
*/
export async function removeBlocked(collections, url) {
const { ap_blocked } = collections;
return await ap_blocked.deleteOne({ url });
const result = await ap_blocked.deleteOne({ url });
invalidateModerationCache();
return result;
}
/**
@@ -204,4 +212,5 @@ export async function setFilterMode(collections, mode) {
if (!ap_profile) return;
const valid = mode === "warn" ? "warn" : "hide";
await ap_profile.updateOne({}, { $set: { moderationFilterMode: valid } });
invalidateModerationCache();
}
+52
View File
@@ -0,0 +1,52 @@
/**
* Tombstone storage for soft-deleted posts (FEP-4f05).
* When a post is deleted, a tombstone record is created so remote servers
* fetching the URL get a proper Tombstone response instead of 404.
* @module storage/tombstones
*/
/**
* Record a tombstone for a deleted post.
* @param {object} collections - MongoDB collections
* @param {object} data - { url, formerType, published, deleted }
*/
export async function addTombstone(collections, { url, formerType, published, deleted }) {
const { ap_tombstones } = collections;
if (!ap_tombstones) return;
await ap_tombstones.updateOne(
{ url },
{
$set: {
url,
formerType: formerType || "Note",
published: published || null,
deleted: deleted || new Date().toISOString(),
},
},
{ upsert: true },
);
}
/**
* Remove a tombstone (post re-published).
* @param {object} collections - MongoDB collections
* @param {string} url - Post URL
*/
export async function removeTombstone(collections, url) {
const { ap_tombstones } = collections;
if (!ap_tombstones) return;
await ap_tombstones.deleteOne({ url });
}
/**
* Look up a tombstone by URL.
* @param {object} collections - MongoDB collections
* @param {string} url - Post URL
* @returns {Promise<object|null>} Tombstone record or null
*/
export async function getTombstone(collections, url) {
const { ap_tombstones } = collections;
if (!ap_tombstones) return null;
return ap_tombstones.findOne({ url });
}
+282
View File
@@ -0,0 +1,282 @@
/**
* ActivityPub syndicator — delivers posts to followers via Fedify.
* @module syndicator
*/
import {
jf2ToAS2Activity,
parseMentions,
} from "./jf2-to-as2.js";
import { lookupWithSecurity } from "./lookup-helpers.js";
import { logActivity } from "./activity-log.js";
import { addTimelineItem } from "./storage/timeline.js";
/**
* Create the ActivityPub syndicator object.
* @param {object} plugin - ActivityPubEndpoint instance
* @returns {object} Syndicator compatible with Indiekit's syndicator API
*/
export function createSyndicator(plugin) {
return {
name: "ActivityPub syndicator",
options: { checked: plugin.options.checked },
get info() {
const hostname = plugin._publicationUrl
? new URL(plugin._publicationUrl).hostname
: "example.com";
return {
checked: plugin.options.checked,
name: `@${plugin.options.actor.handle}@${hostname}`,
uid: plugin._publicationUrl || "https://example.com/",
service: {
name: "ActivityPub (Fediverse)",
photo: "/assets/@rmdes-indiekit-endpoint-activitypub/icon.svg",
url: plugin._publicationUrl || "https://example.com/",
},
};
},
async syndicate(properties) {
if (!plugin._federation) {
return undefined;
}
try {
const actorUrl = plugin._getActorUrl();
const handle = plugin.options.actor.handle;
const ctx = plugin._federation.createContext(
new URL(plugin._publicationUrl),
{ handle, publicationUrl: plugin._publicationUrl },
);
// For replies, resolve the original post author for proper
// addressing (CC) and direct inbox delivery
let replyToActor = null;
if (properties["in-reply-to"]) {
try {
const remoteObject = await lookupWithSecurity(ctx,
new URL(properties["in-reply-to"]),
);
if (remoteObject && typeof remoteObject.getAttributedTo === "function") {
const author = await remoteObject.getAttributedTo();
const authorActor = Array.isArray(author) ? author[0] : author;
if (authorActor?.id) {
replyToActor = {
url: authorActor.id.href,
handle: authorActor.preferredUsername || null,
recipient: authorActor,
};
console.info(
`[ActivityPub] Reply to ${properties["in-reply-to"]} — resolved author: ${replyToActor.url}`,
);
}
}
} catch (error) {
console.warn(
`[ActivityPub] Could not resolve reply-to author for ${properties["in-reply-to"]}: ${error.message}`,
);
}
}
// Resolve @user@domain mentions in content via WebFinger
const contentText = properties.content?.html || properties.content || "";
const mentionHandles = parseMentions(contentText);
const resolvedMentions = [];
const mentionRecipients = [];
for (const { handle } of mentionHandles) {
try {
const mentionedActor = await lookupWithSecurity(ctx,
new URL(`acct:${handle}`),
);
if (mentionedActor?.id) {
resolvedMentions.push({
handle,
actorUrl: mentionedActor.id.href,
profileUrl: mentionedActor.url?.href || null,
});
mentionRecipients.push({
handle,
actorUrl: mentionedActor.id.href,
actor: mentionedActor,
});
console.info(
`[ActivityPub] Resolved mention @${handle}${mentionedActor.id.href}`,
);
}
} catch (error) {
console.warn(
`[ActivityPub] Could not resolve mention @${handle}: ${error.message}`,
);
// Still add with no actorUrl so it gets a fallback link
resolvedMentions.push({ handle, actorUrl: null });
}
}
const activity = await jf2ToAS2Activity(
properties,
actorUrl,
plugin._publicationUrl,
{
replyToActorUrl: replyToActor?.url,
replyToActorHandle: replyToActor?.handle,
visibility: plugin.options.defaultVisibility,
mentions: resolvedMentions,
},
);
if (!activity) {
await logActivity(plugin._collections.ap_activities, {
direction: "outbound",
type: "Syndicate",
actorUrl: plugin._publicationUrl,
objectUrl: properties.url,
summary: `Syndication skipped: could not convert post to AS2`,
});
return undefined;
}
// Count followers for logging
const followerCount =
await plugin._collections.ap_followers.countDocuments();
console.info(
`[ActivityPub] Sending ${activity.constructor?.name || "activity"} for ${properties.url} to ${followerCount} followers`,
);
// Send to followers via shared inboxes with collection sync (FEP-8fcf)
await ctx.sendActivity(
{ identifier: handle },
"followers",
activity,
{
preferSharedInbox: true,
syncCollection: true,
orderingKey: properties.url,
},
);
// For replies, also deliver to the original post author's inbox
// so their server can thread the reply under the original post
if (replyToActor?.recipient) {
try {
await ctx.sendActivity(
{ identifier: handle },
replyToActor.recipient,
activity,
{ orderingKey: properties.url },
);
console.info(
`[ActivityPub] Reply delivered to author: ${replyToActor.url}`,
);
} catch (error) {
console.warn(
`[ActivityPub] Failed to deliver reply to ${replyToActor.url}: ${error.message}`,
);
}
}
// Deliver to mentioned actors' inboxes (skip reply-to author, already delivered above)
for (const { handle: mHandle, actorUrl: mUrl, actor: mActor } of mentionRecipients) {
if (replyToActor?.url === mUrl) continue;
try {
await ctx.sendActivity(
{ identifier: handle },
mActor,
activity,
{ orderingKey: properties.url },
);
console.info(
`[ActivityPub] Mention delivered to @${mHandle}: ${mUrl}`,
);
} catch (error) {
console.warn(
`[ActivityPub] Failed to deliver mention to @${mHandle}: ${error.message}`,
);
}
}
// Determine activity type name
const typeName =
activity.constructor?.name || "Create";
const replyNote = replyToActor
? ` (reply to ${replyToActor.url})`
: "";
const mentionNote = mentionRecipients.length > 0
? ` (mentions: ${mentionRecipients.map(m => `@${m.handle}`).join(", ")})`
: "";
await logActivity(plugin._collections.ap_activities, {
direction: "outbound",
type: typeName,
actorUrl: plugin._publicationUrl,
objectUrl: properties.url,
targetUrl: properties["in-reply-to"] || undefined,
summary: `Sent ${typeName} for ${properties.url} to ${followerCount} followers${replyNote}${mentionNote}`,
});
console.info(
`[ActivityPub] Syndication queued: ${typeName} for ${properties.url}${replyNote}`,
);
// Mirror own Micropub-created posts into ap_timeline so the Mastodon
// Client API (context, statuses, etc.) can find them by ID.
if (typeName === "Create" && properties.url) {
try {
const rawHtml = properties.content?.html || (typeof properties.content === "string" ? properties.content : "") || "";
const now = new Date().toISOString();
const postType = properties["post-type"] || "note";
const asArray = (v) => Array.isArray(v) ? v : v ? [v] : [];
await addTimelineItem(plugin._collections, {
uid: properties.url,
url: properties.url,
type: postType,
content: { html: rawHtml, text: rawHtml.replace(/<[^>]*>/g, "") },
summary: properties["content-warning"] || properties.summary || "",
sensitive: !!(properties.sensitive || properties["content-warning"]),
visibility: properties.visibility || plugin.options.defaultVisibility || "public",
language: properties.lang || properties.language || null,
inReplyTo: properties["in-reply-to"] || null,
published: properties.published || now,
createdAt: now,
author: {
name: plugin.options.actor.name || handle,
url: actorUrl,
photo: plugin.options.actor.icon || "",
handle: `@${handle}`,
emojis: [],
bot: false,
},
photo: asArray(properties.photo),
video: asArray(properties.video),
audio: asArray(properties.audio),
category: asArray(properties.category),
counts: { replies: 0, boosts: 0, likes: 0 },
linkPreviews: [],
mentions: [],
emojis: [],
});
} catch (timelineError) {
console.warn("[ActivityPub] Failed to mirror syndicated post to ap_timeline:", timelineError.message);
}
}
return properties.url || undefined;
} catch (error) {
console.error("[ActivityPub] Syndication failed:", error.message);
await logActivity(plugin._collections.ap_activities, {
direction: "outbound",
type: "Syndicate",
actorUrl: plugin._publicationUrl,
objectUrl: properties.url,
summary: `Syndication failed: ${error.message}`,
}).catch(() => {});
return undefined;
}
},
delete: async (url) => plugin.delete(url),
update: async (properties) => plugin.update(properties),
};
}
+11 -15
View File
@@ -28,7 +28,7 @@ export function sanitizeContent(html) {
},
allowedSchemes: ["http", "https", "mailto"],
allowedSchemesByTag: {
img: ["http", "https", "data"]
img: ["http", "https"]
}
});
}
@@ -46,11 +46,16 @@ export function replaceCustomEmoji(html, emojis) {
if (!emojis?.length || !html) return html;
let result = html;
for (const { shortcode, url } of emojis) {
// Validate URL is HTTP(S) only — reject data:, javascript:, etc.
if (!url || (!url.startsWith("https://") && !url.startsWith("http://"))) continue;
// Escape HTML special characters in URL and shortcode to prevent attribute injection
const safeUrl = url.replace(/&/g, "&amp;").replace(/"/g, "&quot;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
const safeShortcode = shortcode.replace(/&/g, "&amp;").replace(/"/g, "&quot;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
const escaped = shortcode.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const pattern = new RegExp(`:${escaped}:`, "g");
result = result.replace(
pattern,
`<img class="ap-custom-emoji" src="${url}" alt=":${shortcode}:" title=":${shortcode}:" draggable="false">`,
`<img class="ap-custom-emoji" src="${safeUrl}" alt=":${safeShortcode}:" title=":${safeShortcode}:" draggable="false">`,
);
}
return result;
@@ -349,20 +354,11 @@ export async function extractObjectData(object, options = {}) {
// Quote URL — Fedify reads quoteUrl / _misskey_quote / quoteUri
const quoteUrl = object.quoteUrl?.href || "";
// Interaction counts — extract from AP Collection objects
// Interaction counts — not fetched at ingest time. The three collection
// fetches (getReplies, getLikes, getShares) each trigger an HTTP round-trip
// for counts that are ephemeral and stale moments after fetching. Removed
// per audit M11 to save 3 network calls per inbound activity.
const counts = { replies: null, boosts: null, likes: null };
try {
const replies = await object.getReplies?.(loaderOpts);
if (replies?.totalItems != null) counts.replies = replies.totalItems;
} catch { /* ignore — collection may not exist */ }
try {
const likes = await object.getLikes?.(loaderOpts);
if (likes?.totalItems != null) counts.likes = likes.totalItems;
} catch { /* ignore */ }
try {
const shares = await object.getShares?.(loaderOpts);
if (shares?.totalItems != null) counts.boosts = shares.totalItems;
} catch { /* ignore */ }
// Replace custom emoji :shortcode: in content with inline <img> tags.
// Applied after sanitization — these are trusted emoji from the post's tags.
+170 -91
View File
@@ -1,19 +1,20 @@
{
"name": "@rmdes/indiekit-endpoint-activitypub",
"version": "2.1.2",
"version": "3.9.4",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@rmdes/indiekit-endpoint-activitypub",
"version": "2.1.2",
"version": "3.9.4",
"license": "MIT",
"dependencies": {
"@fedify/debugger": "^2.0.0",
"@fedify/fedify": "^2.0.0",
"@fedify/redis": "^2.0.0",
"@fedify/debugger": "^2.1.0",
"@fedify/fedify": "^2.1.0",
"@fedify/redis": "^2.1.0",
"@js-temporal/polyfill": "^0.5.0",
"express": "^5.0.0",
"express-rate-limit": "^7.5.1",
"ioredis": "^5.9.3",
"sanitize-html": "^2.13.1",
"unfurl.js": "^6.4.0"
@@ -22,6 +23,7 @@
"node": ">=22"
},
"peerDependencies": {
"@indiekit/endpoint-micropub": "^1.0.0-beta.25",
"@indiekit/error": "^1.0.0-beta.25",
"@indiekit/frontend": "^1.0.0-beta.25"
}
@@ -513,12 +515,12 @@
}
},
"node_modules/@fedify/debugger": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/@fedify/debugger/-/debugger-2.0.0.tgz",
"integrity": "sha512-2ZGXKQa+BqAO9F+tuZcDiLHDr193cUqKlnX1Z7yDn1ICPL1gPxxwgKAa1b540pBBWSfDCXBSrJlZ3DYK9f52GA==",
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/@fedify/debugger/-/debugger-2.1.0.tgz",
"integrity": "sha512-4s3L3/NkofZCUXR1jADq5ukSbWybpWqgqF4TEg3PHxlXkC3bT/LI4as8zFxTpkkCvM5fE6tXCi5z56rJ9tXzag==",
"dependencies": {
"@js-temporal/polyfill": "^0.5.1",
"@logtape/logtape": "^2.0.0",
"@logtape/logtape": "^2.0.5",
"@opentelemetry/api": "^1.9.0",
"@opentelemetry/context-async-hooks": "^2.5.0",
"@opentelemetry/core": "^2.5.0",
@@ -526,24 +528,24 @@
"hono": "^4.0.0"
},
"peerDependencies": {
"@fedify/fedify": "^2.0.0"
"@fedify/fedify": "^2.1.0"
}
},
"node_modules/@fedify/fedify": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/@fedify/fedify/-/fedify-2.0.0.tgz",
"integrity": "sha512-ZejBFXfILViuIHYhI1BWEk1Pewt9hNO70u6GVaWYKWwU3IVc1/HEsGA/kK9IxJKYZBPqLcCtoI2BZfeOg8I/Hg==",
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/@fedify/fedify/-/fedify-2.1.0.tgz",
"integrity": "sha512-CMGlL9HEaqyuQL4Ma0Jv+9/QgtLjj+HLmjNrg1e/WUQrEwZg9p5WYKk4iNKXF4aIG3XJkAv5UGJlHKF09HifNA==",
"funding": [
"https://opencollective.com/fedify",
"https://github.com/sponsors/dahlia"
],
"license": "MIT",
"dependencies": {
"@fedify/vocab": "2.0.0",
"@fedify/vocab-runtime": "2.0.0",
"@fedify/webfinger": "2.0.0",
"@fedify/vocab": "2.1.0",
"@fedify/vocab-runtime": "2.1.0",
"@fedify/webfinger": "2.1.0",
"@js-temporal/polyfill": "^0.5.1",
"@logtape/logtape": "^2.0.0",
"@logtape/logtape": "^2.0.5",
"@opentelemetry/api": "^1.9.0",
"@opentelemetry/core": "^2.5.0",
"@opentelemetry/sdk-trace-base": "^2.5.0",
@@ -552,7 +554,6 @@
"es-toolkit": "1.43.0",
"json-canon": "^1.0.1",
"jsonld": "^9.0.0",
"multicodec": "^3.2.1",
"structured-field-values": "^2.0.4",
"uri-template-router": "^1.0.0",
"url-template": "^3.1.1",
@@ -565,9 +566,9 @@
}
},
"node_modules/@fedify/redis": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/@fedify/redis/-/redis-2.0.0.tgz",
"integrity": "sha512-WHUhEHZ0BAbcmITgDTdspeolfl4bHpRx+BlmdVRsGScaoQODvvohBfRcTCGbMz2RfQmkhz4l297Nk8Nlqyvg7Q==",
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/@fedify/redis/-/redis-2.1.0.tgz",
"integrity": "sha512-Fqud46FIEBFXDFad029rS46ZlVlWZU2zT6yhBs63jtat7QwMIHDSisizvoVyky4a41TX0ItBNqiAdYELLv/0NQ==",
"funding": [
"https://opencollective.com/fedify",
"https://github.com/sponsors/dahlia"
@@ -575,34 +576,33 @@
"license": "MIT",
"dependencies": {
"@js-temporal/polyfill": "^0.5.1",
"@logtape/logtape": "^2.0.0"
"@logtape/logtape": "^2.0.5"
},
"peerDependencies": {
"@fedify/fedify": "^2.0.0",
"@fedify/fedify": "^2.1.0",
"ioredis": "^5.8.2"
}
},
"node_modules/@fedify/vocab": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/@fedify/vocab/-/vocab-2.0.0.tgz",
"integrity": "sha512-sjw51UltqefhGPxkcSrnwdmBHO5Zm3hOlOHFvzsLg1pbl53KKETcJ8TG6OcMaD0ZiaUqFVKkGAlpDG3FD9O4nw==",
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/@fedify/vocab/-/vocab-2.1.0.tgz",
"integrity": "sha512-tGCgo8kCj6Zwf1JxYsXtEwReujzgitndf59Pdo1BY21UgpAlAe0daY8vdpRM+NybZ4JbOBtM4bH473LVtJlVEA==",
"funding": [
"https://opencollective.com/fedify",
"https://github.com/sponsors/dahlia"
],
"license": "MIT",
"dependencies": {
"@fedify/vocab-runtime": "2.0.0",
"@fedify/vocab-tools": "2.0.0",
"@fedify/webfinger": "2.0.0",
"@fedify/vocab-runtime": "2.1.0",
"@fedify/vocab-tools": "2.1.0",
"@fedify/webfinger": "2.1.0",
"@js-temporal/polyfill": "^0.5.1",
"@logtape/logtape": "^2.0.0",
"@logtape/logtape": "^2.0.5",
"@multiformats/base-x": "^4.0.1",
"@opentelemetry/api": "^1.9.0",
"asn1js": "^3.0.6",
"es-toolkit": "1.43.0",
"jsonld": "^9.0.0",
"multicodec": "^3.2.1",
"pkijs": "^3.3.3"
},
"engines": {
@@ -612,21 +612,21 @@
}
},
"node_modules/@fedify/vocab-runtime": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/@fedify/vocab-runtime/-/vocab-runtime-2.0.0.tgz",
"integrity": "sha512-Cdcbhki75kBi20Eq0Dkpf1XXXVKVwnOzK4O/b4MKH6kmUPEcVyNVb9L3+ZZElViE+kAZ0bmYLlFNp42E7mjjLQ==",
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/@fedify/vocab-runtime/-/vocab-runtime-2.1.0.tgz",
"integrity": "sha512-rISQFJbuRrt1OX9yG+xVUn7DwBTajpOOvy5jdx2ZuRUMvtlD7bgDEUSSS5a7pFuYliKVbR5ZFk6BPAkJC1OnAw==",
"funding": [
"https://opencollective.com/fedify",
"https://github.com/sponsors/dahlia"
],
"license": "MIT",
"dependencies": {
"@logtape/logtape": "^2.0.0",
"@logtape/logtape": "^2.0.5",
"@multiformats/base-x": "^4.0.1",
"@opentelemetry/api": "^1.9.0",
"asn1js": "^3.0.6",
"byte-encodings": "^1.0.11",
"multicodec": "^3.2.1",
"jsonld": "^9.0.0",
"pkijs": "^3.3.3"
},
"engines": {
@@ -636,9 +636,9 @@
}
},
"node_modules/@fedify/vocab-tools": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/@fedify/vocab-tools/-/vocab-tools-2.0.0.tgz",
"integrity": "sha512-AQ1zjGt4wjaTjTOHCgDroNITiSeZ1z99ygNEkmukg5EwgjF7+DVoPV+OTrmVVW/2A6t1blJzfOS0wrlzsn5lqQ==",
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/@fedify/vocab-tools/-/vocab-tools-2.1.0.tgz",
"integrity": "sha512-Gn07LbMoDRVDjklDZH9y/fZ2nwH7ryjillgLpw8qsbjUeVaTViR1Oz/oG2N7S13UqyKttYPzK8hH/utKr1LbXg==",
"funding": [
"https://opencollective.com/fedify",
"https://github.com/sponsors/dahlia"
@@ -657,17 +657,17 @@
}
},
"node_modules/@fedify/webfinger": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/@fedify/webfinger/-/webfinger-2.0.0.tgz",
"integrity": "sha512-D66ZdyUUM9BNQU7OGEWmNLU+FIHApUiYnEogOa5oNj9fy0vjOfxm9hynA+0SCm3emrKrLt6Gm120Re+T5Us5Yg==",
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/@fedify/webfinger/-/webfinger-2.1.0.tgz",
"integrity": "sha512-G5yrCPw1oWijvkGOMjWZFOWohmljQ4pmHgK7BuESshcAizpKRU0t5GcOGMyPzcNrO4+diaddGNg48GFzZ9mK/g==",
"funding": [
"https://opencollective.com/fedify",
"https://github.com/sponsors/dahlia"
],
"license": "MIT",
"dependencies": {
"@fedify/vocab-runtime": "2.0.0",
"@logtape/logtape": "^2.0.0",
"@fedify/vocab-runtime": "2.1.0",
"@logtape/logtape": "^2.0.5",
"@opentelemetry/api": "^1.9.0",
"es-toolkit": "1.43.0"
},
@@ -1167,10 +1167,31 @@
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@indiekit/endpoint-micropub": {
"version": "1.0.0-beta.27",
"resolved": "https://registry.npmjs.org/@indiekit/endpoint-micropub/-/endpoint-micropub-1.0.0-beta.27.tgz",
"integrity": "sha512-0NAiAYte5u+w3kh2dDAXbzA9b8Hujoiue59OHEen8/w1ZHyOI/Zp1ctlErrakFBqElPx6ZyfDbbrBXpaCudXbQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"@indiekit/error": "^1.0.0-beta.27",
"@indiekit/util": "^1.0.0-beta.25",
"@paulrobertlloyd/mf2tojf2": "^3.0.0",
"debug": "^4.3.2",
"express": "^5.0.0",
"lodash": "^4.17.21",
"markdown-it": "^14.0.0",
"newbase60": "^1.3.1",
"turndown": "^7.1.1"
},
"engines": {
"node": ">=20"
}
},
"node_modules/@indiekit/error": {
"version": "1.0.0-beta.25",
"resolved": "https://registry.npmjs.org/@indiekit/error/-/error-1.0.0-beta.25.tgz",
"integrity": "sha512-ZDM6cyC4qPaosv4Ji1gGObSYpOlHNMqys9v428E7/XvK1qT3uW5S8mAeqGu7ErbWdMZINe0ua0fuZwBlGmSPLg==",
"version": "1.0.0-beta.27",
"resolved": "https://registry.npmjs.org/@indiekit/error/-/error-1.0.0-beta.27.tgz",
"integrity": "sha512-Y0XIM1fptHf3i4cfxcIMqueMtqEJ6rOn2qtiYCmJcreiuG72CwaOjXtTW7CELpW/o4B0aZ9pUTEr8ef2+qvRIQ==",
"license": "MIT",
"peer": true,
"engines": {
@@ -1241,14 +1262,21 @@
}
},
"node_modules/@logtape/logtape": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/@logtape/logtape/-/logtape-2.0.2.tgz",
"integrity": "sha512-cveUBLbCMFkvkLycP/2vNWvywl47JcJbazHIju94/QNGboZ/jyYAgZIm0ZXezAOx3eIz8OG1EOZ5CuQP3+2FQg==",
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/@logtape/logtape/-/logtape-2.0.5.tgz",
"integrity": "sha512-UizDkh20ZPJVOddRxG1F77WhHdlNl/sbQgoO8T534R7XvUBMAJ9En9f35u+meW2tRsNLvjz6R87Zanwf53tspQ==",
"funding": [
"https://github.com/sponsors/dahlia"
],
"license": "MIT"
},
"node_modules/@mixmark-io/domino": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/@mixmark-io/domino/-/domino-2.2.0.tgz",
"integrity": "sha512-Y28PR25bHXUg88kCV7nivXrP2Nj2RueZ3/l/jdx6J9f8J4nsEGcgX0Qe6lt7Pa+J79+kPiJU3LguR6O/6zrLOw==",
"license": "BSD-2-Clause",
"peer": true
},
"node_modules/@mongodb-js/saslprep": {
"version": "1.4.6",
"resolved": "https://registry.npmjs.org/@mongodb-js/saslprep/-/saslprep-1.4.6.tgz",
@@ -1355,6 +1383,19 @@
"node": ">=14"
}
},
"node_modules/@paulrobertlloyd/mf2tojf2": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/@paulrobertlloyd/mf2tojf2/-/mf2tojf2-3.0.0.tgz",
"integrity": "sha512-R94UVfQ1RrJSvVEco7jk3yeACLCtEixLm6sPnBNjEPpvYr9IitOh9xSFWTT5eFSjT9qYEpBz9SYv3N/g7LK3Dg==",
"license": "MIT",
"peer": true,
"dependencies": {
"microformats-parser": "^2.0.0"
},
"engines": {
"node": ">=22"
}
},
"node_modules/@sindresorhus/slugify": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/@sindresorhus/slugify/-/slugify-3.0.0.tgz",
@@ -2046,6 +2087,21 @@
"url": "https://opencollective.com/express"
}
},
"node_modules/express-rate-limit": {
"version": "7.5.1",
"resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-7.5.1.tgz",
"integrity": "sha512-7iN8iPMDzOMHPUYllBEsQdWVB6fPDMPqwjBaFrgr4Jgr/+okjvzAy+UHlYYL/Vs0OsOrMkwS6PJDkFlJwoxUnw==",
"license": "MIT",
"engines": {
"node": ">= 16"
},
"funding": {
"url": "https://github.com/sponsors/express-rate-limit"
},
"peerDependencies": {
"express": ">= 4.11"
}
},
"node_modules/finalhandler": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz",
@@ -2770,6 +2826,19 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/microformats-parser": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/microformats-parser/-/microformats-parser-2.0.4.tgz",
"integrity": "sha512-DA2yt3uz2JjupBGoNvaG9ngBP5vSTI1ky2yhxBai/RnQrlzo+gEzuCdvwIIjj2nh3uVPDybTP5u7uua7pOa6LA==",
"license": "MIT",
"peer": true,
"dependencies": {
"parse5": "^7.1.2"
},
"engines": {
"node": ">=18"
}
},
"node_modules/mime-db": {
"version": "1.54.0",
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz",
@@ -2899,23 +2968,6 @@
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"license": "MIT"
},
"node_modules/multicodec": {
"version": "3.2.1",
"resolved": "https://registry.npmjs.org/multicodec/-/multicodec-3.2.1.tgz",
"integrity": "sha512-+expTPftro8VAW8kfvcuNNNBgb9gPeNYV9dn+z1kJRWF2vih+/S79f2RVeIwmrJBUJ6NT9IUPWnZDQvegEh5pw==",
"deprecated": "This module has been superseded by the multiformats module",
"license": "MIT",
"dependencies": {
"uint8arrays": "^3.0.0",
"varint": "^6.0.0"
}
},
"node_modules/multiformats": {
"version": "9.9.0",
"resolved": "https://registry.npmjs.org/multiformats/-/multiformats-9.9.0.tgz",
"integrity": "sha512-HoMUjhH9T8DDBNT+6xzkrd9ga/XiBI4xLr58LJACwK6G3HTOPeMz4nB4KJs33L2BelrIJa7P0VuNaVF3hMYfjg==",
"license": "(Apache-2.0 AND MIT)"
},
"node_modules/nanoid": {
"version": "3.3.11",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
@@ -2943,6 +2995,12 @@
"node": ">= 0.6"
}
},
"node_modules/newbase60": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/newbase60/-/newbase60-1.3.1.tgz",
"integrity": "sha512-2bjwvv8ytc4YQXXnV7lSz7yzQv01eYcdhhX/lo3OWkXgRSxfbbQb922s+6uiC4i5HbNlNu8Vtu9mSZ/xKRaTkg==",
"peer": true
},
"node_modules/node-fetch": {
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz",
@@ -3028,6 +3086,32 @@
"integrity": "sha512-/2qh0lav6CmI15FzA3i/2Bzk2zCgQhGMkvhOhKNcBVQ1ldgpbfiNTVslmooUmWJcADi1f1kIeynbDRVzNlfR6Q==",
"license": "MIT"
},
"node_modules/parse5": {
"version": "7.3.0",
"resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz",
"integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==",
"license": "MIT",
"peer": true,
"dependencies": {
"entities": "^6.0.0"
},
"funding": {
"url": "https://github.com/inikulin/parse5?sponsor=1"
}
},
"node_modules/parse5/node_modules/entities": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz",
"integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==",
"license": "BSD-2-Clause",
"peer": true,
"engines": {
"node": ">=0.12"
},
"funding": {
"url": "https://github.com/fb55/entities?sponsor=1"
}
},
"node_modules/parseurl": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
@@ -3054,9 +3138,9 @@
"license": "ISC"
},
"node_modules/pkijs": {
"version": "3.3.3",
"resolved": "https://registry.npmjs.org/pkijs/-/pkijs-3.3.3.tgz",
"integrity": "sha512-+KD8hJtqQMYoTuL1bbGOqxb4z+nZkTAwVdNtWwe8Tc2xNbEmdJYIYoc6Qt0uF55e6YW6KuTHw1DjQ18gMhzepw==",
"version": "3.4.0",
"resolved": "https://registry.npmjs.org/pkijs/-/pkijs-3.4.0.tgz",
"integrity": "sha512-emEcLuomt2j03vxD54giVB4SxTjnsqkU692xZOZXHDVoYyypEm+b3jpiTcc+Cf+myooc+/Ly0z01jqeNHVgJGw==",
"license": "BSD-3-Clause",
"dependencies": {
"@noble/hashes": "1.4.0",
@@ -3515,6 +3599,16 @@
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"license": "0BSD"
},
"node_modules/turndown": {
"version": "7.2.2",
"resolved": "https://registry.npmjs.org/turndown/-/turndown-7.2.2.tgz",
"integrity": "sha512-1F7db8BiExOKxjSMU2b7if62D/XOyQyZbPKq/nUwopfgnHlqXHqQ0lvfUTeUIr1lZJzOPFn43dODyMSIfvWRKQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"@mixmark-io/domino": "^2.2.0"
}
},
"node_modules/type-is": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz",
@@ -3543,19 +3637,10 @@
"license": "MIT",
"peer": true
},
"node_modules/uint8arrays": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/uint8arrays/-/uint8arrays-3.1.1.tgz",
"integrity": "sha512-+QJa8QRnbdXVpHYjLoTpJIdCTiw9Ir62nocClWuXIq2JIh4Uta0cQsTSpFL678p2CN8B+XSApwcU+pQEqVpKWg==",
"license": "MIT",
"dependencies": {
"multiformats": "^9.4.2"
}
},
"node_modules/undici": {
"version": "6.23.0",
"resolved": "https://registry.npmjs.org/undici/-/undici-6.23.0.tgz",
"integrity": "sha512-VfQPToRA5FZs/qJxLIinmU59u0r7LXqoJkCzinq3ckNJp3vKEh7jTWN589YQ5+aoAC/TGRLyJLCPKcLQbM8r9g==",
"version": "6.24.1",
"resolved": "https://registry.npmjs.org/undici/-/undici-6.24.1.tgz",
"integrity": "sha512-sC+b0tB1whOCzbtlx20fx3WgCXwkW627p4EA9uM+/tNNPkSS+eSEld6pAs9nDv7WbY1UUljBMYPtu9BCOrCWKA==",
"license": "MIT",
"engines": {
"node": ">=18.17"
@@ -3628,12 +3713,6 @@
"integrity": "sha512-IGjKp/o0NL3Bso1PymYURCJxMPNAf/ILOpendP9f5B6e1rTJgdgiOvgfoT8VxCAdY+Wisb9uhGaJJf3yZ2V9nw==",
"license": "MIT"
},
"node_modules/varint": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/varint/-/varint-6.0.0.tgz",
"integrity": "sha512-cXEIW6cfr15lFv563k4GuVuW/fiwjknytD37jIOLSdSWuOI6WnO/oKwmP2FQTU2l01LP8/M5TSAJpzUaGe3uWg==",
"license": "MIT"
},
"node_modules/vary": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
@@ -3672,9 +3751,9 @@
"license": "ISC"
},
"node_modules/yaml": {
"version": "2.8.2",
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.2.tgz",
"integrity": "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==",
"version": "2.8.3",
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.3.tgz",
"integrity": "sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg==",
"license": "ISC",
"bin": {
"yaml": "bin.mjs"
+5 -4
View File
@@ -1,6 +1,6 @@
{
"name": "@rmdes/indiekit-endpoint-activitypub",
"version": "3.8.5",
"version": "3.10.0",
"description": "ActivityPub federation endpoint for Indiekit via Fedify. Adds full fediverse support: actor, inbox, outbox, followers, following, syndication, and Mastodon migration.",
"keywords": [
"indiekit",
@@ -37,11 +37,12 @@
"url": "https://github.com/rmdes/indiekit-endpoint-activitypub/issues"
},
"dependencies": {
"@fedify/debugger": "^2.0.0",
"@fedify/fedify": "^2.0.0",
"@fedify/redis": "^2.0.0",
"@fedify/debugger": "^2.1.0",
"@fedify/fedify": "^2.1.0",
"@fedify/redis": "^2.1.0",
"@js-temporal/polyfill": "^0.5.0",
"express": "^5.0.0",
"express-rate-limit": "^7.5.1",
"ioredis": "^5.9.3",
"sanitize-html": "^2.13.1",
"unfurl.js": "^6.4.0"
+2 -2
View File
@@ -34,7 +34,7 @@
</a>
{% endif %}
</div>
<p x-show="actionResult" x-text="actionResult" class="ap-federation__result" x-cloak></p>
<p x-show="actionResult" x-text="actionResult" class="ap-federation__result" role="status" x-cloak></p>
</section>
{# --- Object Lookup --- #}
@@ -49,7 +49,7 @@
<span x-show="lookupLoading" x-cloak>{{ __("activitypub.federationMgmt.lookupLoading") }}</span>
</button>
</form>
<p x-show="lookupError" x-text="lookupError" class="ap-federation__error" x-cloak></p>
<p x-show="lookupError" x-text="lookupError" class="ap-federation__error" role="alert" x-cloak></p>
<pre x-show="lookupResult" x-text="lookupResult" class="ap-federation__json-view" x-cloak></pre>
</section>
+1 -1
View File
@@ -131,7 +131,7 @@
{{ __("activitypub.moderation.addKeyword") }}
</button>
</form>
<p x-show="error" x-text="error" class="ap-moderation__error" x-cloak></p>
<p x-show="error" x-text="error" class="ap-moderation__error" role="alert" x-cloak></p>
</section>
</div>
+15 -75
View File
@@ -75,29 +75,25 @@
values: [profile.actorType or "Person"]
}) }}
<fieldset class="fieldset" style="margin-block-end: var(--space-l);">
<fieldset class="fieldset" style="margin-block-end: var(--space-l);" x-data="{ links: [{% if profile.attachments and profile.attachments.length > 0 %}{% for att in profile.attachments %}{ name: {{ att.name | dump | safe }}, value: {{ att.value | dump | safe }} }{% if not loop.last %},{% endif %}{% endfor %}{% endif %}] }">
<legend class="label">{{ __("activitypub.profile.linksLabel") }}</legend>
<p class="hint">{{ __("activitypub.profile.linksHint") }}</p>
<div id="profile-links">
{% if profile.attachments and profile.attachments.length > 0 %}
{% for att in profile.attachments %}
<div class="profile-link-row" style="display: grid; grid-template-columns: 1fr 2fr auto; gap: var(--space-s); align-items: end; margin-block-end: var(--space-s);">
<div>
<label class="label" for="link_name_{{ loop.index }}">{{ __("activitypub.profile.linkNameLabel") }}</label>
<input class="input" type="text" id="link_name_{{ loop.index }}" name="link_name[]" value="{{ att.name }}" placeholder="Website">
</div>
<div>
<label class="label" for="link_value_{{ loop.index }}">{{ __("activitypub.profile.linkValueLabel") }}</label>
<input class="input" type="url" id="link_value_{{ loop.index }}" name="link_value[]" value="{{ att.value }}" placeholder="https://example.com">
</div>
<button type="button" class="button button--small profile-link-remove" style="margin-block-end: 4px;">{{ __("activitypub.profile.removeLink") }}</button>
</div>
{% endfor %}
{% endif %}
</div>
<template x-for="(link, index) in links" :key="index">
<div class="profile-link-row" style="display: grid; grid-template-columns: 1fr 2fr auto; gap: var(--space-s); align-items: end; margin-block-end: var(--space-s);">
<div>
<label class="label">{{ __("activitypub.profile.linkNameLabel") }}</label>
<input class="input" type="text" :name="'link_name[]'" x-model="link.name" placeholder="Website">
</div>
<div>
<label class="label">{{ __("activitypub.profile.linkValueLabel") }}</label>
<input class="input" type="url" :name="'link_value[]'" x-model="link.value" placeholder="https://example.com">
</div>
<button type="button" class="button button--small profile-link-remove" style="margin-block-end: 4px;" @click="links.splice(index, 1)">{{ __("activitypub.profile.removeLink") }}</button>
</div>
</template>
<button type="button" class="button button--small" id="add-link-btn">{{ __("activitypub.profile.addLink") }}</button>
<button type="button" class="button button--small" @click="links.push({ name: '', value: '' })">{{ __("activitypub.profile.addLink") }}</button>
</fieldset>
{{ checkboxes({
@@ -127,60 +123,4 @@
{{ button({ text: __("activitypub.profile.save") }) }}
</form>
<script>
(function() {
document.getElementById('profile-links').addEventListener('click', function(e) {
var btn = e.target.closest('.profile-link-remove');
if (btn) btn.closest('.profile-link-row').remove();
});
var linkCount = {{ (profile.attachments.length if profile.attachments) or 0 }};
document.getElementById('add-link-btn').addEventListener('click', function() {
linkCount++;
var container = document.getElementById('profile-links');
var row = document.createElement('div');
row.className = 'profile-link-row';
row.style.cssText = 'display: grid; grid-template-columns: 1fr 2fr auto; gap: var(--space-s); align-items: end; margin-block-end: var(--space-s);';
var nameDiv = document.createElement('div');
var nameLabel = document.createElement('label');
nameLabel.className = 'label';
nameLabel.setAttribute('for', 'link_name_' + linkCount);
nameLabel.textContent = 'Label';
var nameInput = document.createElement('input');
nameInput.className = 'input';
nameInput.type = 'text';
nameInput.id = 'link_name_' + linkCount;
nameInput.name = 'link_name[]';
nameInput.placeholder = 'Website';
nameDiv.appendChild(nameLabel);
nameDiv.appendChild(nameInput);
var valueDiv = document.createElement('div');
var valueLabel = document.createElement('label');
valueLabel.className = 'label';
valueLabel.setAttribute('for', 'link_value_' + linkCount);
valueLabel.textContent = 'URL';
var valueInput = document.createElement('input');
valueInput.className = 'input';
valueInput.type = 'url';
valueInput.id = 'link_value_' + linkCount;
valueInput.name = 'link_value[]';
valueInput.placeholder = 'https://example.com';
valueDiv.appendChild(valueLabel);
valueDiv.appendChild(valueInput);
var removeBtn = document.createElement('button');
removeBtn.type = 'button';
removeBtn.className = 'button button--small profile-link-remove';
removeBtn.style.cssText = 'margin-block-end: 4px;';
removeBtn.textContent = 'Remove';
row.appendChild(nameDiv);
row.appendChild(valueDiv);
row.appendChild(removeBtn);
container.appendChild(row);
});
})();
</script>
{% endblock %}
+2 -1
View File
@@ -101,6 +101,7 @@
data-tab="{{ tab }}"
data-mount-path="{{ mountPath }}"
x-show="count > 0"
role="status"
x-cloak>
<button class="ap-new-posts-banner__btn" @click="loadNew()">
<span x-text="count + ' new post' + (count !== 1 ? 's' : '')"></span> — Load
@@ -167,7 +168,7 @@
<button class="ap-load-more__btn" @click="loadMore()" :disabled="loading" x-show="!done && !loading">
{{ __("activitypub.reader.pagination.loadMore") }}
</button>
<div class="ap-skeleton-group" x-show="loading" x-cloak>
<div class="ap-skeleton-group" x-show="loading" aria-live="polite" x-cloak>
{% include "partials/ap-skeleton-card.njk" %}
{% include "partials/ap-skeleton-card.njk" %}
{% include "partials/ap-skeleton-card.njk" %}
+2
View File
@@ -3,6 +3,8 @@
{% block content %}
{# Infinite scroll component — must load before Alpine to register via alpine:init #}
<script defer src="/assets/@rmdes-indiekit-endpoint-activitypub/reader-infinite-scroll.js"></script>
{# Card interaction component — apCardInteraction Alpine component #}
<script defer src="/assets/@rmdes-indiekit-endpoint-activitypub/reader-interactions.js"></script>
{# Autocomplete components for explore + popular accounts #}
<script defer src="/assets/@rmdes-indiekit-endpoint-activitypub/reader-autocomplete.js"></script>
{# Tab components — apExploreTabs #}
+14 -117
View File
@@ -26,14 +26,14 @@
{# Boost header if this is a boosted post #}
{% if item.type == "boost" and item.boostedBy %}
<div class="ap-card__boost">
🔁 {% if item.boostedBy.url %}<a href="{{ mountPath }}/admin/reader/profile?url={{ item.boostedBy.url | urlencode }}">{% if item.boostedBy.nameHtml %}{{ item.boostedBy.nameHtml | safe }}{% else %}{{ item.boostedBy.name or "Someone" }}{% endif %}</a>{% else %}{% if item.boostedBy.nameHtml %}{{ item.boostedBy.nameHtml | safe }}{% else %}{{ item.boostedBy.name or "Someone" }}{% endif %}{% endif %} {{ __("activitypub.reader.boosted") }}
<span aria-hidden="true">🔁</span><span class="visually-hidden">{{ __("activitypub.reader.boosted") }}</span> {% if item.boostedBy.url %}<a href="{{ mountPath }}/admin/reader/profile?url={{ item.boostedBy.url | urlencode }}">{% if item.boostedBy.nameHtml %}{{ item.boostedBy.nameHtml | safe }}{% else %}{{ item.boostedBy.name or "Someone" }}{% endif %}</a>{% else %}{% if item.boostedBy.nameHtml %}{{ item.boostedBy.nameHtml | safe }}{% else %}{{ item.boostedBy.name or "Someone" }}{% endif %}{% endif %} {{ __("activitypub.reader.boosted") }}
</div>
{% endif %}
{# Reply context if this is a reply #}
{% if item.inReplyTo %}
<div class="ap-card__reply-to">
{{ __("activitypub.reader.replyingTo") }} <a href="{{ mountPath }}/admin/reader/post?url={{ item.inReplyTo | urlencode }}">{{ item.inReplyTo }}</a>
<span aria-hidden="true">↩</span> {{ __("activitypub.reader.replyingTo") }} <a href="{{ mountPath }}/admin/reader/post?url={{ item.inReplyTo | urlencode }}">{{ item.inReplyTo }}</a>
</div>
{% endif %}
@@ -63,10 +63,10 @@
<time datetime="{{ item.published }}" class="ap-card__timestamp" x-data x-relative-time>
{{ item.published | date("PPp") }}
</time>
{% if item.updated %}<span class="ap-card__edited" title="{{ item.updated | date('PPp') }}">✏️</span>{% endif %}
{% if item.updated %}<span class="ap-card__edited" title="{{ item.updated | date('PPp') }}" aria-label="Edited"><span aria-hidden="true">✏️</span></span>{% endif %}
</a>
{% if item.visibility and item.visibility != "public" %}
<span class="ap-card__visibility ap-card__visibility--{{ item.visibility }}" title="{% if item.visibility == 'unlisted' %}{{ __('activitypub.reader.compose.visibilityUnlisted') }}{% elif item.visibility == 'private' %}{{ __('activitypub.reader.compose.visibilityFollowers') }}{% elif item.visibility == 'direct' %}DM{% endif %}">{% if item.visibility == "unlisted" %}🔓{% elif item.visibility == "private" %}🔒{% elif item.visibility == "direct" %}✉️{% endif %}</span>
<span class="ap-card__visibility ap-card__visibility--{{ item.visibility }}" title="{% if item.visibility == 'unlisted' %}{{ __('activitypub.reader.compose.visibilityUnlisted') }}{% elif item.visibility == 'private' %}{{ __('activitypub.reader.compose.visibilityFollowers') }}{% elif item.visibility == 'direct' %}DM{% endif %}" aria-label="{% if item.visibility == 'unlisted' %}{{ __('activitypub.reader.compose.visibilityUnlisted') }}{% elif item.visibility == 'private' %}{{ __('activitypub.reader.compose.visibilityFollowers') }}{% elif item.visibility == 'direct' %}DM{% endif %}"><span aria-hidden="true">{% if item.visibility == "unlisted" %}🔓{% elif item.visibility == "private" %}🔒{% elif item.visibility == "direct" %}✉️{% endif %}</span></span>
{% endif %}
{% endif %}
</header>
@@ -89,48 +89,11 @@
<span x-show="shown" x-cloak>{{ __("activitypub.reader.hideContent") }}</span>
</button>
<div x-show="shown" x-cloak>
{% if item.content and item.content.html %}
<div class="ap-card__content">
{{ item.content.html | safe }}
</div>
{% endif %}
{# Quoted post embed #}
{% include "partials/ap-quote-embed.njk" %}
{# Link previews #}
{% include "partials/ap-link-preview.njk" %}
{# Media hidden behind CW #}
{% include "partials/ap-item-media.njk" %}
{# Poll options #}
{% if item.type == "question" or (item.pollOptions and item.pollOptions.length > 0) %}
{% include "partials/ap-poll-options.njk" %}
{% endif %}
{% include "partials/ap-item-content.njk" %}
</div>
</div>
{% else %}
{# Regular content (no CW) #}
{% if item.content and item.content.html %}
<div class="ap-card__content">
{{ item.content.html | safe }}
</div>
{% endif %}
{# Quoted post embed #}
{% include "partials/ap-quote-embed.njk" %}
{# Link previews #}
{% include "partials/ap-link-preview.njk" %}
{# Media visible directly #}
{% include "partials/ap-item-media.njk" %}
{# Poll options #}
{% if item.type == "question" or (item.pollOptions and item.pollOptions.length > 0) %}
{% include "partials/ap-poll-options.njk" %}
{% endif %}
{% include "partials/ap-item-content.njk" %}
{% endif %}
{# Mentions and hashtags #}
@@ -171,77 +134,11 @@
data-item-url="{{ itemUrl }}"
data-csrf-token="{{ csrfToken }}"
data-mount-path="{{ mountPath }}"
x-data="{
liked: {{ 'true' if isLiked else 'false' }},
boosted: {{ 'true' if isBoosted else 'false' }},
saved: false,
loading: false,
error: '',
boostCount: {{ boostCount if boostCount != null else 'null' }},
likeCount: {{ likeCount if likeCount != null else 'null' }},
async saveLater() {
if (this.saved) return;
const el = this.$root;
const itemUrl = el.dataset.itemUrl;
try {
const res = await fetch('/readlater/save', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
url: itemUrl,
title: el.closest('article')?.querySelector('p')?.textContent?.substring(0, 80) || itemUrl,
source: 'activitypub'
}),
credentials: 'same-origin'
});
if (res.ok) this.saved = true;
else this.error = 'Failed to save';
} catch (e) {
this.error = e.message;
}
if (this.error) setTimeout(() => this.error = '', 3000);
},
async interact(action) {
if (this.loading) return;
this.loading = true;
this.error = '';
const el = this.$root;
const itemUid = el.dataset.itemUid;
const csrfToken = el.dataset.csrfToken;
const basePath = el.dataset.mountPath;
const prev = { liked: this.liked, boosted: this.boosted, boostCount: this.boostCount, likeCount: this.likeCount };
if (action === 'like') { this.liked = true; if (this.likeCount !== null) this.likeCount++; }
else if (action === 'unlike') { this.liked = false; if (this.likeCount !== null && this.likeCount > 0) this.likeCount--; }
else if (action === 'boost') { this.boosted = true; if (this.boostCount !== null) this.boostCount++; }
else if (action === 'unboost') { this.boosted = false; if (this.boostCount !== null && this.boostCount > 0) this.boostCount--; }
try {
const res = await fetch(basePath + '/admin/reader/' + action, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': csrfToken
},
body: JSON.stringify({ url: itemUid })
});
const data = await res.json();
if (!data.success) {
this.liked = prev.liked;
this.boosted = prev.boosted;
this.boostCount = prev.boostCount;
this.likeCount = prev.likeCount;
this.error = data.error || 'Failed';
}
} catch (e) {
this.liked = prev.liked;
this.boosted = prev.boosted;
this.boostCount = prev.boostCount;
this.likeCount = prev.likeCount;
this.error = e.message;
}
this.loading = false;
if (this.error) setTimeout(() => this.error = '', 3000);
}
}">
data-liked="{{ 'true' if isLiked else 'false' }}"
data-boosted="{{ 'true' if isBoosted else 'false' }}"
data-like-count="{{ likeCount if likeCount != null else '' }}"
data-boost-count="{{ boostCount if boostCount != null else '' }}"
x-data="apCardInteraction()">
<a href="{{ mountPath }}/admin/reader/compose?replyTo={{ (itemUrl or itemUid) | urlencode }}"
class="ap-card__action ap-card__action--reply"
title="{{ __('activitypub.reader.actions.reply') }}">
@@ -252,7 +149,7 @@
:title="boosted ? '{{ __('activitypub.reader.actions.unboost') }}' : '{{ __('activitypub.reader.actions.boost') }}'"
:disabled="loading"
@click="interact(boosted ? 'unboost' : 'boost')">
🔁 <span x-text="boosted ? '{{ __('activitypub.reader.actions.boosted') }}' : '{{ __('activitypub.reader.actions.boost') }}'"></span><template x-if="boostCount !== null"><span class="ap-card__count" x-text="boostCount"></span></template>
<span aria-hidden="true">🔁</span> <span x-text="boosted ? '{{ __('activitypub.reader.actions.boosted') }}' : '{{ __('activitypub.reader.actions.boost') }}'"></span><template x-if="boostCount !== null"><span class="ap-card__count" x-text="boostCount"></span></template>
</button>
<button class="ap-card__action ap-card__action--like"
:class="{ 'ap-card__action--active': liked }"
@@ -263,7 +160,7 @@
<span x-text="liked ? '{{ __('activitypub.reader.actions.liked') }}' : '{{ __('activitypub.reader.actions.like') }}'"></span><template x-if="likeCount !== null"><span class="ap-card__count" x-text="likeCount"></span></template>
</button>
<a href="{{ itemUrl }}" class="ap-card__action ap-card__action--link" target="_blank" rel="noopener">
🔗 {{ __("activitypub.reader.actions.viewOriginal") }}
<span aria-hidden="true">🔗</span> {{ __("activitypub.reader.actions.viewOriginal") }}
</a>
{% if application.readlaterEndpoint %}
<button class="ap-card__action ap-card__action--save"
@@ -275,7 +172,7 @@
<span x-text="saved ? 'Saved' : 'Save'"></span>
</button>
{% endif %}
<div x-show="error" x-text="error" class="ap-card__action-error" x-transition></div>
<div x-show="error" x-text="error" class="ap-card__action-error" role="alert" x-transition></div>
</footer>
{# Close moderation content warning wrapper #}
{% if item._moderated %}
+20
View File
@@ -0,0 +1,20 @@
{# Shared content rendering — included in both CW and non-CW paths #}
{% if item.content and item.content.html %}
<div class="ap-card__content">
{{ item.content.html | safe }}
</div>
{% endif %}
{# Quoted post embed #}
{% include "partials/ap-quote-embed.njk" %}
{# Link previews #}
{% include "partials/ap-link-preview.njk" %}
{# Media attachments #}
{% include "partials/ap-item-media.njk" %}
{# Poll options #}
{% if item.type == "question" or (item.pollOptions and item.pollOptions.length > 0) %}
{% include "partials/ap-poll-options.njk" %}
{% endif %}
+1 -1
View File
@@ -14,7 +14,7 @@
<img src="{{ item.actorPhoto }}" alt="{{ item.actorName }}" class="ap-notification__avatar" loading="lazy" crossorigin="anonymous">
{% endif %}
<span class="ap-notification__avatar ap-notification__avatar--default" aria-hidden="true">{{ item.actorName[0] | upper if item.actorName else "?" }}</span>
<span class="ap-notification__type-badge">
<span class="ap-notification__type-badge" aria-hidden="true">
{% if item.type == "like" %}❤{% elif item.type == "boost" %}🔁{% elif item.type == "follow" or item.type == "follow_request" %}👤{% elif item.type == "reply" %}💬{% elif item.type == "mention" %}{% if item.isDirect %}🔒{% else %}@{% endif %}{% elif item.type == "dm" %}✉{% elif item.type == "report" %}⚑{% endif %}
</span>
</div>