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:
@@ -1 +1,3 @@
|
||||
node_modules
|
||||
.playwright-cli/
|
||||
.playwright-mcp/
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Vendored
+74
@@ -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);
|
||||
}
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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), {
|
||||
|
||||
@@ -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(() => {});
|
||||
}
|
||||
@@ -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}`);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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, "&")
|
||||
.replace(/"/g, """)
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">");
|
||||
}
|
||||
|
||||
/**
|
||||
* 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();
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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(() => {});
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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" });
|
||||
});
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
/**
|
||||
* 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>
|
||||
|
||||
@@ -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
@@ -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, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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
@@ -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, "&").replace(/"/g, """).replace(/</g, "<").replace(/>/g, ">");
|
||||
const safeShortcode = shortcode.replace(/&/g, "&").replace(/"/g, """).replace(/</g, "<").replace(/>/g, ">");
|
||||
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.
|
||||
|
||||
Generated
+170
-91
@@ -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
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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" %}
|
||||
|
||||
@@ -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
@@ -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 %}
|
||||
|
||||
@@ -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 %}
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user