feat: ActivityPub reader — timeline, notifications, compose, moderation

Add a dedicated fediverse reader view with:
- Timeline view showing posts from followed accounts with threading,
  content warnings, boosts, and media display
- Compose form with dual-path posting (quick AP reply + Micropub blog post)
- Native AP interactions (like, boost, reply, follow/unfollow)
- Notifications view for likes, boosts, follows, mentions, replies
- Moderation tools (mute/block actors, keyword filters)
- Remote actor profile pages with follow state
- Automatic timeline cleanup with configurable retention
- CSRF protection, XSS prevention, input validation throughout

Removes Microsub bridge dependency — AP content now lives in its own
MongoDB collections (ap_timeline, ap_notifications, ap_interactions,
ap_muted, ap_blocked).

Bumps version to 1.1.0.
This commit is contained in:
Ricardo
2026-02-21 12:13:10 +01:00
parent 81a28ef086
commit 4e514235c2
29 changed files with 5402 additions and 230 deletions

884
assets/reader.css Normal file
View File

@@ -0,0 +1,884 @@
/**
* ActivityPub Reader Styles
* Card-based layout inspired by Phanpy/Elk
* Uses Indiekit CSS custom properties
*/
/* ==========================================================================
Tab Navigation
========================================================================== */
.ap-tabs {
border-bottom: 1px solid var(--color-offset);
display: flex;
gap: var(--space-xs);
margin-bottom: var(--space-m);
overflow-x: auto;
-webkit-overflow-scrolling: touch;
}
.ap-tab {
border-bottom: 2px solid transparent;
color: var(--color-text-muted);
font-size: var(--font-size-body);
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-text);
}
.ap-tab--active {
border-bottom-color: var(--color-primary);
color: var(--color-primary);
font-weight: 600;
}
/* ==========================================================================
Timeline Layout
========================================================================== */
.ap-timeline {
display: flex;
flex-direction: column;
gap: var(--space-m);
}
/* ==========================================================================
Item Card
========================================================================== */
.ap-card {
background: var(--color-background);
border: 1px solid var(--color-offset);
border-radius: var(--border-radius);
overflow: hidden;
padding: var(--space-m);
transition:
box-shadow 0.2s ease,
border-color 0.2s ease;
}
.ap-card:hover {
border-color: var(--color-offset-active);
}
/* Boost header */
.ap-card__boost {
color: var(--color-text-muted);
font-size: var(--font-size-small);
margin-bottom: var(--space-s);
padding-bottom: var(--space-xs);
}
.ap-card__boost a {
color: var(--color-text-muted);
font-weight: 600;
text-decoration: none;
}
.ap-card__boost a:hover {
color: var(--color-text);
text-decoration: underline;
}
/* Reply context */
.ap-card__reply-to {
color: var(--color-text-muted);
font-size: var(--font-size-small);
margin-bottom: var(--space-s);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.ap-card__reply-to a {
color: var(--color-primary);
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 {
border: 1px solid var(--color-offset);
border-radius: 50%;
flex-shrink: 0;
height: 40px;
object-fit: cover;
width: 40px;
}
.ap-card__author-info {
display: flex;
flex-direction: column;
flex: 1;
min-width: 0;
}
.ap-card__author-name {
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__author-handle {
color: var(--color-text-muted);
font-size: var(--font-size-small);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.ap-card__timestamp {
color: var(--color-text-muted);
flex-shrink: 0;
font-size: var(--font-size-small);
}
/* Post title (articles) */
.ap-card__title {
font-size: var(--font-size-heading-4);
font-weight: 600;
line-height: 1.3;
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-text);
line-height: 1.6;
margin-bottom: var(--space-s);
overflow-wrap: break-word;
word-break: break-word;
}
.ap-card__content a {
color: var(--color-primary);
}
.ap-card__content p {
margin-bottom: var(--space-xs);
}
.ap-card__content p:last-child {
margin-bottom: 0;
}
.ap-card__content blockquote {
border-left: 3px solid var(--color-offset);
margin: var(--space-s) 0;
padding-left: var(--space-m);
}
.ap-card__content pre {
background: var(--color-offset);
border-radius: var(--border-radius);
overflow-x: auto;
padding: var(--space-s);
}
.ap-card__content code {
background: var(--color-offset);
border-radius: 3px;
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);
height: auto;
max-width: 100%;
}
/* Content warning */
.ap-card__cw {
margin-bottom: var(--space-s);
}
.ap-card__cw-toggle {
background: var(--color-offset);
border: 1px solid var(--color-offset-active);
border-radius: var(--border-radius);
color: var(--color-text);
cursor: pointer;
display: block;
font-size: var(--font-size-small);
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-active);
}
/* Photo gallery */
.ap-card__gallery {
border-radius: var(--border-radius);
display: grid;
gap: 2px;
margin-bottom: var(--space-s);
overflow: hidden;
}
.ap-card__gallery-link {
display: block;
position: relative;
}
.ap-card__gallery img {
background: var(--color-offset);
display: block;
height: 200px;
object-fit: cover;
width: 100%;
}
.ap-card__gallery-link--more::after {
background: rgba(0, 0, 0, 0.5);
bottom: 0;
content: "";
left: 0;
position: absolute;
right: 0;
top: 0;
}
.ap-card__gallery-more {
color: #fff;
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: 400px;
}
/* 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;
}
/* Video embed */
.ap-card__video {
margin-bottom: var(--space-s);
}
.ap-card__video video {
border-radius: var(--border-radius);
max-height: 400px;
width: 100%;
}
/* Audio player */
.ap-card__audio {
margin-bottom: var(--space-s);
}
.ap-card__audio audio {
width: 100%;
}
/* 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);
border-radius: var(--border-radius);
color: var(--color-text-muted);
font-size: var(--font-size-small);
padding: 2px var(--space-xs);
text-decoration: none;
}
.ap-card__tag:hover {
background: var(--color-offset-active);
color: var(--color-text);
}
/* Interaction buttons */
.ap-card__actions {
border-top: 1px solid var(--color-offset);
display: flex;
flex-wrap: wrap;
gap: var(--space-s);
padding-top: var(--space-s);
}
.ap-card__action {
align-items: center;
background: transparent;
border: 1px solid var(--color-offset);
border-radius: var(--border-radius);
color: var(--color-text-muted);
cursor: pointer;
display: inline-flex;
font-size: var(--font-size-small);
gap: var(--space-xs);
padding: var(--space-xs) var(--space-s);
text-decoration: none;
transition: all 0.2s ease;
}
.ap-card__action:hover {
background: var(--color-offset);
border-color: var(--color-offset-active);
color: var(--color-text);
}
/* Active interaction states */
.ap-card__action--like.ap-card__action--active {
background: rgba(225, 29, 72, 0.1);
border-color: #e11d48;
color: #e11d48;
}
.ap-card__action--boost.ap-card__action--active {
background: rgba(22, 163, 74, 0.1);
border-color: #16a34a;
color: #16a34a;
}
.ap-card__action:disabled {
cursor: wait;
opacity: 0.6;
}
/* Error message */
.ap-card__action-error {
color: #e11d48;
font-size: var(--font-size-small);
width: 100%;
}
/* ==========================================================================
Pagination
========================================================================== */
.ap-pagination {
border-top: 1px solid var(--color-offset);
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);
text-decoration: none;
}
.ap-pagination a:hover {
text-decoration: underline;
}
/* ==========================================================================
Compose Form
========================================================================== */
.ap-compose__context {
background: var(--color-offset);
border-left: 3px solid var(--color-primary);
border-radius: var(--border-radius);
margin-bottom: var(--space-m);
padding: var(--space-m);
}
.ap-compose__context-label {
color: var(--color-text-muted);
font-size: var(--font-size-small);
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-small);
line-height: 1.5;
margin: var(--space-xs) 0;
padding: 0;
}
.ap-compose__context-link {
color: var(--color-text-muted);
font-size: var(--font-size-small);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.ap-compose__form {
display: flex;
flex-direction: column;
gap: var(--space-m);
}
.ap-compose__mode {
border: 1px solid var(--color-offset);
border-radius: var(--border-radius);
display: flex;
flex-direction: column;
gap: var(--space-s);
padding: var(--space-m);
}
.ap-compose__mode legend {
font-weight: 600;
}
.ap-compose__mode-option {
cursor: pointer;
display: flex;
flex-wrap: wrap;
gap: var(--space-xs);
}
.ap-compose__mode-hint {
color: var(--color-text-muted);
display: block;
font-size: var(--font-size-small);
margin-left: 1.5em;
width: 100%;
}
.ap-compose__editor {
position: relative;
}
.ap-compose__textarea {
border: 1px solid var(--color-offset-active);
border-radius: var(--border-radius);
font-family: inherit;
font-size: var(--font-size-body);
line-height: 1.6;
padding: var(--space-s);
resize: vertical;
width: 100%;
}
.ap-compose__textarea:focus {
border-color: var(--color-primary);
outline: 2px solid var(--color-primary);
outline-offset: -2px;
}
.ap-compose__counter {
font-size: var(--font-size-small);
padding-top: var(--space-xs);
text-align: right;
}
.ap-compose__counter--warn {
color: #d97706;
}
.ap-compose__counter--over {
color: #e11d48;
font-weight: 600;
}
.ap-compose__syndication {
border: 1px solid var(--color-offset);
border-radius: var(--border-radius);
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);
color: #fff;
cursor: pointer;
font-size: var(--font-size-body);
font-weight: 600;
padding: var(--space-s) var(--space-l);
}
.ap-compose__submit:hover {
opacity: 0.9;
}
.ap-compose__cancel {
color: var(--color-text-muted);
text-decoration: none;
}
.ap-compose__cancel:hover {
color: var(--color-text);
text-decoration: underline;
}
/* ==========================================================================
Notifications
========================================================================== */
.ap-notification {
align-items: flex-start;
background: var(--color-background);
border: 1px solid var(--color-offset);
border-radius: var(--border-radius);
display: flex;
gap: var(--space-s);
padding: var(--space-m);
}
.ap-notification--unread {
border-color: rgba(255, 204, 0, 0.5);
box-shadow: 0 0 8px 0 rgba(255, 204, 0, 0.3);
}
.ap-notification__icon {
flex-shrink: 0;
font-size: 1.5em;
}
.ap-notification__body {
flex: 1;
min-width: 0;
}
.ap-notification__actor {
font-weight: 600;
}
.ap-notification__action {
color: var(--color-text-muted);
}
.ap-notification__target {
color: var(--color-text-muted);
display: block;
font-size: var(--font-size-small);
margin-top: var(--space-xs);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.ap-notification__excerpt {
background: var(--color-offset);
border-radius: var(--border-radius);
font-size: var(--font-size-small);
margin-top: var(--space-xs);
padding: var(--space-xs) var(--space-s);
}
.ap-notification__time {
color: var(--color-text-muted);
flex-shrink: 0;
font-size: var(--font-size-small);
}
/* ==========================================================================
Remote Profile
========================================================================== */
.ap-profile__header {
border-radius: var(--border-radius);
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 {
margin-bottom: var(--space-s);
}
.ap-profile__avatar {
border: 3px 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);
color: var(--color-text-muted);
display: flex;
font-size: 2em;
font-weight: 600;
justify-content: center;
}
.ap-profile__name {
font-size: var(--font-size-heading-3);
margin-bottom: var(--space-xs);
}
.ap-profile__handle {
color: var(--color-text-muted);
margin-bottom: var(--space-s);
}
.ap-profile__bio {
line-height: 1.6;
margin-bottom: var(--space-s);
}
.ap-profile__bio a {
color: var(--color-primary);
}
.ap-profile__actions {
display: flex;
flex-wrap: wrap;
gap: var(--space-s);
margin-top: var(--space-m);
}
.ap-profile__action {
background: transparent;
border: 1px solid var(--color-offset-active);
border-radius: var(--border-radius);
color: var(--color-text);
cursor: pointer;
font-size: var(--font-size-small);
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: #fff;
}
.ap-profile__action--danger:hover {
border-color: #e11d48;
color: #e11d48;
}
.ap-profile__posts {
margin-top: var(--space-l);
}
.ap-profile__posts h3 {
border-bottom: 1px solid var(--color-offset);
font-size: var(--font-size-heading-4);
margin-bottom: var(--space-m);
padding-bottom: var(--space-s);
}
/* ==========================================================================
Moderation
========================================================================== */
.ap-moderation__section {
margin-bottom: var(--space-l);
}
.ap-moderation__section h2 {
font-size: var(--font-size-heading-4);
margin-bottom: var(--space-s);
}
.ap-moderation__list {
list-style: none;
margin: 0;
padding: 0;
}
.ap-moderation__entry {
align-items: center;
border-bottom: 1px solid var(--color-offset);
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: 1px solid var(--color-offset-active);
border-radius: var(--border-radius);
color: var(--color-text-muted);
cursor: pointer;
flex-shrink: 0;
font-size: var(--font-size-small);
padding: var(--space-xs) var(--space-s);
}
.ap-moderation__remove:hover {
border-color: #e11d48;
color: #e11d48;
}
.ap-moderation__add-form {
display: flex;
gap: var(--space-s);
}
.ap-moderation__input {
border: 1px solid var(--color-offset-active);
border-radius: var(--border-radius);
flex: 1;
font-size: var(--font-size-body);
padding: var(--space-xs) var(--space-s);
}
.ap-moderation__add-btn {
background: var(--color-offset);
border: 1px solid var(--color-offset-active);
border-radius: var(--border-radius);
cursor: pointer;
font-size: var(--font-size-body);
padding: var(--space-xs) var(--space-m);
}
.ap-moderation__add-btn:hover {
background: var(--color-offset-active);
}
/* ==========================================================================
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);
}
}

View File

@@ -0,0 +1,882 @@
# ActivityPub Reader Implementation Plan
Created: 2026-02-21
Status: VERIFIED
Approved: Yes
Iterations: 0
Worktree: No
> **Status Lifecycle:** PENDING → COMPLETE → VERIFIED
> **Iterations:** Tracks implement→verify cycles (incremented by verify phase)
>
> - PENDING: Initial state, awaiting implementation
> - COMPLETE: All tasks implemented
> - VERIFIED: All checks passed
>
> **Approval Gate:** Implementation CANNOT proceed until `Approved: Yes`
> **Worktree:** Set at plan creation (from dispatcher). `Yes` uses git worktree isolation; `No` works directly on current branch
## Summary
**Goal:** Build a dedicated ActivityPub reader within the `@rmdes/indiekit-endpoint-activitypub` plugin, providing a timeline view of followed accounts' posts, a notifications stream, native AP interactions (like, boost, reply, follow/unfollow), and Micropub-based content creation — then remove the Microsub bridge dependency.
**Architecture:** The reader adds new MongoDB collections (`ap_timeline`, `ap_notifications`, `ap_muted`, `ap_blocked`) alongside new controllers, views, and a CSS stylesheet. Inbox listeners are refactored to store items natively instead of bridging to Microsub. Alpine.js provides client-side reactivity for interactions. Content creation uses two paths: direct Fedify `ctx.sendActivity()` for quick likes/boosts, and Micropub POST for replies that become blog posts (user chooses per-reply).
**Tech Stack:** Node.js/Express, MongoDB, Nunjucks templates, Alpine.js, Fedify SDK (`ctx.sendActivity()`, `ctx.lookupObject()`), Indiekit frontend components, CSS custom properties.
## Scope
### In Scope
- Timeline view showing posts from followed accounts with threading, content warnings, boosts, and rich media (images, video, audio, polls)
- Tab-based filtering (All, Notes, Articles, Replies, Boosts, Media)
- Notifications stream (likes, boosts, follows, mentions, replies received)
- Native AP interactions: like, boost, reply (with choice of direct AP or Micropub), follow/unfollow
- Mute/unmute (accounts and keywords), block/unblock
- Profile view for remote actors (view posts, follow/unfollow, mute, block)
- Compose form that submits via Micropub endpoint (for blog-worthy replies)
- Custom CSS stylesheet with card-based layout inspired by Phanpy/Elk
- Content warning spoiler toggle (Alpine.js)
- Image gallery grid for multi-image posts
- Video/audio embed rendering
- Removal of Microsub bridge (`storeTimelineItem`, `getApChannelId`, lazy `microsub_items`/`microsub_channels` accessors)
### Out of Scope
- Mastodon REST API compatibility (no mobile client support — would be a separate project)
- Lists (organizing follows into named groups) — deferred to future plan
- Local/Federated timeline distinction (single timeline of followed accounts only)
- Full-text search within timeline items
- Polls (rendering existing polls is in scope; creating polls is not)
- Direct messages / conversations
- Push notifications (browser notifications)
- Infinite scroll (standard pagination is used)
- Video/audio upload in compose form
## Prerequisites
- Plugin is at v1.0.29+ with all federation hardening features complete
- Fedify SDK available via `this._federation` on the plugin instance
- MongoDB collections infrastructure in `index.js`
- Indiekit frontend components available (`@indiekit/frontend`)
- Alpine.js: **NOT loaded by Indiekit core**. The reader layout must explicitly load Alpine.js via a `<script>` CDN tag (e.g., `<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3/dist/cdn.min.js"></script>`). The existing AP dashboard views use `x-data` directives — they work because the Cloudron deployment's CSP allows `cdn.jsdelivr.net` (see `nginx.conf`). The reader layout template must include Alpine.js in its `<head>` block.
- `sanitize-html` package (add to `package.json` dependencies — used by Microsub plugin already, needed here for XSS prevention on remote content)
## Context for Implementer
> This section is critical for cross-session continuity. Write it for an implementer who has never seen the codebase.
- **Patterns to follow:**
- Route registration: See `index.js:143-169` — admin routes go in `get routes()` method, registered at `/admin/activitypub/*`
- Controller pattern: Each controller exports async functions taking `(request, response)`. See `lib/controllers/dashboard.js` as example
- View pattern: Views are `activitypub-*.njk` files in `views/`. They extend `document.njk` and use Indiekit frontend component macros (`card`, `button`, `badge`, `pagination`, etc.)
- Collection registration: See `index.js:614-621` — register via `Indiekit.addCollection("name")` calls in `init()`, then store references via `this._collections.name = indiekitCollections.get("name")`
- i18n: All user-visible strings go in `locales/en.json` under the `activitypub` namespace, referenced via `__("activitypub.reader.xxx")`
- Asset serving: Place CSS/JS in `assets/` directory. Indiekit core serves at `/assets/@rmdes-indiekit-endpoint-activitypub/`. Reference from views with `<link>` tag.
- **Conventions:**
- ESM modules throughout (`import`/`export`)
- ISO 8601 strings for dates in MongoDB (except `published` in timeline items which uses `Date` for sorting queries)
- Nunjucks templates use `{% from "xxx.njk" import component %}` for Indiekit frontend components
- Alpine.js `x-data`, `x-show`, `x-on:click` for client-side interactivity (loaded explicitly in reader layout, NOT by Indiekit core)
- CSRF protection: Indiekit core has no CSRF middleware. POST endpoints that trigger ActivityPub activities must validate a CSRF token. Use a simple pattern: generate a token per-session and embed as a hidden field in forms / include in `fetch()` headers. Validate on the server side before processing.
- **Key files:**
- `index.js` — Plugin entry point, routes, collections, syndicator, follow/unfollow methods
- `lib/inbox-listeners.js` — All inbox activity handlers (Follow, Like, Announce, Create, Delete, etc.)
- `lib/federation-setup.js` — Fedify federation object configuration (dispatchers, queue, etc.)
- `locales/en.json` — English translations
- `views/activitypub-dashboard.njk` — Dashboard view (reference for card-grid patterns)
- `views/activitypub-following.njk` — Following view (reference for list+pagination)
- **Gotchas:**
- Fedify returns `Temporal.Instant` for dates, not JS `Date`. Convert with `new Date(Number(obj.published.epochMilliseconds))`
- Fedify object properties are often async getters — `await actorObj.icon` not `actorObj.icon`
- `ctx.sendActivity()` first argument is `{ identifier: handle }` where `handle` comes from plugin options
- The plugin stores `this._federation` and creates context via `this._federation.createContext(new URL(this._publicationUrl), { handle, publicationUrl })`
- Remote actor lookup uses `ctx.lookupObject("@handle@instance")` or `ctx.lookupObject("https://url")`
- The AP plugin's asset directory is `assets/` at the package root, served at `/assets/@rmdes-indiekit-endpoint-activitypub/`
- **Domain context:**
- ActivityPub activities: `Like` (favorite), `Announce` (boost/repost), `Create` (new post), `Follow`/`Undo(Follow)`, `Accept`, `Reject`, `Delete`, `Update`, `Block`, `Move`
- Content warnings use the `summary` field on AP objects (Mastodon convention)
- Boosts are `Announce` activities wrapping the original post — the reader must render the original post with boost attribution
- Replies use `inReplyTo` linking to the parent post URL
- Sensitive content uses the `sensitive` boolean on AP objects
## Runtime Environment
- **Start command:** `cloudron exec --app rmendes.net` or locally `npm start` in the Cloudron container
- **Port:** Indiekit on 8080 (behind nginx on 3000)
- **Health check:** `curl https://rmendes.net/.well-known/webfinger?resource=acct:rick@rmendes.net`
- **Deploy:** Build via `cloudron build --no-cache && cloudron update --app rmendes.net --no-backup`
## Feature Inventory — Microsub Bridge Being Replaced
### Files Being Modified (Bridge Removal)
| Old Code | Functions | Mapped to Task |
|----------|-----------|----------------|
| `lib/inbox-listeners.js` — function `storeTimelineItem()` (~line 468) | Timeline item storage from AP activities | Task 2 (store natively), Task 12 (remove bridge) |
| `lib/inbox-listeners.js` — function `getApChannelId()` (~line 413) | Auto-creates Microsub "Fediverse" channel | Task 12 (remove) |
| `index.js` — lazy accessors in `init()` (~line 638) | `microsub_items`, `microsub_channels` collection refs | Task 12 (remove) |
| `lib/inbox-listeners.js` — Create handler (~line 262, calls `storeTimelineItem` at ~line 310) | Stores incoming posts via bridge | Task 2 (redirect to native storage) |
### Feature Mapping Verification
- [x] `storeTimelineItem()` → Task 2 (native `ap_timeline` storage)
- [x] `getApChannelId()` → Task 12 (removed; no longer needed)
- [x] Lazy Microsub collection accessors → Task 12 (removed)
- [x] Inbox Create handler → Task 2 (rewired to native storage)
- [x] Like/Announce inbox storage → Task 3 (notification storage)
## Progress Tracking
**MANDATORY: Update this checklist as tasks complete. Change `[ ]` to `[x]`.**
- [x] Task 1: MongoDB collections and data models
- [x] Task 2: Inbox listener refactor — native timeline storage (includes Delete/Update handling)
- [x] Task 3: Inbox listener refactor — notification storage
- [x] Task 4: Timeline controller and view
- [x] Task 5: Reader CSS stylesheet
- [x] Task 6: Notifications controller and view
- [x] Task 7a: Interaction API — Like and Boost endpoints (with CSRF)
- [x] Task 7b: Interaction UI — Like and Boost buttons (Alpine.js)
- [x] Task 8: Compose form — Micropub reply path
- [x] Task 9: Content warning toggles and rich media rendering
- [x] Task 10: Mute, block, and tab filtering
- [x] Task 11: Remote profile view
- [x] Task 12: Remove Microsub bridge
- [x] Task 13: Timeline retention cleanup
**Total Tasks:** 14 | **Completed:** 14 | **Remaining:** 0
## Implementation Tasks
### Task 1: MongoDB Collections and Data Models
**Objective:** Register new MongoDB collections (`ap_timeline`, `ap_notifications`, `ap_muted`, `ap_blocked`, `ap_interactions`) and create indexes for efficient querying.
**Dependencies:** None
**Files:**
- Modify: `index.js` — Register collections via `Indiekit.addCollection()` in `init()`, store references in `this._collections`, create indexes
- Create: `lib/storage/timeline.js` — Timeline CRUD functions
- Create: `lib/storage/notifications.js` — Notification CRUD functions
- Create: `lib/storage/moderation.js` — Mute/block CRUD functions
**Key Decisions / Notes:**
- `ap_timeline` schema:
```js
{
uid: "https://remote.example/posts/123", // canonical AP object URL (dedup key)
type: "note" | "article" | "boost", // boost = Announce wrapper
url: "https://remote.example/posts/123",
name: "Post Title" | null, // Articles only
content: { text: "...", html: "..." },
summary: "Content warning text" | null, // CW / spoiler
sensitive: false, // Mastodon sensitive flag
published: Date, // Date object for sort queries
author: { name, url, photo, handle }, // handle = "@user@instance"
category: ["tag1", "tag2"],
photo: ["url1", "url2"],
video: ["url1"],
audio: ["url1"],
inReplyTo: "https://parent-post-url" | null,
boostedBy: { name, url, photo, handle } | null, // For Announce activities
boostedAt: Date | null, // When the boost happened
originalUrl: "https://original-post-url" | null, // For boosts: the wrapped object URL
readBy: [],
createdAt: "ISO string"
}
```
- `ap_notifications` schema:
```js
{
uid: "activity-id", // dedup key
type: "like" | "boost" | "follow" | "mention" | "reply",
actorUrl: "https://remote.example/@user",
actorName: "Display Name",
actorPhoto: "https://...",
actorHandle: "@user@instance",
targetUrl: "https://my-post-url" | null, // The post they liked/boosted/replied to
targetName: "My Post Title" | null,
content: { text: "...", html: "..." } | null, // For mentions/replies
published: Date,
read: false,
createdAt: "ISO string"
}
```
- `ap_muted`: `{ url: "actor-url", keyword: null, mutedAt: "ISO" }` — url OR keyword, not both
- `ap_blocked`: `{ url: "actor-url", blockedAt: "ISO" }`
- `ap_interactions`: `{ type: "like"|"boost", objectUrl: "https://...", activityId: "urn:uuid:...", createdAt: "ISO" }` — tracks outgoing interactions for undo support and UI state
- Indexes:
- `ap_timeline`: `{ uid: 1 }` unique, `{ published: -1 }` for timeline sort, `{ "author.url": 1 }` for profile view, `{ type: 1, published: -1 }` for tab filtering
- `ap_notifications`: `{ uid: 1 }` unique, `{ published: -1 }` for sort, `{ read: 1 }` for unread count
- `ap_muted`: `{ url: 1 }` unique (sparse), `{ keyword: 1 }` unique (sparse)
- `ap_blocked`: `{ url: 1 }` unique
- `ap_interactions`: `{ objectUrl: 1, type: 1 }` compound unique (one like/boost per object), `{ type: 1 }` for listing
- Storage functions follow the pattern in Microsub's `lib/storage/items.js` — export pure functions that take `(collections, ...)` parameters
- `addTimelineItem(collections, item)` uses atomic upsert: `updateOne({ uid }, { $setOnInsert: item }, { upsert: true })`
- `getTimelineItems(collections, { before, after, limit, type, authorUrl })` returns cursor-paginated results
- `addNotification(collections, notification)` uses atomic upsert
- `getNotifications(collections, { before, limit })` returns paginated, newest-first
- `getUnreadNotificationCount(collections)` returns count of `{ read: false }`
**Definition of Done:**
- [ ] All five collections registered via `Indiekit.addCollection()` in `init()` (ap_timeline, ap_notifications, ap_muted, ap_blocked, ap_interactions)
- [ ] Indexes created in `init()` method
- [ ] `addTimelineItem` stores item and deduplicates by uid
- [ ] `getTimelineItems` returns paginated results with before/after cursors
- [ ] `addNotification` stores notification and deduplicates
- [ ] `getNotifications` returns paginated newest-first
- [ ] `getUnreadNotificationCount` returns correct count
- [ ] Mute/block CRUD operations work (add, remove, list, check)
- [ ] All storage functions have unit tests
**Verify:**
- `cd /home/rick/code/indiekit-dev/indiekit-endpoint-activitypub && node -e "import('./lib/storage/timeline.js').then(m => console.log(Object.keys(m)))"` — exports exist
- `cd /home/rick/code/indiekit-dev/indiekit-endpoint-activitypub && node -e "import('./lib/storage/notifications.js').then(m => console.log(Object.keys(m)))"` — exports exist
- `cd /home/rick/code/indiekit-dev/indiekit-endpoint-activitypub && node -e "import('./lib/storage/moderation.js').then(m => console.log(Object.keys(m)))"` — exports exist
---
### Task 2: Inbox Listener Refactor — Native Timeline Storage
**Objective:** Modify the inbox Create handler to store posts in `ap_timeline` instead of bridging to Microsub. Also handle Announce (boost) activities by storing the wrapped object with boost attribution.
**Dependencies:** Task 1
**Files:**
- Modify: `lib/inbox-listeners.js` — Refactor Create handler (~line 262) and Announce handler (~line 233) to store in `ap_timeline`, plus Delete/Update handlers for timeline cleanup
- Modify: `package.json` — Add `sanitize-html` to dependencies
- Create: `lib/timeline-store.js` — Helper that extracts data from Fedify objects and calls storage functions
**Key Decisions / Notes:**
- The existing Create handler at `inbox-listeners.js` (function `registerInboxListeners`, Create section ~line 262) currently calls `storeTimelineItem()`. Replace that call with the new native storage
- **CRITICAL — Announce handler bifurcation required:** The current Announce handler (line ~237) has an early return that ONLY processes boosts of our own content: `if (!objectId || (pubUrl && !objectId.startsWith(pubUrl))) return;`. This filter MUST be modified to create two code paths:
1. **Boost of our content** (objectId starts with pubUrl) → store as notification (Task 3)
2. **Boost from a followed account** (announcing actor is in our followers/following) → store in `ap_timeline` with `type: "boost"`
3. **Both conditions true** (a followed account boosts our post) → store BOTH notification AND timeline item
- For timeline boosts: fetch the wrapped object via `await announce.getObject()` (the current handler only reads `announce.objectId` URL, NOT the full object), extract its data, then store with `type: "boost"` and `boostedBy` populated from the announcing actor
- To check if the announcing actor is followed: query `ap_followers` or `ap_following` collection for the actor URL
- Keep the same Fedify object→data extraction logic from `storeTimelineItem` (content, photos, videos, tags, etc.) but move it to a reusable `extractObjectData(object, actorObj)` function in `lib/timeline-store.js`
- **CRITICAL: HTML sanitization** — Remote content HTML MUST be sanitized before storage using `sanitize-html` (same library used in Microsub's `lib/webmention/verifier.js`). Allow safe tags: `a`, `p`, `br`, `em`, `strong`, `blockquote`, `ul`, `ol`, `li`, `code`, `pre`, `span`, `h1`-`h6`, `img`. Allow `href` on `a`, `src`/`alt` on `img`, `class` on `span` (for Mastodon custom emoji). Strip all other HTML including `<script>`, `<style>`, event handlers. This prevents XSS when rendering content with Nunjucks `| safe` filter
- Check muted/blocked before storing — skip items from muted URLs or containing muted keywords
- The existing `storeTimelineItem()` and `getApChannelId()` functions remain for now (cleaned up in Task 12)
- For replies (`inReplyTo`), store the parent URL so the frontend can render threading context
- **Delete activity handling:** Modify the existing Delete handler (`inbox-listeners.js` ~line 318) to also remove items from `ap_timeline` (currently only deletes from `ap_activities`). When a remote user deletes a post, remove the corresponding `ap_timeline` entry by uid.
- **Update activity handling:** Modify the existing Update handler (`inbox-listeners.js` ~line 345) to also update `ap_timeline` items. Currently it only refreshes follower/actor profile data. When a remote user edits a post (Update activity), re-extract the content and update the timeline item. This prevents showing stale content for edited posts.
**Definition of Done:**
- [ ] Create activities from followed accounts stored in `ap_timeline` with all fields populated
- [ ] Announce (boost) activities stored with `type: "boost"`, `boostedBy`, and the original post content
- [ ] Muted actors' posts are skipped during storage
- [ ] Blocked actors' posts are skipped during storage
- [ ] Posts containing muted keywords are skipped
- [ ] Duplicate posts (same uid) are not created
- [ ] Remote HTML content sanitized before storage (no `<script>`, `<style>`, event handlers)
- [ ] Delete activities remove corresponding items from `ap_timeline`
- [ ] Update activities refresh content of existing `ap_timeline` items
- [ ] Tests verify Create → timeline storage flow
- [ ] Tests verify Announce → timeline storage with boost attribution
- [ ] Tests verify Delete → timeline item removal
- [ ] Tests verify Update → timeline item content refresh
**Verify:**
- Integration test: Send a mock Create activity, verify it appears in `ap_timeline` collection
- Integration test: Send a mock Announce activity, verify boost attribution stored correctly
---
### Task 3: Inbox Listener Refactor — Notification Storage
**Objective:** Store incoming Like, Announce (of our posts), Follow, and mention/reply activities as notifications in `ap_notifications`.
**Dependencies:** Task 1
**Files:**
- Modify: `lib/inbox-listeners.js` — Add notification storage calls in Like handler (`activity instanceof Like`), Announce handler (`activity instanceof Announce`), Follow handler (`activity instanceof Follow`), Create handler for mentions/replies
**Key Decisions / Notes:**
- **Like handler** (in `registerInboxListeners`, search for `activity instanceof Like`): already logs to `ap_activities` and filters to only likes of our own posts. This filter is correct for notifications. Add a call to `addNotification()` with `type: "like"`, including the actor info and the liked post URL
- **Announce handler** (search for `activity instanceof Announce`): the dual-path logic from Task 2 handles timeline storage. For notifications, when someone boosts OUR post (objectId starts with pubUrl), store as notification `type: "boost"`
- Follow handler: store as notification `type: "follow"` when someone new follows us
- Create handler: if the post is a reply TO one of our posts (check `inReplyTo` against our publication URL), store as `type: "reply"`; if it mentions us (check tags for Mention with our actor URL), store as `type: "mention"`
- Notification dedup by activity ID or constructed uid (e.g., `like:${actorUrl}:${objectUrl}`)
- Extract actor info (name, photo, handle) from Fedify actor object — use same `extractActorInfo()` helper
**Definition of Done:**
- [ ] Likes of our posts create notification with type "like"
- [ ] Boosts of our posts create notification with type "boost"
- [ ] New follows create notification with type "follow"
- [ ] Replies to our posts create notification with type "reply"
- [ ] Mentions of our actor create notification with type "mention"
- [ ] Notifications are deduplicated by uid
- [ ] All notification types include correct actor info and target post info
- [ ] Tests verify each notification type is stored correctly
**Verify:**
- Unit tests for notification storage from each activity type
- Verify on live site: receive a like → check `ap_notifications` collection via MongoDB query
---
### Task 4: Timeline Controller and View
**Objective:** Create the reader timeline page at `/admin/activitypub/reader` showing posts from followed accounts with pagination, and a reader navigation sidebar.
**Dependencies:** Task 1, Task 2
**Files:**
- Create: `lib/controllers/reader.js` — Timeline controller
- Create: `views/layouts/reader.njk` — Reader layout (extends `document.njk`, adds Alpine.js CDN `<script>` tag and reader stylesheet `<link>`)
- Create: `views/activitypub-reader.njk` — Timeline view (extends `layouts/reader.njk`)
- Create: `views/partials/ap-item-card.njk` — Timeline item card partial
- Modify: `index.js` — Add reader routes and navigation item
- Modify: `locales/en.json` — Add reader i18n strings
**Key Decisions / Notes:**
- Route: `GET /admin/activitypub/reader` → timeline (default tab: "All")
- Route: `GET /admin/activitypub/reader?tab=notes|articles|replies|boosts|media` → filtered tab
- Route: `GET /admin/activitypub/reader?before=cursor` → pagination
- Navigation: Add "Reader" as first navigation item (before Dashboard) with an unread notification count badge
- Timeline controller calls `getTimelineItems()` with optional `type` filter based on tab
- Item card renders: author (avatar + name + handle), content (HTML), photos (grid), video (embed), audio (player), categories/tags, published date, interaction buttons (like, boost, reply, profile link)
- Card layout inspired by Phanpy/Elk: clean white cards with subtle shadows, rounded corners, generous spacing
- Use cursor-based pagination (same pattern as Microsub: `before`/`after` query params)
- Mark items as read when the timeline page loads (or use a "mark all read" button)
- The partial `ap-item-card.njk` renders a single timeline item — reused in both timeline and profile views
- For boosts: show "🔁 {booster} boosted" header above the original post card
- For replies: show "↩ Replying to {parentAuthorUrl}" link above content
- **HTML rendering:** Use `{{ item.content.html | safe }}` in templates — this is safe because content was sanitized at storage time (Task 2). Do NOT use `| safe` on any unsanitized user input
- **Navigation architecture:** Indiekit's `get navigationItems()` returns flat top-level items in the sidebar. The AP plugin currently returns one item ("ActivityPub" → `/activitypub`). Change this to return "Reader" as the primary navigation item (→ `/activitypub/reader`), and add sub-navigation within the reader views (Dashboard, Reader, Notifications, Following, Settings/Moderation) using a local `<nav>` in the view template — NOT via `get navigationItems()` (which only handles top-level sidebar items)
**Definition of Done:**
- [ ] `/admin/activitypub/reader` renders timeline with posts from followed accounts
- [ ] Item cards show author info, content, media, tags, date, and interaction buttons
- [ ] Tab filtering works for notes, articles, replies, boosts, media
- [ ] Pagination works with cursor-based before/after
- [ ] Boost attribution renders correctly (boosted by header)
- [ ] Reply context renders (replying to link)
- [ ] Navigation item appears in sidebar with Reader label
- [ ] Empty state shown when timeline is empty
**Verify:**
- `curl -s https://rmendes.net/admin/activitypub/reader -H "Cookie: ..." | grep -c "ap-item-card"` — returns item count
- Visual check via `playwright-cli open https://rmendes.net/admin/activitypub/reader`
---
### Task 5: Reader CSS Stylesheet
**Objective:** Create a custom CSS stylesheet for the AP reader with card-based layout, image grids, and responsive design.
**Dependencies:** Task 4
**Files:**
- Create: `assets/reader.css` — Reader stylesheet
- Modify: `views/activitypub-reader.njk` — Link stylesheet
**Key Decisions / Notes:**
- Follow the pattern from Microsub: `<link rel="stylesheet" href="/assets/@rmdes-indiekit-endpoint-activitypub/reader.css">`
- Use Indiekit CSS custom properties: `--space-s`, `--space-m`, `--space-l`, `--color-offset`, `--border-radius`, `--color-text`, `--color-background`, etc.
- Card styles: `.ap-card` — white background, border, rounded corners, padding, margin-bottom
- Author header: `.ap-card__author` — flexbox row with avatar (40px circle), name (bold), handle (@user@instance, muted), timestamp (right-aligned, relative)
- Content: `.ap-card__content` — prose-like styling, max-width for readability
- Image grid: `.ap-card__gallery` — CSS Grid, 2-column for 2 images, 2x2 for 3-4 images, rounded corners, gap
- Video embed: `.ap-card__video` — responsive 16:9 container
- Audio player: `.ap-card__audio` — full-width native audio element
- Content warning: `.ap-card__cw` — blurred/collapsed content behind a "Show more" button
- Boost header: `.ap-card__boost` — small text with repost icon, muted color
- Reply context: `.ap-card__reply-to` — small text with reply icon, linked to parent
- Interaction buttons: `.ap-card__actions` — flexbox row, icon buttons with count labels
- Tab bar: `.ap-tabs` — horizontal tabs, active tab highlighted
- Notifications: `.ap-notification` — compact card with icon, actor, action description, post excerpt
- Responsive: Stack to single column on mobile, full-width cards
- Dark mode: Use Indiekit's `prefers-color-scheme` media query with its CSS custom properties
**Definition of Done:**
- [ ] Cards render with clean, readable layout
- [ ] Image gallery works for 1-4 images with proper grid
- [ ] Content warnings show blurred/collapsed state
- [ ] Interaction buttons aligned horizontally below content
- [ ] Tab bar renders with active state
- [ ] Responsive on mobile viewport
- [ ] Uses Indiekit CSS custom properties (not hardcoded colors)
**Verify:**
- `playwright-cli open https://rmendes.net/admin/activitypub/reader` → screenshot → visual check
- `playwright-cli resize 375 812` → mobile check
---
### Task 6: Notifications Controller and View
**Objective:** Create the notifications page at `/admin/activitypub/reader/notifications` showing likes, boosts, follows, mentions, and replies received.
**Dependencies:** Task 3, Task 5
**Files:**
- Modify: `lib/controllers/reader.js` — Add notifications controller function
- Create: `views/activitypub-notifications.njk` — Notifications view (extends `layouts/reader.njk`)
- Create: `views/partials/ap-notification-card.njk` — Notification card partial
- Modify: `index.js` — Add notification route
- Modify: `locales/en.json` — Add notification i18n strings
**Key Decisions / Notes:**
- Route: `GET /admin/activitypub/reader/notifications`
- Notification card is more compact than timeline card: icon + actor name + action text + post excerpt + timestamp
- Group similar notifications? No — keep it chronological for simplicity
- Mark notifications as read when the page loads (set `read: true` on all displayed)
- Unread count shown as badge on "Reader" navigation item (combine timeline and notification counts)
- Notification type → display:
- `like`: "❤ {actor} liked your post {title}" with link to the post
- `boost`: "🔁 {actor} boosted your post {title}"
- `follow`: "👤 {actor} followed you" with link to their profile
- `reply`: "💬 {actor} replied to your post {title}" with content preview
- `mention`: "@ {actor} mentioned you" with content preview
- Pagination: same cursor-based pattern as timeline
**Definition of Done:**
- [ ] `/admin/activitypub/reader/notifications` renders notification stream
- [ ] Each notification type displays correctly with icon, actor, action, and target
- [ ] Notifications marked as read when page loads
- [ ] Unread count appears on Reader navigation badge
- [ ] Pagination works for notifications
- [ ] Empty state shown when no notifications
**Verify:**
- `curl -s https://rmendes.net/admin/activitypub/reader/notifications -H "Cookie: ..."` — renders HTML
- Check unread badge updates after viewing notifications
---
### Task 7a: Interaction API — Like and Boost Endpoints
**Objective:** Create the server-side API endpoints for Like, Unlike, Boost, and Unboost that send ActivityPub activities via Fedify.
**Dependencies:** Task 1, Task 4
**Files:**
- Create: `lib/controllers/interactions.js` — Handle like/boost/unlike/unboost POST requests (receives plugin instance via injection)
- Create: `lib/csrf.js` — Simple CSRF token generation and validation middleware
- Modify: `index.js` — Add interaction routes, inject plugin instance into controller (same pattern as `refollowPauseController(mp, this)` at `index.js:165-166`)
- Modify: `locales/en.json` — Add interaction i18n strings
**Key Decisions / Notes:**
- **CRITICAL — Federation context injection:** Regular controllers only have access to `request.app.locals.application` — they do NOT have `this._federation` or `this._collections`. The interaction controller needs federation context to call `ctx.sendActivity()`. Follow the refollow controller pattern: in `index.js`, pass the plugin instance when registering routes: `interactionController(mp, this)`. The controller factory returns route handlers with access to `pluginInstance._federation` and `pluginInstance._collections`. This same pattern is needed for ALL controllers that send ActivityPub activities (interactions, compose, moderation/block).
- **CSRF protection:** Generate a per-session CSRF token (store in `request.session.csrfToken`). Embed as hidden field in forms and as `X-CSRF-Token` header in `fetch()` requests. Validate on all POST endpoints before processing. Create `lib/csrf.js` with `generateToken(session)` and `validateToken(request)` functions.
- Routes:
- `POST /admin/activitypub/reader/like` — body: `{ url: "post-url", _csrf: "token" }` → sends Like activity
- `POST /admin/activitypub/reader/unlike` — body: `{ url: "post-url", _csrf: "token" }` → sends Undo(Like)
- `POST /admin/activitypub/reader/boost` — body: `{ url: "post-url", _csrf: "token" }` → sends Announce activity
- `POST /admin/activitypub/reader/unboost` — body: `{ url: "post-url", _csrf: "token" }` → sends Undo(Announce)
- Implementation pattern (like):
1. Validate CSRF token
2. Look up the post author via the post URL using `ctx.lookupObject(url)`
3. Construct a `Like` activity with the post as object
4. Send via `ctx.sendActivity({ identifier: handle }, recipient, likeActivity)`
5. Store the interaction in `ap_interactions` collection
6. Return JSON response `{ success: true, type: "like", objectUrl: "..." }`
- For Announce (boost): construct `Announce` activity wrapping the original post, send to followers via shared inbox
- Track interactions in `ap_interactions` collection `{ type: "like"|"boost", objectUrl: "...", activityId: "urn:uuid:...", createdAt: "ISO" }` — allows undo by looking up the activity ID
- Error handling: return JSON `{ success: false, error: "message" }` with appropriate HTTP status
**Definition of Done:**
- [ ] Like endpoint sends Like activity to remote actor's inbox
- [ ] Unlike endpoint sends Undo(Like) activity
- [ ] Boost endpoint sends Announce activity to followers
- [ ] Unboost endpoint sends Undo(Announce) activity
- [ ] CSRF token validated on all POST endpoints
- [ ] Interaction tracking persisted in `ap_interactions`
- [ ] JSON response returned for all endpoints
- [ ] Tests verify activity construction and sending
**Verify:**
- Like a post via `curl -X POST .../reader/like -d '{"url":"...","_csrf":"..."}'` → check JSON response
- Verify `ap_interactions` collection has the record
- Check remote instance shows the like (manual)
---
### Task 7b: Interaction UI — Like and Boost Buttons
**Objective:** Add Alpine.js-powered like/boost buttons to timeline cards with optimistic updates and error handling.
**Dependencies:** Task 7a
**Files:**
- Modify: `views/partials/ap-item-card.njk` — Add like/boost buttons with Alpine.js reactivity
- Modify: `lib/controllers/reader.js` — Query `ap_interactions` on timeline load to populate liked/boosted state, pass CSRF token to template
- Modify: `assets/reader.css` — Add interaction button styles (if not already in Task 5)
**Key Decisions / Notes:**
- Use Alpine.js `x-data` on each card to track `liked` and `boosted` state — initialized from server data
- Timeline controller queries `ap_interactions` for all displayed item URLs, builds a Set of liked/boosted URLs, passes to template
- Button click makes `fetch()` POST with CSRF token in `X-CSRF-Token` header, toggles visual state immediately (optimistic update)
- Error handling: if the API returns `{ success: false }`, revert the visual state and show a brief error message
- Button styling: heart icon for like (filled when liked), repost icon for boost (highlighted when boosted)
**Definition of Done:**
- [ ] Like/boost buttons appear on every timeline card
- [ ] Button state reflects server state on page load (already-liked/boosted show active)
- [ ] Clicking like sends POST and toggles button visually
- [ ] Clicking boost sends POST and toggles button visually
- [ ] Failed interactions revert button state and show error
- [ ] CSRF token included in all fetch() requests
**Verify:**
- `playwright-cli open .../reader` → find a post → click like → verify button state changes
- Reload page → verify liked state persists
- Unlike → verify button reverts
---
### Task 8: Compose Form — Micropub Reply Path
**Objective:** Add a compose form for replying to posts, with the option to submit via Micropub (creating a blog post) or via direct AP reply.
**Dependencies:** Task 4, Task 7a
**Files:**
- Modify: `lib/controllers/reader.js` — Add compose and submitCompose functions
- Create: `views/activitypub-compose.njk` — Compose form view
- Modify: `views/partials/ap-item-card.njk` — Add reply button linking to compose
- Modify: `index.js` — Add compose routes
- Modify: `locales/en.json` — Add compose i18n strings
**Key Decisions / Notes:**
- Routes:
- `GET /admin/activitypub/reader/compose?replyTo=url` — Show compose form
- `POST /admin/activitypub/reader/compose` — Submit reply
- Compose form has two submit paths (radio toggle):
1. **"Post as blog reply" (Micropub)** — Submits to Micropub endpoint as `in-reply-to` + `content`, creating a permanent blog post that gets syndicated to AP via the existing syndicator pipeline
2. **"Quick reply" (Direct AP)** — Constructs a Create(Note) activity with `inReplyTo` and sends directly via `ctx.sendActivity()` to the author's inbox + followers. No blog post created.
- The form pattern borrows from Microsub compose (`views/compose.njk`): textarea, hidden in-reply-to field, syndication target checkboxes (for Micropub path)
- For the quick reply path: the Note is ephemeral (not stored as a blog post) but IS stored in the timeline as the user's own post
- Fetch syndication targets from Micropub config endpoint (same pattern as Microsub compose at `reader.js:403-407`)
- **Micropub endpoint discovery:** Access via `request.app.locals.application.micropubEndpoint` (same as Microsub). Auth token from `request.session.access_token`. Build absolute URL from relative endpoint path using `application.url` as base.
- Character counter for quick reply mode (AP convention: 500 chars)
- Reply context: show the parent post above the compose form (fetch via stored timeline item or `ctx.lookupObject()`)
- **Federation context injection:** The compose controller needs plugin instance for the direct AP reply path (same `ctx.sendActivity()` pattern as Task 7a). Register via same injection pattern.
- **CSRF protection:** Both form submit paths must validate CSRF token (reuse `lib/csrf.js` from Task 7a)
**Definition of Done:**
- [ ] Compose form renders with reply context (parent post preview)
- [ ] "Post as blog reply" submits via Micropub and redirects back to reader
- [ ] "Quick reply" sends Create(Note) directly via Fedify
- [ ] Quick reply includes proper `inReplyTo` reference
- [ ] Quick reply is delivered to the original author's inbox
- [ ] Syndication targets appear for Micropub path
- [ ] Character counter works in quick reply mode
- [ ] Error handling for both paths
**Verify:**
- Post a Micropub reply → verify blog post created and syndicated
- Post a quick reply → verify it appears on the remote instance as a reply
- Check `in-reply-to` is correctly set in both cases
---
### Task 9: Content Warning Toggles and Rich Media Rendering
**Objective:** Implement content warning spoiler toggle (click to reveal), image gallery grid, and video/audio embeds in timeline cards.
**Dependencies:** Task 4, Task 5
**Files:**
- Modify: `views/partials/ap-item-card.njk` — Add CW toggle, gallery grid, video/audio
- Modify: `assets/reader.css` — Add styles for CW, gallery, video, audio
**Key Decisions / Notes:**
- **Content warnings:** Posts with `summary` field (Mastodon CW) render as:
- Visible: CW text (the summary)
- Hidden (behind button): The actual content + media
- Alpine.js `x-data="{ revealed: false }"` + `x-show="revealed"` + `@click="revealed = !revealed"`
- Button text toggles: "Show more" / "Show less"
- `sensitive: true` without summary: "Sensitive content" as default CW text
- **Image gallery:**
- 1 image: Full width, max-height with object-fit: cover
- 2 images: Side-by-side (50/50 grid)
- 3 images: First image full width, second and third side-by-side below
- 4+ images: 2x2 grid, "+N more" overlay on 4th image if >4
- All images rounded corners, gap between
- Click to expand? Lightbox is out of scope — just link to full image
- **Video:** `<video>` tag with controls, poster if available, responsive wrapper
- **Audio:** `<audio>` tag with controls, full width
- **Polls:** Render poll options as a list with vote counts if available (read-only display)
**Definition of Done:**
- [ ] Content warnings display summary text with "Show more" button
- [ ] Clicking "Show more" reveals hidden content and media
- [ ] Clicking "Show less" re-hides content
- [ ] Image gallery renders correctly for 1, 2, 3, and 4+ images
- [ ] Videos render with native player controls
- [ ] Audio renders with native player controls
- [ ] Sensitive posts without summary show "Sensitive content" label
**Verify:**
- `playwright-cli open https://rmendes.net/admin/activitypub/reader`
- Find a post with CW → click "Show more" → content reveals
- Find a post with multiple images → verify grid layout
- `playwright-cli snapshot` → verify structure
---
### Task 10: Mute, Block, and Tab Filtering
**Objective:** Add mute/block functionality for actors and keywords, and implement tab-based timeline filtering.
**Dependencies:** Task 1, Task 4
**Files:**
- Create: `lib/controllers/moderation.js` — Mute/block controller
- Modify: `lib/controllers/reader.js` — Add tab filtering logic, mute/block from profile
- Create: `views/activitypub-moderation.njk` — Moderation settings page (list muted/blocked)
- Modify: `views/partials/ap-item-card.njk` — Add mute/block in item card dropdown menu
- Modify: `index.js` — Add moderation routes
- Modify: `locales/en.json` — Add moderation i18n strings
**Key Decisions / Notes:**
- Routes:
- `POST /admin/activitypub/reader/mute` — body: `{ url: "actor-url" }` or `{ keyword: "text" }`
- `POST /admin/activitypub/reader/unmute` — body: `{ url: "actor-url" }` or `{ keyword: "text" }`
- `POST /admin/activitypub/reader/block` — body: `{ url: "actor-url" }` → also sends Block activity
- `POST /admin/activitypub/reader/unblock` — body: `{ url: "actor-url" }` → sends Undo(Block)
- `GET /admin/activitypub/reader/moderation` — View muted/blocked lists
- Mute: hide from timeline but don't notify the remote actor. Filter at query time: exclude items where `author.url` is in muted list or content matches muted keyword
- Block: send `Block` activity to remote actor via `ctx.sendActivity()` AND hide from timeline. On block: also remove existing timeline items from that actor. **Federation context injection** needed for Block/Undo(Block) — same plugin instance pattern as Task 7a.
- **CSRF protection:** All POST endpoints (mute/unmute/block/unblock) must validate CSRF token (reuse `lib/csrf.js` from Task 7a)
- Tab filtering implementation: `getTimelineItems()` accepts a `type` parameter. Map tabs:
- All → no filter
- Notes → `type: "note"`
- Articles → `type: "article"`
- Replies → items where `inReplyTo` is not null
- Boosts → `type: "boost"`
- Media → items where `photo`, `video`, or `audio` arrays are non-empty
- Each tab shows a count badge? No — too expensive on every page load. Just tab labels.
- Card dropdown (three dots menu): "Mute @user", "Block @user", "Mute keyword..."
**Definition of Done:**
- [ ] Muting an actor hides their posts from timeline
- [ ] Muting a keyword hides matching posts from timeline
- [ ] Blocking an actor sends Block activity and removes their posts
- [ ] Unblocking sends Undo(Block)
- [ ] Moderation settings page lists all muted actors, keywords, and blocked actors
- [ ] Can unmute/unblock from the settings page
- [ ] Tab filtering returns correct subset of timeline items
- [ ] Card dropdown has mute/block actions
**Verify:**
- Mute an actor → verify their posts disappear from timeline
- Block an actor → verify Block activity sent + posts removed
- Switch between tabs → verify correct filtering
---
### Task 11: Remote Profile View
**Objective:** Create a profile page for viewing remote actors, showing their info and recent posts, with follow/unfollow, mute, and block buttons.
**Dependencies:** Task 4, Task 7b, Task 10
**Files:**
- Modify: `lib/controllers/reader.js` — Add profile controller function
- Create: `views/activitypub-remote-profile.njk` — Remote actor profile view (**NOT** `activitypub-profile.njk` — that file already exists for the user's own profile editor)
- Modify: `assets/reader.css` — Add profile view styles
- Modify: `index.js` — Add profile route
- Modify: `locales/en.json` — Add profile i18n strings
**Key Decisions / Notes:**
- Route: `GET /admin/activitypub/reader/profile?url=actor-url` or `GET /admin/activitypub/reader/profile?handle=@user@instance`
- Fetch actor info via `ctx.lookupObject(url)` — returns Fedify Actor with name, summary, icon, image, followerCount, followingCount, etc.
- Show: avatar, header image, display name, handle, bio, follower/following counts, profile links
- Show recent posts from that actor in the timeline (filter `ap_timeline` by `author.url`)
- If the actor is not followed, posts won't be in the local timeline — show a message "Follow to see their posts" or attempt to fetch their outbox via `ctx.traverseCollection(outbox)` (limited, slow)
- Decision: For now, only show locally-stored posts (from following). If not following, show profile info only with a "Follow to see their posts in your timeline" CTA
- Action buttons: Follow/Unfollow (reuse existing `followActor`/`unfollowActor` methods from `index.js`), Mute, Block
- Link to external profile: "View on {instance}" link to the actor's URL
**Definition of Done:**
- [ ] Profile page renders remote actor info (avatar, name, handle, bio)
- [ ] Profile shows header image if available
- [ ] Profile shows follower/following counts
- [ ] Posts from that actor shown below profile (if following)
- [ ] Follow/unfollow button works
- [ ] Mute/block buttons work from profile
- [ ] "View on {instance}" external link present
- [ ] Graceful handling when actor lookup fails
**Verify:**
- Navigate to a followed actor's profile → verify info and posts display
- Follow/unfollow from profile → verify state changes
- Navigate to an unknown handle → verify graceful error
---
### Task 12: Remove Microsub Bridge
**Objective:** Remove all Microsub bridge code from the AP plugin — `storeTimelineItem()`, `getApChannelId()`, and the lazy `microsub_items`/`microsub_channels` collection accessors.
**Dependencies:** Task 2, Task 3, Task 4, Task 6 (all reader functionality must be working first)
**Files:**
- Modify: `lib/inbox-listeners.js` — Remove `storeTimelineItem()` function (lines 455-576), remove `getApChannelId()` function (lines 400-453), remove any remaining calls to these functions
- Modify: `index.js` — Remove lazy `microsub_items` and `microsub_channels` getter/accessors (lines 638-643), remove any `microsub` references from collection handling
- Modify: `lib/inbox-listeners.js` — Remove the `storeTimelineItem()` call in the Create handler (should already be replaced in Task 2, but verify)
**Key Decisions / Notes:**
- This is a cleanup task — all replacement functionality should already be working via Tasks 2-6
- The Microsub plugin itself remains fully functional — it still manages its own RSS/Atom feeds, channels, and items. We're only removing the AP plugin's code that bridges INTO Microsub collections
- After removal, the `microsub_items` collection may still contain old AP items (with `source.type: "activitypub"`) — these can be left in place or cleaned up manually by the user
- Verify that the Microsub plugin's "Fediverse" channel still works for non-AP content (it's created by `getApChannelId` which we're removing). If no non-AP content uses it, the channel becomes orphaned — that's fine.
- Test that the AP plugin starts cleanly without any Microsub collections referenced
- Bump version in `package.json` for this change since it removes a dependency
**Definition of Done:**
- [ ] `storeTimelineItem()` function removed from `inbox-listeners.js`
- [ ] `getApChannelId()` function removed from `inbox-listeners.js`
- [ ] No references to `microsub_items` or `microsub_channels` in any AP plugin file
- [ ] No `import` or `require` of Microsub-related modules
- [ ] Plugin starts without errors when Microsub plugin is not loaded
- [ ] Plugin starts without errors when Microsub plugin IS loaded (no conflict)
- [ ] Existing AP timeline/notification functionality unaffected
- [ ] Version bumped in `package.json`
**Verify:**
- `grep -r "microsub" /home/rick/code/indiekit-dev/indiekit-endpoint-activitypub/lib/ /home/rick/code/indiekit-dev/indiekit-endpoint-activitypub/index.js` — returns zero matches
- `node -e "import('./index.js')"` — plugin loads without errors
- Deploy to Cloudron → verify reader works, verify Microsub reader still works independently
---
### Task 13: Timeline Retention Cleanup
**Objective:** Implement automatic cleanup of old timeline items to prevent unbounded collection growth.
**Dependencies:** Task 1, Task 2
**Files:**
- Create: `lib/timeline-cleanup.js` — Retention cleanup function
- Modify: `index.js` — Schedule periodic cleanup (e.g., on server startup and via a setInterval)
**Key Decisions / Notes:**
- Keep the last 1000 timeline items (configurable via plugin options: `timelineRetention: 1000`)
- Cleanup runs on plugin `init()` and then every 24 hours via `setInterval`
- Implementation: `ap_timeline.deleteMany({ published: { $lt: oldestKeepDate } })` — find the published date of the 1000th newest item, delete everything older
- Alternative: count-based: `ap_timeline.find().sort({ published: -1 }).skip(1000).forEach(doc => delete)`
- Decision: Use count-based approach — simpler, handles edge cases where many items share the same date
- Also clean up corresponding `ap_interactions` entries for deleted timeline items (remove stale like/boost tracking)
- Log cleanup results: "Timeline cleanup: removed N items older than {date}"
**Definition of Done:**
- [ ] Cleanup function removes items beyond retention limit
- [ ] Cleanup runs on startup and periodically
- [ ] Retention limit is configurable via plugin options
- [ ] Stale `ap_interactions` entries cleaned up alongside timeline items
- [ ] Cleanup logged for diagnostics
- [ ] Tests verify retention limit is enforced
**Verify:**
- Insert 1050 test items → run cleanup → verify only 1000 remain
- Verify `ap_interactions` for removed items are also deleted
---
## Testing Strategy
- **Unit tests:** Storage functions (timeline CRUD, notification CRUD, moderation CRUD), data extraction helpers (`extractObjectData`, `extractActorInfo`), tab filtering logic
- **Integration tests:** Bash-based tests in `/home/rick/code/indiekit-dev/activitypub-tests/` — add new tests for reader endpoints (authenticated GET requests), interaction endpoints (POST like/boost), notification counts
- **Manual verification:**
- Use `playwright-cli` to verify reader UI renders correctly
- Send real AP interactions from a test Mastodon account to verify inbox→timeline→notification flow
- Compose replies via both paths (Micropub and direct AP) and verify they appear on remote instances
## Risks and Mitigations
| Risk | Likelihood | Impact | Mitigation |
|------|-----------|--------|------------|
| `ctx.lookupObject()` slow for remote actors (profile view) | High | Medium | Cache actor info in `ap_timeline` author fields; only call lookupObject once per profile visit, not per card |
| `ctx.sendActivity()` for likes/boosts may fail silently | Medium | Medium | Store interaction attempt in `ap_interactions` with status field; show error state in UI if delivery fails |
| Content warnings/sensitive flag not consistently set by remote servers | Medium | Low | Treat `summary` presence as CW signal (Mastodon convention); fall back to "Sensitive content" for `sensitive: true` without summary |
| Image gallery CSS breaks with very large images | Low | Low | Use `object-fit: cover` with max-height constraints; all images in grid cells |
| Removing Microsub bridge while user still has AP items in Microsub channel | Medium | Low | Leave existing items in `microsub_items` untouched; they'll still be readable through the Microsub reader. Only new AP items go to `ap_timeline` |
| Alpine.js optimistic updates for like/boost may desync with server state | Medium | Low | On page reload, always read server state from timeline items; track interactions in `ap_interactions` collection |
| CSRF attacks on POST endpoints could trigger unwanted AP activities | Medium | High | All POST endpoints validate per-session CSRF token via `lib/csrf.js`; token embedded in forms and `fetch()` headers |
| Timeline collection grows unbounded | High | Medium | Task 13 implements automatic retention cleanup (keep last 1000 items, configurable) |
| Announce wraps a deleted/inaccessible object | Medium | Low | If `activity.getObject()` returns null or fails, skip storing the boost and log a warning. Don't crash the inbox handler. |
| Remote actor lookup fails during profile view | Medium | Low | Show error message "Could not load profile — the server may be temporarily unavailable" with retry link. Don't crash the page. |
## Open Questions
- Should there be a "Refresh timeline" button/action, or does it automatically show new items on page reload? → Decision: Automatic on reload for MVP; real-time updates (SSE/polling) deferred
- Should the AP reader be the default landing page when navigating to `/admin/activitypub/`? → Decision: Yes, redirect `/admin/activitypub/` to `/admin/activitypub/reader` as the primary view. Dashboard remains accessible via sub-navigation within the reader layout. The top-level sidebar `get navigationItems()` returns "Reader" linking to `/activitypub/reader`.
- What's the maximum number of timeline items to store before cleanup? → Decision: Keep last 1000 items; auto-delete older items on a weekly basis
### Deferred Ideas
- Real-time timeline updates via Server-Sent Events (SSE) or periodic polling
- Lists feature (organizing follows into named groups with separate timelines)
- Thread view (expanding full conversation thread from a reply)
- Mastodon REST API compatibility layer for mobile clients
- Push notifications for new mentions/replies
- Image lightbox for gallery view
- Infinite scroll instead of pagination
- Timeline item search

124
index.js
View File

@@ -9,6 +9,28 @@ import {
jf2ToAS2Activity,
} from "./lib/jf2-to-as2.js";
import { dashboardController } from "./lib/controllers/dashboard.js";
import {
readerController,
notificationsController,
composeController,
submitComposeController,
remoteProfileController,
followController,
unfollowController,
} from "./lib/controllers/reader.js";
import {
likeController,
unlikeController,
boostController,
unboostController,
} from "./lib/controllers/interactions.js";
import {
muteController,
unmuteController,
blockController,
unblockController,
moderationController,
} from "./lib/controllers/moderation.js";
import { followersController } from "./lib/controllers/followers.js";
import { followingController } from "./lib/controllers/following.js";
import { activitiesController } from "./lib/controllers/activities.js";
@@ -38,6 +60,7 @@ import {
} from "./lib/controllers/refollow.js";
import { startBatchRefollow } from "./lib/batch-refollow.js";
import { logActivity } from "./lib/activity-log.js";
import { scheduleCleanup } from "./lib/timeline-cleanup.js";
const defaults = {
mountPath: "/activitypub",
@@ -54,6 +77,7 @@ const defaults = {
redisUrl: "",
parallelWorkers: 5,
actorType: "Person",
timelineRetention: 1000,
};
export default class ActivityPubEndpoint {
@@ -72,8 +96,8 @@ export default class ActivityPubEndpoint {
get navigationItems() {
return {
href: this.options.mountPath,
text: "activitypub.title",
href: `${this.options.mountPath}/admin/reader`,
text: "activitypub.reader.title",
requiresDatabase: true,
};
}
@@ -145,6 +169,22 @@ export default class ActivityPubEndpoint {
const mp = this.options.mountPath;
router.get("/", dashboardController(mp));
router.get("/admin/reader", readerController(mp));
router.get("/admin/reader/notifications", notificationsController(mp));
router.get("/admin/reader/compose", composeController(mp, this));
router.post("/admin/reader/compose", submitComposeController(mp, this));
router.post("/admin/reader/like", likeController(mp, this));
router.post("/admin/reader/unlike", unlikeController(mp, this));
router.post("/admin/reader/boost", boostController(mp, this));
router.post("/admin/reader/unboost", unboostController(mp, this));
router.get("/admin/reader/profile", remoteProfileController(mp, this));
router.post("/admin/reader/follow", followController(mp, this));
router.post("/admin/reader/unfollow", unfollowController(mp, this));
router.get("/admin/reader/moderation", moderationController(mp));
router.post("/admin/reader/mute", muteController(mp, this));
router.post("/admin/reader/unmute", unmuteController(mp, this));
router.post("/admin/reader/block", blockController(mp, this));
router.post("/admin/reader/unblock", unblockController(mp, this));
router.get("/admin/followers", followersController(mp));
router.get("/admin/following", followingController(mp));
router.get("/admin/activities", activitiesController(mp));
@@ -483,7 +523,7 @@ export default class ActivityPubEndpoint {
inbox,
sharedInbox,
followedAt: new Date().toISOString(),
source: "microsub-reader",
source: "reader",
},
},
{ upsert: true },
@@ -619,6 +659,12 @@ export default class ActivityPubEndpoint {
Indiekit.addCollection("ap_profile");
Indiekit.addCollection("ap_featured");
Indiekit.addCollection("ap_featured_tags");
// Reader collections
Indiekit.addCollection("ap_timeline");
Indiekit.addCollection("ap_notifications");
Indiekit.addCollection("ap_muted");
Indiekit.addCollection("ap_blocked");
Indiekit.addCollection("ap_interactions");
// Store collection references (posts resolved lazily)
const indiekitCollections = Indiekit.collections;
@@ -631,16 +677,15 @@ export default class ActivityPubEndpoint {
ap_profile: indiekitCollections.get("ap_profile"),
ap_featured: indiekitCollections.get("ap_featured"),
ap_featured_tags: indiekitCollections.get("ap_featured_tags"),
// Reader collections
ap_timeline: indiekitCollections.get("ap_timeline"),
ap_notifications: indiekitCollections.get("ap_notifications"),
ap_muted: indiekitCollections.get("ap_muted"),
ap_blocked: indiekitCollections.get("ap_blocked"),
ap_interactions: indiekitCollections.get("ap_interactions"),
get posts() {
return indiekitCollections.get("posts");
},
// Lazy access to Microsub collections (may not exist if plugin not loaded)
get microsub_items() {
return indiekitCollections.get("microsub_items");
},
get microsub_channels() {
return indiekitCollections.get("microsub_channels");
},
_publicationUrl: this._publicationUrl,
};
@@ -675,6 +720,60 @@ export default class ActivityPubEndpoint {
{ 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_muted.createIndex(
{ url: 1 },
{ unique: true, sparse: true, background: true },
);
this._collections.ap_muted.createIndex(
{ keyword: 1 },
{ unique: true, sparse: true, background: true },
);
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 },
);
// Seed actor profile from config on first run
this._seedProfile().catch((error) => {
console.warn("[ActivityPub] Profile seed failed:", error.message);
@@ -720,6 +819,11 @@ export default class ActivityPubEndpoint {
console.error("[ActivityPub] Batch refollow start failed:", error.message);
});
}, 10_000);
// Schedule timeline retention cleanup (runs on startup + every 24h)
if (this.options.timelineRetention > 0) {
scheduleCleanup(this._collections, this.options.timelineRetention);
}
}
/**

323
lib/controllers/compose.js Normal file
View File

@@ -0,0 +1,323 @@
/**
* Compose controllers — reply form via Micropub or direct AP.
*/
import { Temporal } from "@js-temporal/polyfill";
import { getTimelineItem } from "../storage/timeline.js";
import { getToken, validateToken } from "../csrf.js";
/**
* Fetch syndication targets from the Micropub config endpoint.
* @param {object} application - Indiekit application locals
* @param {string} token - Session access token
* @returns {Promise<Array>}
*/
async function getSyndicationTargets(application, token) {
try {
const micropubEndpoint = application.micropubEndpoint;
if (!micropubEndpoint) return [];
const micropubUrl = micropubEndpoint.startsWith("http")
? micropubEndpoint
: new URL(micropubEndpoint, application.url).href;
const configUrl = `${micropubUrl}?q=config`;
const configResponse = await fetch(configUrl, {
headers: {
Authorization: `Bearer ${token}`,
Accept: "application/json",
},
});
if (configResponse.ok) {
const config = await configResponse.json();
return config["syndicate-to"] || [];
}
return [];
} catch {
return [];
}
}
/**
* GET /admin/reader/compose — Show compose form.
* @param {string} mountPath - Plugin mount path
* @param {object} plugin - ActivityPub plugin instance
*/
export function composeController(mountPath, plugin) {
return async (request, response, next) => {
try {
const { application } = request.app.locals;
const replyTo = request.query.replyTo || "";
// Fetch reply context (the post being replied to)
let replyContext = null;
if (replyTo) {
const collections = {
ap_timeline: application?.collections?.get("ap_timeline"),
};
// Try to find the post in our timeline first
replyContext = await getTimelineItem(collections, replyTo);
// If not in timeline, try to look up remotely
if (!replyContext && plugin._federation) {
try {
const handle = plugin.options.actor.handle;
const ctx = plugin._federation.createContext(
new URL(plugin._publicationUrl),
{ handle, publicationUrl: plugin._publicationUrl },
);
const remoteObject = await ctx.lookupObject(new URL(replyTo));
if (remoteObject) {
let authorName = "";
let authorUrl = "";
if (typeof remoteObject.getAttributedTo === "function") {
const author = await remoteObject.getAttributedTo();
const actor = Array.isArray(author) ? author[0] : author;
if (actor) {
authorName =
actor.name?.toString() ||
actor.preferredUsername?.toString() ||
"";
authorUrl = actor.id?.href || "";
}
}
replyContext = {
url: replyTo,
name: remoteObject.name?.toString() || "",
content: {
text:
remoteObject.content?.toString()?.slice(0, 300) || "",
},
author: { name: authorName, url: authorUrl },
};
}
} catch {
// Could not resolve — form still works without context
}
}
}
// Fetch syndication targets for Micropub path
const token = request.session?.access_token;
const syndicationTargets = token
? await getSyndicationTargets(application, token)
: [];
const csrfToken = getToken(request.session);
response.render("activitypub-compose", {
title: response.locals.__("activitypub.compose.title"),
replyTo,
replyContext,
syndicationTargets,
csrfToken,
mountPath,
});
} catch (error) {
next(error);
}
};
}
/**
* POST /admin/reader/compose — Submit reply via Micropub or direct AP.
* @param {string} mountPath - Plugin mount path
* @param {object} plugin - ActivityPub plugin instance
*/
export function submitComposeController(mountPath, plugin) {
return async (request, response, next) => {
try {
if (!validateToken(request)) {
return response.status(403).render("error", {
title: "Error",
content: "Invalid CSRF token",
});
}
const { application } = request.app.locals;
const { content, mode } = request.body;
const inReplyTo = request.body["in-reply-to"];
const syndicateTo = request.body["mp-syndicate-to"];
if (!content || !content.trim()) {
return response.status(400).render("error", {
title: "Error",
content: response.locals.__("activitypub.compose.errorEmpty"),
});
}
// Quick reply — direct AP
if (mode === "quick") {
if (!plugin._federation) {
return response.status(503).render("error", {
title: "Error",
content: "Federation not initialized",
});
}
const { Create, Note } = await import("@fedify/fedify");
const handle = plugin.options.actor.handle;
const ctx = plugin._federation.createContext(
new URL(plugin._publicationUrl),
{ handle, publicationUrl: plugin._publicationUrl },
);
const noteId = `urn:uuid:${crypto.randomUUID()}`;
const actorUri = ctx.getActorUri(handle);
const note = new Note({
id: new URL(noteId),
attribution: actorUri,
content: content.trim(),
inReplyTo: inReplyTo ? new URL(inReplyTo) : undefined,
published: Temporal.Now.instant(),
});
const create = new Create({
id: new URL(`${noteId}#activity`),
actor: actorUri,
object: note,
});
// Send to followers
await ctx.sendActivity({ identifier: handle }, "followers", create, {
preferSharedInbox: true,
syncCollection: true,
orderingKey: noteId,
});
// If replying, also send to the original author
if (inReplyTo) {
try {
const remoteObject = await ctx.lookupObject(new URL(inReplyTo));
if (
remoteObject &&
typeof remoteObject.getAttributedTo === "function"
) {
const author = await remoteObject.getAttributedTo();
const recipient = Array.isArray(author)
? author[0]
: author;
if (recipient) {
await ctx.sendActivity(
{ identifier: handle },
recipient,
create,
{ orderingKey: noteId },
);
}
}
} catch {
// Non-critical — followers still got it
}
}
console.info(
`[ActivityPub] Sent quick reply${inReplyTo ? ` to ${inReplyTo}` : ""}`,
);
return response.redirect(`${mountPath}/admin/reader`);
}
// Micropub path — post as blog reply
const micropubEndpoint = application.micropubEndpoint;
if (!micropubEndpoint) {
return response.status(500).render("error", {
title: "Error",
content: "Micropub endpoint not configured",
});
}
const micropubUrl = micropubEndpoint.startsWith("http")
? micropubEndpoint
: new URL(micropubEndpoint, application.url).href;
const token = request.session?.access_token;
if (!token) {
return response.redirect(
"/session/login?redirect=" + request.originalUrl,
);
}
const micropubData = new URLSearchParams();
micropubData.append("h", "entry");
micropubData.append("content", content.trim());
if (inReplyTo) {
micropubData.append("in-reply-to", inReplyTo);
}
if (syndicateTo) {
const targets = Array.isArray(syndicateTo)
? syndicateTo
: [syndicateTo];
for (const target of targets) {
micropubData.append("mp-syndicate-to", target);
}
}
const micropubResponse = await fetch(micropubUrl, {
method: "POST",
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/x-www-form-urlencoded",
Accept: "application/json",
},
body: micropubData.toString(),
});
if (
micropubResponse.ok ||
micropubResponse.status === 201 ||
micropubResponse.status === 202
) {
const location = micropubResponse.headers.get("Location");
console.info(
`[ActivityPub] Created blog reply via Micropub: ${location || "success"}`,
);
return response.redirect(`${mountPath}/admin/reader`);
}
const errorBody = await micropubResponse.text();
let errorMessage = `Micropub error: ${micropubResponse.statusText}`;
try {
const errorJson = JSON.parse(errorBody);
if (errorJson.error_description) {
errorMessage = String(errorJson.error_description);
} else if (errorJson.error) {
errorMessage = String(errorJson.error);
}
} catch {
// Not JSON
}
return response.status(micropubResponse.status).render("error", {
title: "Error",
content: errorMessage,
});
} catch (error) {
console.error("[ActivityPub] Compose submit failed:", error.message);
return response.status(500).render("error", {
title: "Error",
content: "Failed to create post. Please try again later.",
});
}
};
}

View File

@@ -0,0 +1,208 @@
/**
* Boost/Unboost interaction controllers.
* Sends Announce and Undo(Announce) activities via Fedify.
*/
import { validateToken } from "../csrf.js";
/**
* POST /admin/reader/boost — send an Announce activity to followers.
*/
export function boostController(mountPath, plugin) {
return async (request, response, next) => {
try {
if (!validateToken(request)) {
return response.status(403).json({
success: false,
error: "Invalid CSRF token",
});
}
const { url } = request.body;
if (!url) {
return response.status(400).json({
success: false,
error: "Missing post URL",
});
}
if (!plugin._federation) {
return response.status(503).json({
success: false,
error: "Federation not initialized",
});
}
const { Announce } = await import("@fedify/fedify");
const handle = plugin.options.actor.handle;
const ctx = plugin._federation.createContext(
new URL(plugin._publicationUrl),
{ handle, publicationUrl: plugin._publicationUrl },
);
const activityId = `urn:uuid:${crypto.randomUUID()}`;
// Construct Announce activity
const announce = new Announce({
id: new URL(activityId),
actor: ctx.getActorUri(handle),
object: new URL(url),
});
// Send to followers via shared inbox
await ctx.sendActivity({ identifier: handle }, "followers", announce, {
preferSharedInbox: true,
syncCollection: true,
orderingKey: url,
});
// Also send to the original post author
try {
const remoteObject = await ctx.lookupObject(new URL(url));
if (
remoteObject &&
typeof remoteObject.getAttributedTo === "function"
) {
const author = await remoteObject.getAttributedTo();
const recipient = Array.isArray(author) ? author[0] : author;
if (recipient) {
await ctx.sendActivity(
{ identifier: handle },
recipient,
announce,
{ orderingKey: url },
);
}
}
} catch {
// Non-critical — followers still received the boost
}
// Track the interaction
const { application } = request.app.locals;
const interactions = application?.collections?.get("ap_interactions");
if (interactions) {
await interactions.updateOne(
{ objectUrl: url, type: "boost" },
{
$set: {
objectUrl: url,
type: "boost",
activityId,
createdAt: new Date().toISOString(),
},
},
{ upsert: true },
);
}
console.info(`[ActivityPub] Sent Announce (boost) for ${url}`);
return response.json({
success: true,
type: "boost",
objectUrl: url,
});
} catch (error) {
console.error("[ActivityPub] Boost failed:", error.message);
return response.status(500).json({
success: false,
error: "Boost failed. Please try again later.",
});
}
};
}
/**
* POST /admin/reader/unboost — send an Undo(Announce) to followers.
*/
export function unboostController(mountPath, plugin) {
return async (request, response, next) => {
try {
if (!validateToken(request)) {
return response.status(403).json({
success: false,
error: "Invalid CSRF token",
});
}
const { url } = request.body;
if (!url) {
return response.status(400).json({
success: false,
error: "Missing post URL",
});
}
if (!plugin._federation) {
return response.status(503).json({
success: false,
error: "Federation not initialized",
});
}
const { application } = request.app.locals;
const interactions = application?.collections?.get("ap_interactions");
const existing = interactions
? await interactions.findOne({ objectUrl: url, type: "boost" })
: null;
if (!existing) {
return response.status(404).json({
success: false,
error: "No boost found for this post",
});
}
const { Announce, Undo } = await import("@fedify/fedify");
const handle = plugin.options.actor.handle;
const ctx = plugin._federation.createContext(
new URL(plugin._publicationUrl),
{ handle, publicationUrl: plugin._publicationUrl },
);
// Construct Undo(Announce)
const announce = new Announce({
id: existing.activityId ? new URL(existing.activityId) : undefined,
actor: ctx.getActorUri(handle),
object: new URL(url),
});
const undo = new Undo({
actor: ctx.getActorUri(handle),
object: announce,
});
// Send to followers
await ctx.sendActivity({ identifier: handle }, "followers", undo, {
preferSharedInbox: true,
orderingKey: url,
});
// Remove the interaction record
if (interactions) {
await interactions.deleteOne({ objectUrl: url, type: "boost" });
}
console.info(`[ActivityPub] Sent Undo(Announce) for ${url}`);
return response.json({
success: true,
type: "unboost",
objectUrl: url,
});
} catch (error) {
console.error("[ActivityPub] Unboost failed:", error.message);
return response.status(500).json({
success: false,
error: "Unboost failed. Please try again later.",
});
}
};
}

View File

@@ -0,0 +1,231 @@
/**
* Like/Unlike interaction controllers.
* Sends Like and Undo(Like) activities via Fedify.
*/
import { validateToken } from "../csrf.js";
/**
* POST /admin/reader/like — send a Like activity to the post author.
* @param {string} mountPath - Plugin mount path
* @param {object} plugin - ActivityPub plugin instance (for federation access)
*/
export function likeController(mountPath, plugin) {
return async (request, response, next) => {
try {
if (!validateToken(request)) {
return response.status(403).json({
success: false,
error: "Invalid CSRF token",
});
}
const { url } = request.body;
if (!url) {
return response.status(400).json({
success: false,
error: "Missing post URL",
});
}
if (!plugin._federation) {
return response.status(503).json({
success: false,
error: "Federation not initialized",
});
}
const { Like } = await import("@fedify/fedify");
const handle = plugin.options.actor.handle;
const ctx = plugin._federation.createContext(
new URL(plugin._publicationUrl),
{ handle, publicationUrl: plugin._publicationUrl },
);
// Look up the remote post to find its author
const remoteObject = await ctx.lookupObject(new URL(url));
if (!remoteObject) {
return response.status(404).json({
success: false,
error: "Could not resolve remote post",
});
}
// Get the post author for delivery
let recipient = null;
if (typeof remoteObject.getAttributedTo === "function") {
const author = await remoteObject.getAttributedTo();
recipient = Array.isArray(author) ? author[0] : author;
}
if (!recipient) {
return response.status(404).json({
success: false,
error: "Could not resolve post author",
});
}
// Generate a unique activity ID
const activityId = `urn:uuid:${crypto.randomUUID()}`;
// Construct and send Like activity
const like = new Like({
id: new URL(activityId),
actor: ctx.getActorUri(handle),
object: new URL(url),
});
await ctx.sendActivity({ identifier: handle }, recipient, like, {
orderingKey: url,
});
// Track the interaction for undo
const { application } = request.app.locals;
const interactions = application?.collections?.get("ap_interactions");
if (interactions) {
await interactions.updateOne(
{ objectUrl: url, type: "like" },
{
$set: {
objectUrl: url,
type: "like",
activityId,
recipientUrl: recipient.id?.href || "",
createdAt: new Date().toISOString(),
},
},
{ upsert: true },
);
}
console.info(`[ActivityPub] Sent Like for ${url}`);
return response.json({
success: true,
type: "like",
objectUrl: url,
});
} catch (error) {
console.error("[ActivityPub] Like failed:", error.message);
return response.status(500).json({
success: false,
error: "Like failed. Please try again later.",
});
}
};
}
/**
* POST /admin/reader/unlike — send an Undo(Like) activity.
*/
export function unlikeController(mountPath, plugin) {
return async (request, response, next) => {
try {
if (!validateToken(request)) {
return response.status(403).json({
success: false,
error: "Invalid CSRF token",
});
}
const { url } = request.body;
if (!url) {
return response.status(400).json({
success: false,
error: "Missing post URL",
});
}
if (!plugin._federation) {
return response.status(503).json({
success: false,
error: "Federation not initialized",
});
}
const { application } = request.app.locals;
const interactions = application?.collections?.get("ap_interactions");
// Look up the original interaction to get the activity ID
const existing = interactions
? await interactions.findOne({ objectUrl: url, type: "like" })
: null;
if (!existing) {
return response.status(404).json({
success: false,
error: "No like found for this post",
});
}
const { Like, Undo } = await import("@fedify/fedify");
const handle = plugin.options.actor.handle;
const ctx = plugin._federation.createContext(
new URL(plugin._publicationUrl),
{ handle, publicationUrl: plugin._publicationUrl },
);
// Resolve the recipient
const remoteObject = await ctx.lookupObject(new URL(url));
let recipient = null;
if (remoteObject && typeof remoteObject.getAttributedTo === "function") {
const author = await remoteObject.getAttributedTo();
recipient = Array.isArray(author) ? author[0] : author;
}
if (!recipient) {
// Clean up the local record even if we can't send Undo
if (interactions) {
await interactions.deleteOne({ objectUrl: url, type: "like" });
}
return response.json({
success: true,
type: "unlike",
objectUrl: url,
});
}
// Construct Undo(Like)
const like = new Like({
id: existing.activityId ? new URL(existing.activityId) : undefined,
actor: ctx.getActorUri(handle),
object: new URL(url),
});
const undo = new Undo({
actor: ctx.getActorUri(handle),
object: like,
});
await ctx.sendActivity({ identifier: handle }, recipient, undo, {
orderingKey: url,
});
// Remove the interaction record
if (interactions) {
await interactions.deleteOne({ objectUrl: url, type: "like" });
}
console.info(`[ActivityPub] Sent Undo(Like) for ${url}`);
return response.json({
success: true,
type: "unlike",
objectUrl: url,
});
} catch (error) {
console.error("[ActivityPub] Unlike failed:", error.message);
return response.status(500).json({
success: false,
error: "Unlike failed. Please try again later.",
});
}
};
}

View File

@@ -0,0 +1,7 @@
/**
* Interaction controllers — Like, Unlike, Boost, Unboost.
* Re-exports from split modules for backward compatibility with index.js imports.
*/
export { likeController, unlikeController } from "./interactions-like.js";
export { boostController, unboostController } from "./interactions-boost.js";

View File

@@ -0,0 +1,294 @@
/**
* Moderation controllers — Mute, Unmute, Block, Unblock.
*/
import { validateToken, getToken } from "../csrf.js";
import {
addMuted,
removeMuted,
addBlocked,
removeBlocked,
getAllMuted,
getAllBlocked,
} from "../storage/moderation.js";
/**
* Helper to get moderation collections from request.
*/
function getModerationCollections(request) {
const { application } = request.app.locals;
return {
ap_muted: application?.collections?.get("ap_muted"),
ap_blocked: application?.collections?.get("ap_blocked"),
ap_timeline: application?.collections?.get("ap_timeline"),
};
}
/**
* POST /admin/reader/mute — Mute an actor or keyword.
*/
export function muteController(mountPath, plugin) {
return async (request, response, next) => {
try {
if (!validateToken(request)) {
return response.status(403).json({
success: false,
error: "Invalid CSRF token",
});
}
const { url, keyword } = request.body;
if (!url && !keyword) {
return response.status(400).json({
success: false,
error: "Provide url or keyword to mute",
});
}
const collections = getModerationCollections(request);
await addMuted(collections, { url: url || undefined, keyword: keyword || undefined });
console.info(
`[ActivityPub] Muted ${url ? `actor: ${url}` : `keyword: ${keyword}`}`,
);
return response.json({
success: true,
type: "mute",
url: url || undefined,
keyword: keyword || undefined,
});
} catch (error) {
console.error("[ActivityPub] Mute failed:", error.message);
return response.status(500).json({
success: false,
error: "Operation failed. Please try again later.",
});
}
};
}
/**
* POST /admin/reader/unmute — Unmute an actor or keyword.
*/
export function unmuteController(mountPath, plugin) {
return async (request, response, next) => {
try {
if (!validateToken(request)) {
return response.status(403).json({
success: false,
error: "Invalid CSRF token",
});
}
const { url, keyword } = request.body;
if (!url && !keyword) {
return response.status(400).json({
success: false,
error: "Provide url or keyword to unmute",
});
}
const collections = getModerationCollections(request);
await removeMuted(collections, { url: url || undefined, keyword: keyword || undefined });
return response.json({
success: true,
type: "unmute",
url: url || undefined,
keyword: keyword || undefined,
});
} catch (error) {
return response.status(500).json({
success: false,
error: "Operation failed. Please try again later.",
});
}
};
}
/**
* POST /admin/reader/block — Block an actor (sends Block activity + removes timeline items).
*/
export function blockController(mountPath, plugin) {
return async (request, response, next) => {
try {
if (!validateToken(request)) {
return response.status(403).json({
success: false,
error: "Invalid CSRF token",
});
}
const { url } = request.body;
if (!url) {
return response.status(400).json({
success: false,
error: "Missing actor URL",
});
}
const collections = getModerationCollections(request);
// Store the block
await addBlocked(collections, url);
// Remove timeline items from this actor
if (collections.ap_timeline) {
await collections.ap_timeline.deleteMany({ "author.url": url });
}
// Send Block activity via federation
if (plugin._federation) {
try {
const { Block } = await import("@fedify/fedify");
const handle = plugin.options.actor.handle;
const ctx = plugin._federation.createContext(
new URL(plugin._publicationUrl),
{ handle, publicationUrl: plugin._publicationUrl },
);
const remoteActor = await ctx.lookupObject(new URL(url));
if (remoteActor) {
const block = new Block({
actor: ctx.getActorUri(handle),
object: new URL(url),
});
await ctx.sendActivity(
{ identifier: handle },
remoteActor,
block,
{ orderingKey: url },
);
}
} catch (error) {
console.warn(
`[ActivityPub] Could not send Block to ${url}: ${error.message}`,
);
}
}
console.info(`[ActivityPub] Blocked actor: ${url}`);
return response.json({
success: true,
type: "block",
url,
});
} catch (error) {
console.error("[ActivityPub] Block failed:", error.message);
return response.status(500).json({
success: false,
error: "Operation failed. Please try again later.",
});
}
};
}
/**
* POST /admin/reader/unblock — Unblock an actor (sends Undo(Block)).
*/
export function unblockController(mountPath, plugin) {
return async (request, response, next) => {
try {
if (!validateToken(request)) {
return response.status(403).json({
success: false,
error: "Invalid CSRF token",
});
}
const { url } = request.body;
if (!url) {
return response.status(400).json({
success: false,
error: "Missing actor URL",
});
}
const collections = getModerationCollections(request);
await removeBlocked(collections, url);
// Send Undo(Block) via federation
if (plugin._federation) {
try {
const { Block, Undo } = await import("@fedify/fedify");
const handle = plugin.options.actor.handle;
const ctx = plugin._federation.createContext(
new URL(plugin._publicationUrl),
{ handle, publicationUrl: plugin._publicationUrl },
);
const remoteActor = await ctx.lookupObject(new URL(url));
if (remoteActor) {
const block = new Block({
actor: ctx.getActorUri(handle),
object: new URL(url),
});
const undo = new Undo({
actor: ctx.getActorUri(handle),
object: block,
});
await ctx.sendActivity(
{ identifier: handle },
remoteActor,
undo,
{ orderingKey: url },
);
}
} catch (error) {
console.warn(
`[ActivityPub] Could not send Undo(Block) to ${url}: ${error.message}`,
);
}
}
console.info(`[ActivityPub] Unblocked actor: ${url}`);
return response.json({
success: true,
type: "unblock",
url,
});
} catch (error) {
return response.status(500).json({
success: false,
error: "Operation failed. Please try again later.",
});
}
};
}
/**
* GET /admin/reader/moderation — View muted/blocked lists.
*/
export function moderationController(mountPath) {
return async (request, response, next) => {
try {
const collections = getModerationCollections(request);
const csrfToken = getToken(request.session);
const muted = await getAllMuted(collections);
const blocked = await getAllBlocked(collections);
response.render("activitypub-moderation", {
title: response.locals.__("activitypub.moderation.title"),
muted,
blocked,
csrfToken,
mountPath,
});
} catch (error) {
next(error);
}
};
}

View File

@@ -0,0 +1,218 @@
/**
* Remote profile controllers — view remote actors and follow/unfollow.
*/
import { getToken, validateToken } from "../csrf.js";
import { sanitizeContent } from "../timeline-store.js";
/**
* GET /admin/reader/profile — Show remote actor profile.
* @param {string} mountPath - Plugin mount path
* @param {object} plugin - ActivityPub plugin instance
*/
export function remoteProfileController(mountPath, plugin) {
return async (request, response, next) => {
try {
const { application } = request.app.locals;
const actorUrl = request.query.url || request.query.handle;
if (!actorUrl) {
return response.status(400).render("error", {
title: "Error",
content: "Missing actor URL or handle",
});
}
if (!plugin._federation) {
return response.status(503).render("error", {
title: "Error",
content: "Federation not initialized",
});
}
const handle = plugin.options.actor.handle;
const ctx = plugin._federation.createContext(
new URL(plugin._publicationUrl),
{ handle, publicationUrl: plugin._publicationUrl },
);
// Look up the remote actor
let actor;
try {
actor = await ctx.lookupObject(new URL(actorUrl));
} catch {
return response.status(404).render("error", {
title: "Error",
content: response.locals.__("activitypub.profile.remote.notFound"),
});
}
if (!actor) {
return response.status(404).render("error", {
title: "Error",
content: response.locals.__("activitypub.profile.remote.notFound"),
});
}
// Extract actor info
const name =
actor.name?.toString() ||
actor.preferredUsername?.toString() ||
actorUrl;
const actorHandle = actor.preferredUsername?.toString() || "";
const summary = sanitizeContent(actor.summary?.toString() || "");
let icon = "";
let image = "";
try {
const iconObj = await actor.getIcon();
icon = iconObj?.url?.href || "";
} catch {
// No icon
}
try {
const imageObj = await actor.getImage();
image = imageObj?.url?.href || "";
} catch {
// No header image
}
// Extract host for "View on {instance}"
let instanceHost = "";
try {
instanceHost = new URL(actorUrl).hostname;
} catch {
// Invalid URL
}
// Check if we're following this actor
const followingCol = application?.collections?.get("ap_following");
const isFollowing = followingCol
? !!(await followingCol.findOne({ actorUrl }))
: false;
// Get their posts from our timeline (only if following)
let posts = [];
if (isFollowing) {
const timelineCol = application?.collections?.get("ap_timeline");
if (timelineCol) {
posts = await timelineCol
.find({ "author.url": actorUrl })
.sort({ published: -1 })
.limit(20)
.toArray();
}
}
// Check mute/block state
const mutedCol = application?.collections?.get("ap_muted");
const blockedCol = application?.collections?.get("ap_blocked");
const isMuted = mutedCol
? !!(await mutedCol.findOne({ url: actorUrl }))
: false;
const isBlocked = blockedCol
? !!(await blockedCol.findOne({ url: actorUrl }))
: false;
const csrfToken = getToken(request.session);
response.render("activitypub-remote-profile", {
title: name,
actorUrl,
name,
actorHandle,
summary,
icon,
image,
instanceHost,
isFollowing,
isMuted,
isBlocked,
posts,
csrfToken,
mountPath,
});
} catch (error) {
next(error);
}
};
}
/**
* POST /admin/reader/follow — Follow a remote actor.
*/
export function followController(mountPath, plugin) {
return async (request, response, next) => {
try {
if (!validateToken(request)) {
return response.status(403).json({
success: false,
error: "Invalid CSRF token",
});
}
const { url } = request.body;
if (!url) {
return response.status(400).json({
success: false,
error: "Missing actor URL",
});
}
const result = await plugin.followActor(url);
return response.json({
success: result.ok,
error: result.error || undefined,
});
} catch (error) {
return response.status(500).json({
success: false,
error: "Operation failed. Please try again later.",
});
}
};
}
/**
* POST /admin/reader/unfollow — Unfollow a remote actor.
*/
export function unfollowController(mountPath, plugin) {
return async (request, response, next) => {
try {
if (!validateToken(request)) {
return response.status(403).json({
success: false,
error: "Invalid CSRF token",
});
}
const { url } = request.body;
if (!url) {
return response.status(400).json({
success: false,
error: "Missing actor URL",
});
}
const result = await plugin.unfollowActor(url);
return response.json({
success: result.ok,
error: result.error || undefined,
});
} catch (error) {
return response.status(500).json({
success: false,
error: "Operation failed. Please try again later.",
});
}
};
}

187
lib/controllers/reader.js Normal file
View File

@@ -0,0 +1,187 @@
/**
* Reader controller — shows timeline of posts from followed accounts.
*/
import { getTimelineItems } from "../storage/timeline.js";
import {
getNotifications,
getUnreadNotificationCount,
markAllNotificationsRead,
} from "../storage/notifications.js";
import { getToken } from "../csrf.js";
import {
getMutedUrls,
getMutedKeywords,
getBlockedUrls,
} from "../storage/moderation.js";
// Re-export controllers from split modules for backward compatibility
export {
composeController,
submitComposeController,
} from "./compose.js";
export {
remoteProfileController,
followController,
unfollowController,
} from "./profile.remote.js";
export function readerController(mountPath) {
return async (request, response, next) => {
try {
const { application } = request.app.locals;
const collections = {
ap_timeline: application?.collections?.get("ap_timeline"),
ap_notifications: application?.collections?.get("ap_notifications"),
};
// Query parameters
const tab = request.query.tab || "all";
const before = request.query.before;
const after = request.query.after;
const limit = Number.parseInt(request.query.limit || "20", 10);
// Build query options
const options = { before, after, limit };
// Tab filtering
if (tab === "notes") {
options.type = "note";
} else if (tab === "articles") {
options.type = "article";
} else if (tab === "boosts") {
options.type = "boost";
}
// Get timeline items
const result = await getTimelineItems(collections, options);
// Apply client-side filtering for tabs not supported by storage layer
let items = result.items;
if (tab === "replies") {
items = items.filter((item) => item.inReplyTo);
} else if (tab === "media") {
items = items.filter(
(item) =>
(item.photo && item.photo.length > 0) ||
(item.video && item.video.length > 0) ||
(item.audio && item.audio.length > 0),
);
}
// Apply moderation filters (muted actors, keywords, blocked actors)
const modCollections = {
ap_muted: application?.collections?.get("ap_muted"),
ap_blocked: application?.collections?.get("ap_blocked"),
};
const [mutedUrls, mutedKeywords, blockedUrls] = await Promise.all([
getMutedUrls(modCollections),
getMutedKeywords(modCollections),
getBlockedUrls(modCollections),
]);
const hiddenUrls = new Set([...mutedUrls, ...blockedUrls]);
if (hiddenUrls.size > 0 || mutedKeywords.length > 0) {
items = items.filter((item) => {
// Filter by author URL
if (item.author?.url && hiddenUrls.has(item.author.url)) {
return false;
}
// Filter by muted keywords in content
if (mutedKeywords.length > 0 && item.content?.text) {
const lower = item.content.text.toLowerCase();
if (
mutedKeywords.some((kw) => lower.includes(kw.toLowerCase()))
) {
return false;
}
}
return true;
});
}
// Get unread notification count for badge
const unreadCount = await getUnreadNotificationCount(collections);
// Get interaction state for liked/boosted indicators
const interactionsCol =
application?.collections?.get("ap_interactions");
const interactionMap = {};
if (interactionsCol) {
const itemUrls = items
.map((item) => item.url || item.originalUrl)
.filter(Boolean);
if (itemUrls.length > 0) {
const interactions = await interactionsCol
.find({ objectUrl: { $in: itemUrls } })
.toArray();
for (const interaction of interactions) {
if (!interactionMap[interaction.objectUrl]) {
interactionMap[interaction.objectUrl] = {};
}
interactionMap[interaction.objectUrl][interaction.type] = true;
}
}
}
// CSRF token for interaction forms
const csrfToken = getToken(request.session);
response.render("activitypub-reader", {
title: response.locals.__("activitypub.reader.title"),
items,
tab,
before: result.before,
after: result.after,
unreadCount,
interactionMap,
csrfToken,
mountPath,
});
} catch (error) {
next(error);
}
};
}
export function notificationsController(mountPath) {
return async (request, response, next) => {
try {
const { application } = request.app.locals;
const collections = {
ap_notifications: application?.collections?.get("ap_notifications"),
};
const before = request.query.before;
const limit = Number.parseInt(request.query.limit || "20", 10);
// Get notifications
const result = await getNotifications(collections, { before, limit });
// Get unread count before marking as read
const unreadCount = await getUnreadNotificationCount(collections);
// Mark all as read when page loads
if (result.items.length > 0) {
await markAllNotificationsRead(collections);
}
response.render("activitypub-notifications", {
title: response.locals.__("activitypub.notifications.title"),
items: result.items,
before: result.before,
unreadCount,
mountPath,
});
} catch (error) {
next(error);
}
};
}

49
lib/csrf.js Normal file
View File

@@ -0,0 +1,49 @@
/**
* Simple CSRF token generation and validation.
* Tokens are stored in the Express session.
*/
import { randomBytes, timingSafeEqual } from "node:crypto";
/**
* Get or generate a CSRF token for the current session.
* @param {object} session - Express session object
* @returns {string} CSRF token
*/
export function getToken(session) {
if (!session._csrfToken) {
session._csrfToken = randomBytes(32).toString("hex");
}
return session._csrfToken;
}
/**
* Validate a CSRF token from a request.
* Checks both the request body `_csrf` field and the `X-CSRF-Token` header.
* @param {object} request - Express request object
* @returns {boolean} Whether the token is valid
*/
export function validateToken(request) {
const sessionToken = request.session?._csrfToken;
if (!sessionToken) {
return false;
}
const requestToken =
request.body?._csrf || request.headers["x-csrf-token"];
if (!requestToken) {
return false;
}
if (sessionToken.length !== requestToken.length) {
return false;
}
return timingSafeEqual(
Buffer.from(sessionToken),
Buffer.from(requestToken),
);
}

View File

@@ -23,6 +23,9 @@ import {
} from "@fedify/fedify";
import { logActivity as logActivityShared } from "./activity-log.js";
import { sanitizeContent, extractActorInfo, extractObjectData } from "./timeline-store.js";
import { addTimelineItem, deleteTimelineItem, updateTimelineItem } from "./storage/timeline.js";
import { addNotification } from "./storage/notifications.js";
/**
* Register all inbox listeners on a federation's inbox chain.
@@ -83,6 +86,19 @@ export function registerInboxListeners(inboxChain, options) {
actorName: followerName,
summary: `${followerName} followed you`,
});
// Store notification
const followerInfo = await extractActorInfo(followerActor);
await addNotification(collections, {
uid: follow.id?.href || `follow:${followerUrl}`,
type: "follow",
actorUrl: followerInfo.url,
actorName: followerInfo.name,
actorPhoto: followerInfo.photo,
actorHandle: followerInfo.handle,
published: follow.published ? new Date(follow.published) : new Date(),
createdAt: new Date().toISOString(),
});
})
.on(Undo, async (ctx, undo) => {
const actorUrl = undo.actorId?.href || "";
@@ -139,7 +155,7 @@ export function registerInboxListeners(inboxChain, options) {
const result = await collections.ap_following.findOneAndUpdate(
{
actorUrl,
source: { $in: ["refollow:sent", "microsub-reader"] },
source: { $in: ["refollow:sent", "reader", "microsub-reader"] },
},
{
$set: {
@@ -176,7 +192,7 @@ export function registerInboxListeners(inboxChain, options) {
const result = await collections.ap_following.findOneAndUpdate(
{
actorUrl,
source: { $in: ["refollow:sent", "microsub-reader"] },
source: { $in: ["refollow:sent", "reader", "microsub-reader"] },
},
{
$set: {
@@ -210,17 +226,18 @@ export function registerInboxListeners(inboxChain, options) {
if (!objectId || (pubUrl && !objectId.startsWith(pubUrl))) return;
const actorUrl = like.actorId?.href || "";
let actorName = actorUrl;
let actorObj;
try {
const actorObj = await like.getActor();
actorName =
actorObj?.name?.toString() ||
actorObj?.preferredUsername?.toString() ||
actorUrl;
actorObj = await like.getActor();
} catch {
/* actor not dereferenceable — use URL */
actorObj = null;
}
const actorName =
actorObj?.name?.toString() ||
actorObj?.preferredUsername?.toString() ||
actorUrl;
await logActivity(collections, storeRawActivities, {
direction: "inbound",
type: "Like",
@@ -229,35 +246,96 @@ export function registerInboxListeners(inboxChain, options) {
objectUrl: objectId,
summary: `${actorName} liked ${objectId}`,
});
// Store notification
const actorInfo = await extractActorInfo(actorObj);
await addNotification(collections, {
uid: like.id?.href || `like:${actorUrl}:${objectId}`,
type: "like",
actorUrl: actorInfo.url,
actorName: actorInfo.name,
actorPhoto: actorInfo.photo,
actorHandle: actorInfo.handle,
targetUrl: objectId,
targetName: "", // Could fetch post title, but not critical
published: like.published ? new Date(like.published) : new Date(),
createdAt: new Date().toISOString(),
});
})
.on(Announce, async (ctx, announce) => {
// Use .objectId — no remote fetch needed (see Like handler comment)
const objectId = announce.objectId?.href || "";
// Only log boosts of our own content
const pubUrl = collections._publicationUrl;
if (!objectId || (pubUrl && !objectId.startsWith(pubUrl))) return;
if (!objectId) return;
const actorUrl = announce.actorId?.href || "";
let actorName = actorUrl;
try {
const actorObj = await announce.getActor();
actorName =
const pubUrl = collections._publicationUrl;
// Dual path logic: Notification vs Timeline
// PATH 1: Boost of OUR content → Notification
if (pubUrl && objectId.startsWith(pubUrl)) {
let actorObj;
try {
actorObj = await announce.getActor();
} catch {
actorObj = null;
}
const actorName =
actorObj?.name?.toString() ||
actorObj?.preferredUsername?.toString() ||
actorUrl;
} catch {
/* actor not dereferenceable — use URL */
// Log the boost activity
await logActivity(collections, storeRawActivities, {
direction: "inbound",
type: "Announce",
actorUrl,
actorName,
objectUrl: objectId,
summary: `${actorName} boosted ${objectId}`,
});
// Create notification
const actorInfo = await extractActorInfo(actorObj);
await addNotification(collections, {
uid: announce.id?.href || `${actorUrl}#boost-${objectId}`,
type: "boost",
actorUrl: actorInfo.url,
actorName: actorInfo.name,
actorPhoto: actorInfo.photo,
actorHandle: actorInfo.handle,
targetUrl: objectId,
targetName: "", // Could fetch post title, but not critical
published: announce.published ? new Date(announce.published).toISOString() : new Date().toISOString(),
createdAt: new Date().toISOString(),
});
// Don't return — fall through to check if actor is also followed
}
await logActivity(collections, storeRawActivities, {
direction: "inbound",
type: "Announce",
actorUrl,
actorName,
objectUrl: objectId,
summary: `${actorName} boosted ${objectId}`,
});
// PATH 2: Boost from someone we follow → Timeline (store original post)
const following = await collections.ap_following.findOne({ actorUrl });
if (following) {
try {
// Fetch the original object being boosted
const object = await announce.getObject();
if (!object) return;
// Get booster actor info
const boosterActor = await announce.getActor();
const boosterInfo = await extractActorInfo(boosterActor);
// Extract and store with boost metadata
const timelineItem = await extractObjectData(object, {
boostedBy: boosterInfo,
boostedAt: announce.published ? new Date(announce.published).toISOString() : new Date().toISOString(),
});
await addTimelineItem(collections, timelineItem);
} catch (error) {
console.error("Failed to store boosted timeline item:", error);
}
}
})
.on(Create, async (ctx, create) => {
let object;
@@ -292,6 +370,7 @@ export function registerInboxListeners(inboxChain, options) {
}
// Log replies to our posts (existing behavior for conversations)
const pubUrl = collections._publicationUrl;
if (inReplyTo) {
const content = object.content?.toString() || "";
await logActivity(collections, storeRawActivities, {
@@ -304,21 +383,86 @@ export function registerInboxListeners(inboxChain, options) {
content,
summary: `${actorName} replied to ${inReplyTo}`,
});
// Create notification if reply is to one of OUR posts
if (pubUrl && inReplyTo.startsWith(pubUrl)) {
const actorInfo = await extractActorInfo(actorObj);
const rawHtml = object.content?.toString() || "";
const contentHtml = sanitizeContent(rawHtml);
const contentText = rawHtml.replace(/<[^>]*>/g, "").substring(0, 200);
await addNotification(collections, {
uid: object.id?.href || `reply:${actorUrl}:${inReplyTo}`,
type: "reply",
actorUrl: actorInfo.url,
actorName: actorInfo.name,
actorPhoto: actorInfo.photo,
actorHandle: actorInfo.handle,
targetUrl: inReplyTo,
targetName: "",
content: {
text: contentText,
html: contentHtml,
},
published: object.published ? new Date(object.published).toISOString() : new Date().toISOString(),
createdAt: new Date().toISOString(),
});
}
}
// Check for mentions of our actor
if (object.tag) {
const tags = Array.isArray(object.tag) ? object.tag : [object.tag];
const ourActorUrl = ctx.getActorUri(handle).href;
for (const tag of tags) {
if (tag.type === "Mention" && tag.href?.href === ourActorUrl) {
const actorInfo = await extractActorInfo(actorObj);
const rawMentionHtml = object.content?.toString() || "";
const mentionHtml = sanitizeContent(rawMentionHtml);
const contentText = rawMentionHtml.replace(/<[^>]*>/g, "").substring(0, 200);
await addNotification(collections, {
uid: object.id?.href || `mention:${actorUrl}:${object.id?.href}`,
type: "mention",
actorUrl: actorInfo.url,
actorName: actorInfo.name,
actorPhoto: actorInfo.photo,
actorHandle: actorInfo.handle,
content: {
text: contentText,
html: mentionHtml,
},
published: object.published ? new Date(object.published).toISOString() : new Date().toISOString(),
createdAt: new Date().toISOString(),
});
break; // Only create one mention notification per post
}
}
}
// Store timeline items from accounts we follow (native storage)
const following = await collections.ap_following.findOne({ actorUrl });
if (following) {
try {
const timelineItem = await extractObjectData(object);
await addTimelineItem(collections, timelineItem);
} catch (error) {
// Log extraction errors but don't fail the entire handler
console.error("Failed to store timeline item:", error);
}
}
// Store timeline items from accounts we follow
await storeTimelineItem(collections, {
actorUrl,
actorName,
actorObj,
object,
inReplyTo,
});
})
.on(Delete, async (ctx, del) => {
const objectId = del.objectId?.href || "";
if (objectId) {
// Remove from activity log
await collections.ap_activities.deleteMany({ objectUrl: objectId });
// Remove from timeline
await deleteTimelineItem(collections, objectId);
}
})
.on(Move, async (ctx, move) => {
@@ -343,7 +487,44 @@ export function registerInboxListeners(inboxChain, options) {
});
})
.on(Update, async (ctx, update) => {
// Remote actor updated their profile — refresh stored follower data
// Update can be for a profile OR for a post (edited content)
// Try to get the object being updated
let object;
try {
object = await update.getObject();
} catch {
object = null;
}
// PATH 1: If object is a Note/Article → Update timeline item content
if (object && (object instanceof Note || object.type === "Article")) {
const objectUrl = object.id?.href || "";
if (objectUrl) {
try {
// Extract updated content
const contentHtml = object.content?.toString() || "";
const contentText = object.source?.content?.toString() || contentHtml.replace(/<[^>]*>/g, "");
const updates = {
content: {
text: contentText,
html: contentHtml,
},
name: object.name?.toString() || "",
summary: object.summary?.toString() || "",
sensitive: object.sensitive || false,
};
await updateTimelineItem(collections, objectUrl, updates);
} catch (error) {
console.error("Failed to update timeline item:", error);
}
}
return;
}
// PATH 2: Otherwise, assume profile update — refresh stored follower data
const actorObj = await update.getActor();
const actorUrl = actorObj?.id?.href || "";
if (!actorUrl) return;
@@ -397,180 +578,3 @@ async function logActivity(collections, storeRaw, record, rawJson) {
);
}
// Cached ActivityPub channel ObjectId
let _apChannelId = null;
/**
* Look up (or auto-create) the ActivityPub channel's ObjectId.
* Cached after first successful call.
*
* The channel is created with `userId: "default"` so it appears in the
* Microsub reader UI alongside user-created channels.
*
* @param {object} collections - MongoDB collections
* @returns {Promise<import("mongodb").ObjectId|null>}
*/
async function getApChannelId(collections) {
if (_apChannelId) return _apChannelId;
if (!collections.microsub_channels) return null;
let channel = await collections.microsub_channels.findOne({
uid: "activitypub",
});
if (!channel) {
// Auto-create the channel with the same fields the Microsub plugin uses
const maxOrderDoc = await collections.microsub_channels
.find({ userId: "default" })
.sort({ order: -1 })
.limit(1)
.toArray();
const order = maxOrderDoc.length > 0 ? maxOrderDoc[0].order + 1 : 0;
const doc = {
uid: "activitypub",
name: "Fediverse",
userId: "default",
order,
settings: { excludeTypes: [], excludeRegex: null },
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
};
await collections.microsub_channels.insertOne(doc);
channel = doc;
console.info("[ActivityPub] Auto-created Microsub channel 'Fediverse'");
} else if (!channel.userId) {
// Fix existing channel missing userId (created by earlier version)
await collections.microsub_channels.updateOne(
{ _id: channel._id },
{ $set: { userId: "default" } },
);
console.info("[ActivityPub] Fixed Microsub channel: set userId to 'default'");
}
_apChannelId = channel._id;
return _apChannelId;
}
/**
* Store a Create activity as a Microsub timeline item if the actor
* is someone we follow. Skips gracefully if the Microsub plugin
* isn't loaded or the AP channel doesn't exist yet.
*
* @param {object} collections - MongoDB collections
* @param {object} params
* @param {string} params.actorUrl - Actor URL
* @param {string} params.actorName - Actor display name
* @param {object} params.actorObj - Fedify actor object
* @param {object} params.object - Fedify Note/Article object
* @param {string|null} params.inReplyTo - URL this is a reply to (if any)
*/
async function storeTimelineItem(
collections,
{ actorUrl, actorName, actorObj, object, inReplyTo },
) {
// Skip if Microsub plugin not loaded
if (!collections.microsub_items || !collections.microsub_channels) return;
// Only store posts from accounts we follow
const following = await collections.ap_following.findOne({ actorUrl });
if (!following) return;
const channelId = await getApChannelId(collections);
if (!channelId) return;
const objectUrl = object.id?.href || "";
if (!objectUrl) return;
// Extract content
const contentHtml = object.content?.toString() || "";
const contentText = contentHtml.replace(/<[^>]*>/g, "").trim();
// Name (usually only on Article, not Note)
const name = object.name?.toString() || undefined;
const summary = object.summary?.toString() || undefined;
// Published date — Fedify returns Temporal.Instant
let published;
if (object.published) {
try {
published = new Date(Number(object.published.epochMilliseconds));
} catch {
published = new Date();
}
}
// Author avatar
let authorPhoto = "";
try {
if (actorObj.icon) {
const iconObj = await actorObj.icon;
authorPhoto = iconObj?.url?.href || "";
}
} catch {
/* remote fetch may fail */
}
// Tags / categories
const category = [];
try {
for await (const tag of object.getTags()) {
const tagName = tag.name?.toString();
if (tagName) category.push(tagName.replace(/^#/, ""));
}
} catch {
/* ignore */
}
// Attachments (photos, videos, audio)
const photo = [];
const video = [];
const audio = [];
try {
for await (const att of object.getAttachments()) {
const mediaType = att.mediaType?.toString() || "";
const url = att.url?.href || att.id?.href || "";
if (!url) continue;
if (mediaType.startsWith("image/")) photo.push(url);
else if (mediaType.startsWith("video/")) video.push(url);
else if (mediaType.startsWith("audio/")) audio.push(url);
}
} catch {
/* ignore */
}
const item = {
channelId,
feedId: null,
uid: objectUrl,
type: "entry",
url: objectUrl,
name,
content: contentHtml ? { text: contentText, html: contentHtml } : undefined,
summary,
published: published || new Date(),
author: {
name: actorName,
url: actorUrl,
photo: authorPhoto,
},
category,
photo,
video,
audio,
inReplyTo: inReplyTo ? [inReplyTo] : [],
source: {
type: "activitypub",
actorUrl,
},
readBy: [],
createdAt: new Date().toISOString(),
};
// Atomic upsert — prevents duplicates without a separate check+insert
await collections.microsub_items.updateOne(
{ channelId, uid: objectUrl },
{ $setOnInsert: item },
{ upsert: true },
);
}

180
lib/storage/moderation.js Normal file
View File

@@ -0,0 +1,180 @@
/**
* Moderation storage operations (mute/block)
* @module storage/moderation
*/
/**
* Add a muted URL or keyword
* @param {object} collections - MongoDB collections
* @param {object} data - Mute data
* @param {string} [data.url] - Actor URL to mute (mutually exclusive with keyword)
* @param {string} [data.keyword] - Keyword to mute (mutually exclusive with url)
* @returns {Promise<object>} Created mute entry
*/
export async function addMuted(collections, { url, keyword }) {
const { ap_muted } = collections;
if (!url && !keyword) {
throw new Error("Either url or keyword must be provided");
}
if (url && keyword) {
throw new Error("Cannot mute both url and keyword in same entry");
}
const entry = {
url: url || null,
keyword: keyword || null,
mutedAt: new Date().toISOString(),
};
// Upsert to avoid duplicates
const filter = url ? { url } : { keyword };
await ap_muted.updateOne(filter, { $setOnInsert: entry }, { upsert: true });
return await ap_muted.findOne(filter);
}
/**
* Remove a muted URL or keyword
* @param {object} collections - MongoDB collections
* @param {object} data - Mute identifier
* @param {string} [data.url] - Actor URL to unmute
* @param {string} [data.keyword] - Keyword to unmute
* @returns {Promise<object>} Delete result
*/
export async function removeMuted(collections, { url, keyword }) {
const { ap_muted } = collections;
const filter = {};
if (url) {
filter.url = url;
} else if (keyword) {
filter.keyword = keyword;
} else {
throw new Error("Either url or keyword must be provided");
}
return await ap_muted.deleteOne(filter);
}
/**
* Get all muted URLs
* @param {object} collections - MongoDB collections
* @returns {Promise<string[]>} Array of muted URLs
*/
export async function getMutedUrls(collections) {
const { ap_muted } = collections;
const entries = await ap_muted.find({ url: { $ne: null } }).toArray();
return entries.map((entry) => entry.url);
}
/**
* Get all muted keywords
* @param {object} collections - MongoDB collections
* @returns {Promise<string[]>} Array of muted keywords
*/
export async function getMutedKeywords(collections) {
const { ap_muted } = collections;
const entries = await ap_muted.find({ keyword: { $ne: null } }).toArray();
return entries.map((entry) => entry.keyword);
}
/**
* Check if a URL is muted
* @param {object} collections - MongoDB collections
* @param {string} url - URL to check
* @returns {Promise<boolean>} True if muted
*/
export async function isUrlMuted(collections, url) {
const { ap_muted } = collections;
const entry = await ap_muted.findOne({ url });
return !!entry;
}
/**
* Check if content contains muted keywords
* @param {object} collections - MongoDB collections
* @param {string} content - Content text to check
* @returns {Promise<boolean>} True if contains muted keyword
*/
export async function containsMutedKeyword(collections, content) {
const keywords = await getMutedKeywords(collections);
const lowerContent = content.toLowerCase();
return keywords.some((keyword) => lowerContent.includes(keyword.toLowerCase()));
}
/**
* Add a blocked actor URL
* @param {object} collections - MongoDB collections
* @param {string} url - Actor URL to block
* @returns {Promise<object>} Created block entry
*/
export async function addBlocked(collections, url) {
const { ap_blocked } = collections;
const entry = {
url,
blockedAt: new Date().toISOString(),
};
// Upsert to avoid duplicates
await ap_blocked.updateOne({ url }, { $setOnInsert: entry }, { upsert: true });
return await ap_blocked.findOne({ url });
}
/**
* Remove a blocked actor URL
* @param {object} collections - MongoDB collections
* @param {string} url - Actor URL to unblock
* @returns {Promise<object>} Delete result
*/
export async function removeBlocked(collections, url) {
const { ap_blocked } = collections;
return await ap_blocked.deleteOne({ url });
}
/**
* Get all blocked URLs
* @param {object} collections - MongoDB collections
* @returns {Promise<string[]>} Array of blocked URLs
*/
export async function getBlockedUrls(collections) {
const { ap_blocked } = collections;
const entries = await ap_blocked.find({}).toArray();
return entries.map((entry) => entry.url);
}
/**
* Check if a URL is blocked
* @param {object} collections - MongoDB collections
* @param {string} url - URL to check
* @returns {Promise<boolean>} True if blocked
*/
export async function isUrlBlocked(collections, url) {
const { ap_blocked } = collections;
const entry = await ap_blocked.findOne({ url });
return !!entry;
}
/**
* Get list of all muted entries (URLs and keywords)
* @param {object} collections - MongoDB collections
* @returns {Promise<object[]>} Array of mute entries
*/
export async function getAllMuted(collections) {
const { ap_muted } = collections;
return await ap_muted.find({}).toArray();
}
/**
* Get list of all blocked entries
* @param {object} collections - MongoDB collections
* @returns {Promise<object[]>} Array of block entries
*/
export async function getAllBlocked(collections) {
const { ap_blocked } = collections;
return await ap_blocked.find({}).toArray();
}

View File

@@ -0,0 +1,132 @@
/**
* Notification storage operations
* @module storage/notifications
*/
/**
* Add a notification (uses atomic upsert for deduplication)
* @param {object} collections - MongoDB collections
* @param {object} notification - Notification data
* @param {string} notification.uid - Activity ID or constructed dedup key
* @param {string} notification.type - "like" | "boost" | "follow" | "mention" | "reply"
* @param {string} notification.actorUrl - Remote actor URL
* @param {string} notification.actorName - Display name
* @param {string} notification.actorPhoto - Avatar URL
* @param {string} notification.actorHandle - @user@instance
* @param {string} [notification.targetUrl] - The post they liked/boosted/replied to
* @param {string} [notification.targetName] - Post title
* @param {object} [notification.content] - { text, html } for mentions/replies
* @param {Date} notification.published - Activity timestamp (kept as Date for sort)
* @param {string} notification.createdAt - ISO string creation timestamp
* @returns {Promise<object>} Created or existing notification
*/
export async function addNotification(collections, notification) {
const { ap_notifications } = collections;
const result = await ap_notifications.updateOne(
{ uid: notification.uid },
{
$setOnInsert: {
...notification,
read: false,
},
},
{ upsert: true },
);
if (result.upsertedCount > 0) {
return await ap_notifications.findOne({ uid: notification.uid });
}
// Return existing document if it was a duplicate
return await ap_notifications.findOne({ uid: notification.uid });
}
/**
* Get notifications with cursor-based pagination
* @param {object} collections - MongoDB collections
* @param {object} options - Query options
* @param {string} [options.before] - Before cursor (published date)
* @param {number} [options.limit=20] - Items per page
* @param {boolean} [options.unreadOnly=false] - Show only unread notifications
* @returns {Promise<object>} { items, before }
*/
export async function getNotifications(collections, options = {}) {
const { ap_notifications } = collections;
const parsedLimit = Number.parseInt(options.limit, 10);
const limit = Math.min(
Number.isFinite(parsedLimit) && parsedLimit > 0 ? parsedLimit : 20,
100,
);
const query = {};
// Unread filter
if (options.unreadOnly) {
query.read = false;
}
// Cursor pagination
if (options.before) {
query.published = { $lt: new Date(options.before) };
}
const rawItems = await ap_notifications
.find(query)
.sort({ published: -1 })
.limit(limit)
.toArray();
// Normalize published dates to ISO strings for Nunjucks | date filter
const items = rawItems.map((item) => ({
...item,
published: item.published instanceof Date
? item.published.toISOString()
: item.published,
}));
// Generate cursor for next page
const before =
items.length > 0
? items[items.length - 1].published
: null;
return {
items,
before,
};
}
/**
* Get count of unread notifications
* @param {object} collections - MongoDB collections
* @returns {Promise<number>} Unread notification count
*/
export async function getUnreadNotificationCount(collections) {
const { ap_notifications } = collections;
return await ap_notifications.countDocuments({ read: false });
}
/**
* Mark notifications as read
* @param {object} collections - MongoDB collections
* @param {string[]} uids - Notification UIDs to mark read
* @returns {Promise<object>} Update result
*/
export async function markNotificationsRead(collections, uids) {
const { ap_notifications } = collections;
return await ap_notifications.updateMany(
{ uid: { $in: uids } },
{ $set: { read: true } },
);
}
/**
* Mark all notifications as read
* @param {object} collections - MongoDB collections
* @returns {Promise<object>} Update result
*/
export async function markAllNotificationsRead(collections) {
const { ap_notifications } = collections;
return await ap_notifications.updateMany({}, { $set: { read: true } });
}

210
lib/storage/timeline.js Normal file
View File

@@ -0,0 +1,210 @@
/**
* Timeline item storage operations
* @module storage/timeline
*/
/**
* Add a timeline item (uses atomic upsert for deduplication)
* @param {object} collections - MongoDB collections
* @param {object} item - Timeline item data
* @param {string} item.uid - Canonical AP object URL (dedup key)
* @param {string} item.type - "note" | "article" | "boost"
* @param {string} item.url - Post URL
* @param {string} [item.name] - Post title (articles only)
* @param {object} item.content - { text, html }
* @param {string} [item.summary] - Content warning text
* @param {boolean} item.sensitive - Sensitive content flag
* @param {Date} item.published - Published date (kept as Date for sort queries)
* @param {object} item.author - { name, url, photo, handle }
* @param {string[]} item.category - Tags/categories
* @param {string[]} item.photo - Photo URLs
* @param {string[]} item.video - Video URLs
* @param {string[]} item.audio - Audio URLs
* @param {string} [item.inReplyTo] - Parent post URL
* @param {object} [item.boostedBy] - { name, url, photo, handle } for boosts
* @param {Date} [item.boostedAt] - Boost timestamp
* @param {string} [item.originalUrl] - Original post URL for boosts
* @param {string} item.createdAt - ISO string creation timestamp
* @returns {Promise<object>} Created or existing item
*/
export async function addTimelineItem(collections, item) {
const { ap_timeline } = collections;
const result = await ap_timeline.updateOne(
{ uid: item.uid },
{
$setOnInsert: {
...item,
readBy: [],
},
},
{ upsert: true },
);
if (result.upsertedCount > 0) {
return await ap_timeline.findOne({ uid: item.uid });
}
// Return existing document if it was a duplicate
return await ap_timeline.findOne({ uid: item.uid });
}
/**
* Get timeline items with cursor-based pagination
* @param {object} collections - MongoDB collections
* @param {object} options - Query options
* @param {string} [options.before] - Before cursor (published date)
* @param {string} [options.after] - After cursor (published date)
* @param {number} [options.limit=20] - Items per page
* @param {string} [options.type] - Filter by type
* @param {string} [options.authorUrl] - Filter by author URL
* @returns {Promise<object>} { items, before, after }
*/
export async function getTimelineItems(collections, options = {}) {
const { ap_timeline } = collections;
const parsedLimit = Number.parseInt(options.limit, 10);
const limit = Math.min(
Number.isFinite(parsedLimit) && parsedLimit > 0 ? parsedLimit : 20,
100,
);
const query = {};
// Type filter
if (options.type) {
query.type = options.type;
}
// Author filter (for profile view) — validate string type to prevent operator injection
if (options.authorUrl) {
if (typeof options.authorUrl !== "string") {
throw new Error("Invalid authorUrl");
}
query["author.url"] = options.authorUrl;
}
// Cursor pagination — validate dates
if (options.before) {
const beforeDate = new Date(options.before);
if (Number.isNaN(beforeDate.getTime())) {
throw new Error("Invalid before cursor");
}
query.published = { $lt: beforeDate };
} else if (options.after) {
const afterDate = new Date(options.after);
if (Number.isNaN(afterDate.getTime())) {
throw new Error("Invalid after cursor");
}
query.published = { $gt: afterDate };
}
const rawItems = await ap_timeline
.find(query)
.sort({ published: -1 })
.limit(limit)
.toArray();
// Normalize published dates to ISO strings for Nunjucks | date filter
const items = rawItems.map((item) => ({
...item,
published: item.published instanceof Date
? item.published.toISOString()
: item.published,
}));
// Generate cursors for pagination
const before =
items.length > 0
? items[0].published
: null;
const after =
items.length > 0
? items[items.length - 1].published
: null;
return {
items,
before,
after,
};
}
/**
* Get a single timeline item by UID
* @param {object} collections - MongoDB collections
* @param {string} uid - Item UID (canonical URL)
* @returns {Promise<object|null>} Timeline item or null
*/
export async function getTimelineItem(collections, uid) {
const { ap_timeline } = collections;
return await ap_timeline.findOne({ uid });
}
/**
* Delete a timeline item by UID
* @param {object} collections - MongoDB collections
* @param {string} uid - Item UID
* @returns {Promise<object>} Delete result
*/
export async function deleteTimelineItem(collections, uid) {
const { ap_timeline } = collections;
return await ap_timeline.deleteOne({ uid });
}
/**
* Update a timeline item's content (for Update activities)
* @param {object} collections - MongoDB collections
* @param {string} uid - Item UID
* @param {object} updates - Fields to update
* @param {object} [updates.content] - New content
* @param {string} [updates.name] - New title
* @param {string} [updates.summary] - New content warning
* @param {boolean} [updates.sensitive] - New sensitive flag
* @returns {Promise<object>} Update result
*/
export async function updateTimelineItem(collections, uid, updates) {
const { ap_timeline } = collections;
return await ap_timeline.updateOne({ uid }, { $set: updates });
}
/**
* Delete timeline items older than a cutoff date (retention cleanup)
* @param {object} collections - MongoDB collections
* @param {Date} cutoffDate - Delete items published before this date
* @returns {Promise<number>} Number of items deleted
*/
export async function deleteOldTimelineItems(collections, cutoffDate) {
const { ap_timeline } = collections;
const result = await ap_timeline.deleteMany({ published: { $lt: cutoffDate } });
return result.deletedCount;
}
/**
* Delete timeline items by count-based retention (keep N newest)
* @param {object} collections - MongoDB collections
* @param {number} keepCount - Number of items to keep
* @returns {Promise<number>} Number of items deleted
*/
export async function cleanupTimelineByCount(collections, keepCount) {
const { ap_timeline } = collections;
// Find the Nth newest item's published date
const items = await ap_timeline
.find({})
.sort({ published: -1 })
.skip(keepCount)
.limit(1)
.toArray();
if (items.length === 0) {
return 0; // Fewer than keepCount items exist
}
const cutoffDate = items[0].published;
return await deleteOldTimelineItems(collections, cutoffDate);
}

88
lib/timeline-cleanup.js Normal file
View File

@@ -0,0 +1,88 @@
/**
* Timeline retention cleanup — removes old timeline items to prevent
* unbounded collection growth and cleans up stale interaction tracking.
*/
/**
* Remove timeline items beyond the retention limit and clean up
* corresponding ap_interactions entries.
*
* Uses aggregation to identify exact items to delete by UID,
* avoiding race conditions between finding and deleting.
*
* @param {object} collections - MongoDB collections
* @param {number} retentionLimit - Max number of timeline items to keep
* @returns {Promise<{removed: number, interactionsRemoved: number}>}
*/
export async function cleanupTimeline(collections, retentionLimit) {
if (!collections.ap_timeline || retentionLimit <= 0) {
return { removed: 0, interactionsRemoved: 0 };
}
const totalCount = await collections.ap_timeline.countDocuments();
if (totalCount <= retentionLimit) {
return { removed: 0, interactionsRemoved: 0 };
}
// Use aggregation to get exact UIDs beyond the retention limit.
// This avoids race conditions: we delete by UID, not by date.
const toDelete = await collections.ap_timeline
.aggregate([
{ $sort: { published: -1 } },
{ $skip: retentionLimit },
{ $project: { uid: 1 } },
])
.toArray();
if (!toDelete.length) {
return { removed: 0, interactionsRemoved: 0 };
}
const removedUids = toDelete.map((item) => item.uid).filter(Boolean);
// Delete old timeline items by UID
const deleteResult = await collections.ap_timeline.deleteMany({
_id: { $in: toDelete.map((item) => item._id) },
});
// Clean up stale interactions for removed items
let interactionsRemoved = 0;
if (removedUids.length > 0 && collections.ap_interactions) {
const interactionResult = await collections.ap_interactions.deleteMany({
objectUrl: { $in: removedUids },
});
interactionsRemoved = interactionResult.deletedCount || 0;
}
const removed = deleteResult.deletedCount || 0;
if (removed > 0) {
console.info(
`[ActivityPub] Timeline cleanup: removed ${removed} items, ${interactionsRemoved} stale interactions`,
);
}
return { removed, interactionsRemoved };
}
/**
* Schedule periodic timeline cleanup.
*
* @param {object} collections - MongoDB collections
* @param {number} retentionLimit - Max number of timeline items to keep
* @param {number} intervalMs - Cleanup interval in milliseconds (default: 24 hours)
* @returns {NodeJS.Timeout} The interval timer (for cleanup if needed)
*/
export function scheduleCleanup(collections, retentionLimit, intervalMs = 86_400_000) {
// Run immediately on startup
cleanupTimeline(collections, retentionLimit).catch((error) => {
console.error("[ActivityPub] Timeline cleanup failed:", error.message);
});
// Then run periodically
return setInterval(() => {
cleanupTimeline(collections, retentionLimit).catch((error) => {
console.error("[ActivityPub] Timeline cleanup failed:", error.message);
});
}, intervalMs);
}

207
lib/timeline-store.js Normal file
View File

@@ -0,0 +1,207 @@
/**
* Timeline item extraction helpers
* @module timeline-store
*/
import sanitizeHtml from "sanitize-html";
/**
* Sanitize HTML content for safe display
* @param {string} html - Raw HTML content
* @returns {string} Sanitized HTML
*/
export function sanitizeContent(html) {
if (!html) return "";
return sanitizeHtml(html, {
allowedTags: [
"p", "br", "a", "strong", "em", "ul", "ol", "li",
"blockquote", "code", "pre", "h1", "h2", "h3", "h4", "h5", "h6",
"span", "div", "img"
],
allowedAttributes: {
a: ["href", "rel", "class"],
img: ["src", "alt", "class"],
span: ["class"],
div: ["class"]
},
allowedSchemes: ["http", "https", "mailto"],
allowedSchemesByTag: {
img: ["http", "https", "data"]
}
});
}
/**
* Extract actor information from Fedify Person/Application/Service object
* @param {object} actor - Fedify actor object
* @returns {object} { name, url, photo, handle }
*/
export async function extractActorInfo(actor) {
if (!actor) {
return {
name: "Unknown",
url: "",
photo: "",
handle: ""
};
}
const rawName = actor.name?.toString() || actor.preferredUsername?.toString() || "Unknown";
// Strip all HTML from actor names to prevent stored XSS
const name = sanitizeHtml(rawName, { allowedTags: [], allowedAttributes: {} });
const url = actor.id?.href || "";
// Extract photo URL from icon (Fedify uses async getters)
let photo = "";
try {
if (typeof actor.getIcon === "function") {
const iconObj = await actor.getIcon();
photo = iconObj?.url?.href || "";
} else {
const iconObj = await actor.icon;
photo = iconObj?.url?.href || "";
}
} catch {
// No icon available
}
// Extract handle from actor URL
let handle = "";
try {
const actorUrl = new URL(url);
const username = actor.preferredUsername?.toString() || "";
if (username) {
handle = `@${username}@${actorUrl.hostname}`;
}
} catch {
// Invalid URL, keep handle empty
}
return { name, url, photo, handle };
}
/**
* Extract timeline item data from Fedify Note/Article object
* @param {object} object - Fedify Note or Article object
* @param {object} options - Extraction options
* @param {object} [options.boostedBy] - Actor info for boosts
* @param {Date} [options.boostedAt] - Boost timestamp
* @returns {Promise<object>} Timeline item data
*/
export async function extractObjectData(object, options = {}) {
if (!object) {
throw new Error("Object is required");
}
const uid = object.id?.href || "";
const url = object.url?.href || uid;
// Determine type
let type = "note";
if (object.type?.toLowerCase() === "article") {
type = "article";
}
if (options.boostedBy) {
type = "boost";
}
// Extract content
const contentHtml = object.content?.toString() || "";
const contentText = object.source?.content?.toString() || contentHtml.replace(/<[^>]*>/g, "");
const content = {
text: contentText,
html: sanitizeContent(contentHtml)
};
// Extract name (articles only)
const name = type === "article" ? (object.name?.toString() || "") : "";
// Content warning / summary
const summary = object.summary?.toString() || "";
const sensitive = object.sensitive || false;
// Published date — store as ISO string per Indiekit convention
const published = object.published
? new Date(object.published).toISOString()
: new Date().toISOString();
// Extract author — use async getAttributedTo() for Fedify objects
let authorObj = null;
try {
if (typeof object.getAttributedTo === "function") {
const attr = await object.getAttributedTo();
authorObj = Array.isArray(attr) ? attr[0] : attr;
}
} catch {
// Fallback: try direct property access for plain objects
authorObj = object.attribution || object.attributedTo || null;
}
const author = await extractActorInfo(authorObj);
// Extract tags/categories
const category = [];
if (object.tag) {
const tags = Array.isArray(object.tag) ? object.tag : [object.tag];
for (const tag of tags) {
if (tag.type === "Hashtag" && tag.name) {
category.push(tag.name.toString().replace(/^#/, ""));
}
}
}
// Extract media attachments
const photo = [];
const video = [];
const audio = [];
if (object.attachment) {
const attachments = Array.isArray(object.attachment) ? object.attachment : [object.attachment];
for (const att of attachments) {
const mediaUrl = att.url?.href || "";
if (!mediaUrl) continue;
const mediaType = att.mediaType?.toLowerCase() || "";
if (mediaType.startsWith("image/")) {
photo.push(mediaUrl);
} else if (mediaType.startsWith("video/")) {
video.push(mediaUrl);
} else if (mediaType.startsWith("audio/")) {
audio.push(mediaUrl);
}
}
}
// In-reply-to
const inReplyTo = object.inReplyTo?.href || "";
// Build base timeline item
const item = {
uid,
type,
url,
name,
content,
summary,
sensitive,
published,
author,
category,
photo,
video,
audio,
inReplyTo,
createdAt: new Date().toISOString()
};
// Add boost metadata if this is a boost
if (options.boostedBy) {
item.boostedBy = options.boostedBy;
item.boostedAt = options.boostedAt || new Date().toISOString();
item.originalUrl = url;
}
return item;
}

View File

@@ -49,7 +49,16 @@
"authorizedFetchLabel": "Require authorized fetch (secure mode)",
"authorizedFetchHint": "When enabled, only servers with valid HTTP Signatures can fetch your actor and collections. This improves privacy but may reduce compatibility with some clients.",
"save": "Save profile",
"saved": "Profile saved. Changes are now visible to the fediverse."
"saved": "Profile saved. Changes are now visible to the fediverse.",
"remote": {
"follow": "Follow",
"unfollow": "Unfollow",
"viewOn": "View on",
"postsTitle": "Posts",
"noPosts": "No posts from this account yet.",
"followToSee": "Follow this account to see their posts in your timeline.",
"notFound": "Could not find this account. It may have been deleted or the server may be unavailable."
}
},
"migrate": {
"title": "Mastodon migration",
@@ -94,6 +103,80 @@
"paused": "Paused",
"completed": "Completed"
}
},
"moderation": {
"title": "Moderation",
"blockedTitle": "Blocked accounts",
"mutedActorsTitle": "Muted accounts",
"mutedKeywordsTitle": "Muted keywords",
"noBlocked": "No blocked accounts.",
"noMutedActors": "No muted accounts.",
"noMutedKeywords": "No muted keywords.",
"unblock": "Unblock",
"unmute": "Unmute",
"addKeywordTitle": "Add muted keyword",
"keywordPlaceholder": "Enter keyword or phrase…",
"addKeyword": "Add",
"muteActor": "Mute",
"blockActor": "Block"
},
"compose": {
"title": "Compose reply",
"modeLabel": "Reply mode",
"modeMicropub": "Post as blog reply",
"modeMicropubHint": "Creates a permanent post on your blog, syndicated to the fediverse",
"modeQuick": "Quick reply",
"modeQuickHint": "Sends a reply directly to the fediverse (no blog post created)",
"placeholder": "Write your reply…",
"syndicateLabel": "Syndicate to",
"submitMicropub": "Post reply",
"submitQuick": "Send reply",
"cancel": "Cancel",
"errorEmpty": "Reply content cannot be empty"
},
"notifications": {
"title": "Notifications",
"empty": "No notifications yet. Interactions from other fediverse users will appear here.",
"liked": "liked your post",
"boostedPost": "boosted your post",
"followedYou": "followed you",
"repliedTo": "replied to your post",
"mentionedYou": "mentioned you"
},
"reader": {
"title": "Reader",
"tabs": {
"all": "All",
"notes": "Notes",
"articles": "Articles",
"replies": "Replies",
"boosts": "Boosts",
"media": "Media"
},
"pagination": {
"newer": "← Newer",
"older": "Older →"
},
"empty": "Your timeline is empty. Follow some accounts to see their posts here.",
"boosted": "boosted",
"replyingTo": "Replying to",
"showContent": "Show content",
"hideContent": "Hide content",
"sensitiveContent": "Sensitive content",
"videoNotSupported": "Your browser does not support the video element.",
"audioNotSupported": "Your browser does not support the audio element.",
"actions": {
"reply": "Reply",
"boost": "Boost",
"unboost": "Undo boost",
"like": "Like",
"unlike": "Unlike",
"viewOriginal": "View original",
"liked": "Liked",
"boosted": "Boosted",
"likeError": "Could not like this post",
"boostError": "Could not boost this post"
}
}
}
}

204
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "@rmdes/indiekit-endpoint-activitypub",
"version": "1.0.21",
"version": "1.0.29",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@rmdes/indiekit-endpoint-activitypub",
"version": "1.0.21",
"version": "1.0.29",
"license": "MIT",
"dependencies": {
"@fedify/express": "^1.10.3",
@@ -14,7 +14,8 @@
"@fedify/redis": "^1.10.3",
"@js-temporal/polyfill": "^0.5.0",
"express": "^5.0.0",
"ioredis": "^5.9.3"
"ioredis": "^5.9.3",
"sanitize-html": "^2.13.1"
},
"engines": {
"node": ">=22"
@@ -438,6 +439,15 @@
}
}
},
"node_modules/deepmerge": {
"version": "4.3.1",
"resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz",
"integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/denque": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz",
@@ -456,6 +466,61 @@
"node": ">= 0.8"
}
},
"node_modules/dom-serializer": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz",
"integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==",
"license": "MIT",
"dependencies": {
"domelementtype": "^2.3.0",
"domhandler": "^5.0.2",
"entities": "^4.2.0"
},
"funding": {
"url": "https://github.com/cheeriojs/dom-serializer?sponsor=1"
}
},
"node_modules/domelementtype": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz",
"integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/fb55"
}
],
"license": "BSD-2-Clause"
},
"node_modules/domhandler": {
"version": "5.0.3",
"resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz",
"integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==",
"license": "BSD-2-Clause",
"dependencies": {
"domelementtype": "^2.3.0"
},
"engines": {
"node": ">= 4"
},
"funding": {
"url": "https://github.com/fb55/domhandler?sponsor=1"
}
},
"node_modules/domutils": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz",
"integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==",
"license": "BSD-2-Clause",
"dependencies": {
"dom-serializer": "^2.0.0",
"domelementtype": "^2.3.0",
"domhandler": "^5.0.3"
},
"funding": {
"url": "https://github.com/fb55/domutils?sponsor=1"
}
},
"node_modules/dunder-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
@@ -485,6 +550,18 @@
"node": ">= 0.8"
}
},
"node_modules/entities": {
"version": "4.5.0",
"resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz",
"integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==",
"license": "BSD-2-Clause",
"engines": {
"node": ">=0.12"
},
"funding": {
"url": "https://github.com/fb55/entities?sponsor=1"
}
},
"node_modules/es-define-property": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
@@ -531,6 +608,18 @@
"integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==",
"license": "MIT"
},
"node_modules/escape-string-regexp": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz",
"integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==",
"license": "MIT",
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/etag": {
"version": "1.8.1",
"resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz",
@@ -704,6 +793,25 @@
"node": ">= 0.4"
}
},
"node_modules/htmlparser2": {
"version": "8.0.2",
"resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-8.0.2.tgz",
"integrity": "sha512-GYdjWKDkbRLkZ5geuHs5NY1puJ+PXwP7+fHPRz06Eirsb9ugf6d8kkXav6ADhcODhFFPMIXyxkxSuMf3D6NCFA==",
"funding": [
"https://github.com/fb55/htmlparser2?sponsor=1",
{
"type": "github",
"url": "https://github.com/sponsors/fb55"
}
],
"license": "MIT",
"dependencies": {
"domelementtype": "^2.3.0",
"domhandler": "^5.0.3",
"domutils": "^3.0.1",
"entities": "^4.4.0"
}
},
"node_modules/http-errors": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
@@ -779,6 +887,15 @@
"node": ">= 0.10"
}
},
"node_modules/is-plain-object": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-5.0.0.tgz",
"integrity": "sha512-VRSzKkbMm5jMDoKLbltAkFQ5Qr7VDiTFGXxYFXXowVj387GeGNOCsOH6Msy00SGZ3Fp84b1Naa1psqgcCIEP5Q==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/is-promise": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz",
@@ -926,6 +1043,24 @@
"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",
"integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/ai"
}
],
"license": "MIT",
"bin": {
"nanoid": "bin/nanoid.cjs"
},
"engines": {
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
}
},
"node_modules/negotiator": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz",
@@ -968,6 +1103,12 @@
"wrappy": "1"
}
},
"node_modules/parse-srcset": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/parse-srcset/-/parse-srcset-1.0.2.tgz",
"integrity": "sha512-/2qh0lav6CmI15FzA3i/2Bzk2zCgQhGMkvhOhKNcBVQ1ldgpbfiNTVslmooUmWJcADi1f1kIeynbDRVzNlfR6Q==",
"license": "MIT"
},
"node_modules/parseurl": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
@@ -987,6 +1128,12 @@
"url": "https://opencollective.com/express"
}
},
"node_modules/picocolors": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
"integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
"license": "ISC"
},
"node_modules/pkijs": {
"version": "3.3.3",
"resolved": "https://registry.npmjs.org/pkijs/-/pkijs-3.3.3.tgz",
@@ -1004,6 +1151,34 @@
"node": ">=16.0.0"
}
},
"node_modules/postcss": {
"version": "8.5.6",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz",
"integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==",
"funding": [
{
"type": "opencollective",
"url": "https://opencollective.com/postcss/"
},
{
"type": "tidelift",
"url": "https://tidelift.com/funding/github/npm/postcss"
},
{
"type": "github",
"url": "https://github.com/sponsors/ai"
}
],
"license": "MIT",
"dependencies": {
"nanoid": "^3.3.11",
"picocolors": "^1.1.1",
"source-map-js": "^1.2.1"
},
"engines": {
"node": "^10 || ^12 || >=14"
}
},
"node_modules/proxy-addr": {
"version": "2.0.7",
"resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
@@ -1129,6 +1304,20 @@
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
"license": "MIT"
},
"node_modules/sanitize-html": {
"version": "2.17.1",
"resolved": "https://registry.npmjs.org/sanitize-html/-/sanitize-html-2.17.1.tgz",
"integrity": "sha512-ehFCW+q1a4CSOWRAdX97BX/6/PDEkCqw7/0JXZAGQV57FQB3YOkTa/rrzHPeJ+Aghy4vZAFfWMYyfxIiB7F/gw==",
"license": "MIT",
"dependencies": {
"deepmerge": "^4.2.2",
"escape-string-regexp": "^4.0.0",
"htmlparser2": "^8.0.0",
"is-plain-object": "^5.0.0",
"parse-srcset": "^1.0.2",
"postcss": "^8.3.11"
}
},
"node_modules/send": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz",
@@ -1258,6 +1447,15 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/source-map-js": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
"integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
"license": "BSD-3-Clause",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/standard-as-callback": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/standard-as-callback/-/standard-as-callback-2.1.0.tgz",

View File

@@ -1,6 +1,6 @@
{
"name": "@rmdes/indiekit-endpoint-activitypub",
"version": "1.0.29",
"version": "1.1.0",
"description": "ActivityPub federation endpoint for Indiekit via Fedify. Adds full fediverse support: actor, inbox, outbox, followers, following, syndication, and Mastodon migration.",
"keywords": [
"indiekit",
@@ -42,7 +42,8 @@
"@fedify/redis": "^1.10.3",
"@js-temporal/polyfill": "^0.5.0",
"express": "^5.0.0",
"ioredis": "^5.9.3"
"ioredis": "^5.9.3",
"sanitize-html": "^2.13.1"
},
"peerDependencies": {
"@indiekit/error": "^1.0.0-beta.25",

View File

@@ -0,0 +1,94 @@
{% extends "layouts/reader.njk" %}
{% from "heading/macro.njk" import heading with context %}
{% block content %}
{{ heading({
text: title,
level: 1,
parent: { text: __("activitypub.reader.title"), href: mountPath + "/admin/reader" }
}) }}
{# Reply context — show the post being replied to #}
{% if replyContext %}
<div class="ap-compose__context">
<div class="ap-compose__context-label">{{ __("activitypub.reader.replyingTo") }}</div>
{% if replyContext.author %}
<div class="ap-compose__context-author">
<a href="{{ replyContext.author.url }}">{{ replyContext.author.name }}</a>
</div>
{% endif %}
{% if replyContext.content and replyContext.content.text %}
<blockquote class="ap-compose__context-text">
{{ replyContext.content.text | truncate(300) }}
</blockquote>
{% endif %}
<a href="{{ replyTo }}" class="ap-compose__context-link" target="_blank" rel="noopener">{{ replyTo }}</a>
</div>
{% endif %}
<form method="post" action="{{ mountPath }}/admin/reader/compose" class="ap-compose__form"
x-data="{
mode: 'micropub',
content: '',
maxChars: 500,
get remaining() { return this.maxChars - this.content.length; }
}">
<input type="hidden" name="_csrf" value="{{ csrfToken }}">
{% if replyTo %}
<input type="hidden" name="in-reply-to" value="{{ replyTo }}">
{% endif %}
{# Mode toggle #}
<fieldset class="ap-compose__mode">
<legend>{{ __("activitypub.compose.modeLabel") }}</legend>
<label class="ap-compose__mode-option">
<input type="radio" name="mode" value="micropub" x-model="mode" checked>
{{ __("activitypub.compose.modeMicropub") }}
<span class="ap-compose__mode-hint">{{ __("activitypub.compose.modeMicropubHint") }}</span>
</label>
<label class="ap-compose__mode-option">
<input type="radio" name="mode" value="quick" x-model="mode">
{{ __("activitypub.compose.modeQuick") }}
<span class="ap-compose__mode-hint">{{ __("activitypub.compose.modeQuickHint") }}</span>
</label>
</fieldset>
{# Content textarea #}
<div class="ap-compose__editor">
<textarea name="content" class="ap-compose__textarea"
rows="6"
:maxlength="mode === 'quick' ? maxChars : undefined"
x-model="content"
placeholder="{{ __('activitypub.compose.placeholder') }}"
required></textarea>
<div class="ap-compose__counter" x-show="mode === 'quick'" x-cloak>
<span :class="{ 'ap-compose__counter--warn': remaining < 50, 'ap-compose__counter--over': remaining < 0 }"
x-text="remaining"></span>
</div>
</div>
{# Syndication targets (Micropub mode only) #}
{% if syndicationTargets.length > 0 %}
<fieldset class="ap-compose__syndication" x-show="mode === 'micropub'">
<legend>{{ __("activitypub.compose.syndicateLabel") }}</legend>
{% for target in syndicationTargets %}
<label class="ap-compose__syndication-target">
<input type="checkbox" name="mp-syndicate-to" value="{{ target.uid }}" checked>
{{ target.name }}
</label>
{% endfor %}
</fieldset>
{% endif %}
<div class="ap-compose__actions">
<button type="submit" class="ap-compose__submit">
<span x-show="mode === 'micropub'">{{ __("activitypub.compose.submitMicropub") }}</span>
<span x-show="mode === 'quick'">{{ __("activitypub.compose.submitQuick") }}</span>
</button>
<a href="{{ mountPath }}/admin/reader" class="ap-compose__cancel">
{{ __("activitypub.compose.cancel") }}
</a>
</div>
</form>
{% endblock %}

View File

@@ -0,0 +1,118 @@
{% extends "layouts/reader.njk" %}
{% from "heading/macro.njk" import heading with context %}
{% from "prose/macro.njk" import prose with context %}
{% block content %}
{{ heading({
text: title,
level: 1,
parent: { text: __("activitypub.reader.title"), href: mountPath + "/admin/reader" }
}) }}
{# Blocked actors #}
<section class="ap-moderation__section">
<h2>{{ __("activitypub.moderation.blockedTitle") }}</h2>
{% if blocked.length > 0 %}
<ul class="ap-moderation__list">
{% for entry in blocked %}
<li class="ap-moderation__entry"
x-data="{ removing: false }">
<a href="{{ entry.url }}">{{ entry.url }}</a>
<button class="ap-moderation__remove"
:disabled="removing"
@click="
removing = true;
fetch('{{ mountPath }}/admin/reader/unblock', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': '{{ csrfToken }}' },
body: JSON.stringify({ url: '{{ entry.url }}' })
}).then(r => r.json()).then(d => { if (d.success) $el.closest('li').remove(); else removing = false; }).catch(() => removing = false);
">{{ __("activitypub.moderation.unblock") }}</button>
</li>
{% endfor %}
</ul>
{% else %}
{{ prose({ text: __("activitypub.moderation.noBlocked") }) }}
{% endif %}
</section>
{# Muted actors #}
<section class="ap-moderation__section">
<h2>{{ __("activitypub.moderation.mutedActorsTitle") }}</h2>
{% set mutedActors = muted | selectattr("url") %}
{% if mutedActors | length > 0 %}
<ul class="ap-moderation__list">
{% for entry in mutedActors %}
<li class="ap-moderation__entry"
x-data="{ removing: false }">
<a href="{{ entry.url }}">{{ entry.url }}</a>
<button class="ap-moderation__remove"
:disabled="removing"
@click="
removing = true;
fetch('{{ mountPath }}/admin/reader/unmute', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': '{{ csrfToken }}' },
body: JSON.stringify({ url: '{{ entry.url }}' })
}).then(r => r.json()).then(d => { if (d.success) $el.closest('li').remove(); else removing = false; }).catch(() => removing = false);
">{{ __("activitypub.moderation.unmute") }}</button>
</li>
{% endfor %}
</ul>
{% else %}
{{ prose({ text: __("activitypub.moderation.noMutedActors") }) }}
{% endif %}
</section>
{# Muted keywords #}
<section class="ap-moderation__section">
<h2>{{ __("activitypub.moderation.mutedKeywordsTitle") }}</h2>
{% set mutedKeywords = muted | selectattr("keyword") %}
{% if mutedKeywords | length > 0 %}
<ul class="ap-moderation__list">
{% for entry in mutedKeywords %}
<li class="ap-moderation__entry"
x-data="{ removing: false }">
<code>{{ entry.keyword }}</code>
<button class="ap-moderation__remove"
:disabled="removing"
@click="
removing = true;
fetch('{{ mountPath }}/admin/reader/unmute', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': '{{ csrfToken }}' },
body: JSON.stringify({ keyword: '{{ entry.keyword }}' })
}).then(r => r.json()).then(d => { if (d.success) $el.closest('li').remove(); else removing = false; }).catch(() => removing = false);
">{{ __("activitypub.moderation.unmute") }}</button>
</li>
{% endfor %}
</ul>
{% else %}
{{ prose({ text: __("activitypub.moderation.noMutedKeywords") }) }}
{% endif %}
</section>
{# Add keyword mute form #}
<section class="ap-moderation__section">
<h2>{{ __("activitypub.moderation.addKeywordTitle") }}</h2>
<form class="ap-moderation__add-form"
x-data="{ keyword: '', submitting: false }"
@submit.prevent="
if (!keyword.trim()) return;
submitting = true;
fetch('{{ mountPath }}/admin/reader/mute', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': '{{ csrfToken }}' },
body: JSON.stringify({ keyword: keyword.trim() })
}).then(r => r.json()).then(d => { if (d.success) location.reload(); submitting = false; }).catch(() => submitting = false);
">
<input type="text" x-model="keyword"
placeholder="{{ __('activitypub.moderation.keywordPlaceholder') }}"
class="ap-moderation__input">
<button type="submit" :disabled="submitting" class="ap-moderation__add-btn">
{{ __("activitypub.moderation.addKeyword") }}
</button>
</form>
</section>
{% endblock %}

View File

@@ -0,0 +1,31 @@
{% extends "layouts/reader.njk" %}
{% from "heading/macro.njk" import heading with context %}
{% from "prose/macro.njk" import prose with context %}
{% block content %}
{{ heading({
text: __("activitypub.notifications.title"),
level: 1,
parent: { text: __("activitypub.reader.title"), href: mountPath + "/admin/reader" }
}) }}
{% if items.length > 0 %}
<div class="ap-timeline">
{% for item in items %}
{% include "partials/ap-notification-card.njk" %}
{% endfor %}
</div>
{# Pagination #}
{% if before %}
<nav class="ap-pagination">
<a href="?before={{ before }}" class="ap-pagination__next">
{{ __("activitypub.reader.pagination.older") }}
</a>
</nav>
{% endif %}
{% else %}
{{ prose({ text: __("activitypub.notifications.empty") }) }}
{% endif %}
{% endblock %}

View File

@@ -0,0 +1,61 @@
{% extends "layouts/reader.njk" %}
{% from "heading/macro.njk" import heading with context %}
{% from "prose/macro.njk" import prose with context %}
{% block content %}
{{ heading({
text: __("activitypub.reader.title"),
level: 1,
parent: { text: __("activitypub.title"), href: mountPath }
}) }}
{# Tab navigation #}
<nav class="ap-tabs" role="tablist">
<a href="?tab=all" class="ap-tab{% if tab == 'all' %} ap-tab--active{% endif %}" role="tab">
{{ __("activitypub.reader.tabs.all") }}
</a>
<a href="?tab=notes" class="ap-tab{% if tab == 'notes' %} ap-tab--active{% endif %}" role="tab">
{{ __("activitypub.reader.tabs.notes") }}
</a>
<a href="?tab=articles" class="ap-tab{% if tab == 'articles' %} ap-tab--active{% endif %}" role="tab">
{{ __("activitypub.reader.tabs.articles") }}
</a>
<a href="?tab=replies" class="ap-tab{% if tab == 'replies' %} ap-tab--active{% endif %}" role="tab">
{{ __("activitypub.reader.tabs.replies") }}
</a>
<a href="?tab=boosts" class="ap-tab{% if tab == 'boosts' %} ap-tab--active{% endif %}" role="tab">
{{ __("activitypub.reader.tabs.boosts") }}
</a>
<a href="?tab=media" class="ap-tab{% if tab == 'media' %} ap-tab--active{% endif %}" role="tab">
{{ __("activitypub.reader.tabs.media") }}
</a>
</nav>
{# Timeline items #}
{% if items.length > 0 %}
<div class="ap-timeline">
{% for item in items %}
{% include "partials/ap-item-card.njk" %}
{% endfor %}
</div>
{# Pagination #}
{% if before or after %}
<nav class="ap-pagination">
{% if after %}
<a href="?tab={{ tab }}&after={{ after }}" class="ap-pagination__prev">
{{ __("activitypub.reader.pagination.newer") }}
</a>
{% endif %}
{% if before %}
<a href="?tab={{ tab }}&before={{ before }}" class="ap-pagination__next">
{{ __("activitypub.reader.pagination.older") }}
</a>
{% endif %}
</nav>
{% endif %}
{% else %}
{{ prose({ text: __("activitypub.reader.empty") }) }}
{% endif %}
{% endblock %}

View File

@@ -0,0 +1,117 @@
{% extends "layouts/reader.njk" %}
{% from "heading/macro.njk" import heading with context %}
{% from "prose/macro.njk" import prose with context %}
{% block content %}
{{ heading({
text: title,
level: 1,
parent: { text: __("activitypub.reader.title"), href: mountPath + "/admin/reader" }
}) }}
<div class="ap-profile"
x-data="{
following: {{ 'true' if isFollowing else 'false' }},
muted: {{ 'true' if isMuted else 'false' }},
blocked: {{ 'true' if isBlocked else 'false' }},
loading: false,
async action(endpoint, body) {
if (this.loading) return;
this.loading = true;
try {
const res = await fetch('{{ mountPath }}/admin/reader/' + endpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': '{{ csrfToken }}'
},
body: JSON.stringify(body)
});
const data = await res.json();
return data.success;
} catch { return false; }
finally { this.loading = false; }
}
}">
{# Header image #}
{% if image %}
<div class="ap-profile__header">
<img src="{{ image }}" alt="" class="ap-profile__header-img">
</div>
{% endif %}
{# Profile info #}
<div class="ap-profile__info">
<div class="ap-profile__avatar-wrap">
{% if icon %}
<img src="{{ icon }}" alt="{{ name }}" class="ap-profile__avatar">
{% else %}
<div class="ap-profile__avatar ap-profile__avatar--placeholder">{{ name[0] }}</div>
{% endif %}
</div>
<div class="ap-profile__details">
<h2 class="ap-profile__name">{{ name }}</h2>
{% if actorHandle %}
<div class="ap-profile__handle">@{{ actorHandle }}@{{ instanceHost }}</div>
{% endif %}
{% if summary %}
<div class="ap-profile__bio">{{ summary | safe }}</div>
{% endif %}
</div>
{# Action buttons #}
<div class="ap-profile__actions">
<button class="ap-profile__action ap-profile__action--follow"
:class="{ 'ap-profile__action--active': following }"
:disabled="loading"
@click="
const ok = await action(following ? 'unfollow' : 'follow', { url: '{{ actorUrl }}' });
if (ok) following = !following;
">
<span x-text="following ? '{{ __('activitypub.profile.remote.unfollow') }}' : '{{ __('activitypub.profile.remote.follow') }}'"></span>
</button>
<button class="ap-profile__action"
:disabled="loading"
@click="
const ok = await action(muted ? 'unmute' : 'mute', { url: '{{ actorUrl }}' });
if (ok) muted = !muted;
">
<span x-text="muted ? '{{ __('activitypub.moderation.unmute') }}' : '{{ __('activitypub.moderation.muteActor') }}'"></span>
</button>
<button class="ap-profile__action ap-profile__action--danger"
:disabled="loading"
@click="
const ok = await action(blocked ? 'unblock' : 'block', { url: '{{ actorUrl }}' });
if (ok) blocked = !blocked;
">
<span x-text="blocked ? '{{ __('activitypub.moderation.unblock') }}' : '{{ __('activitypub.moderation.blockActor') }}'"></span>
</button>
<a href="{{ actorUrl }}" class="ap-profile__action" target="_blank" rel="noopener">
{{ __("activitypub.profile.remote.viewOn") }} {{ instanceHost }}
</a>
</div>
</div>
{# Posts from this actor #}
<div class="ap-profile__posts">
<h3>{{ __("activitypub.profile.remote.postsTitle") }}</h3>
{% if posts.length > 0 %}
<div class="ap-timeline">
{% for item in posts %}
{% include "partials/ap-item-card.njk" %}
{% endfor %}
</div>
{% elif isFollowing %}
{{ prose({ text: __("activitypub.profile.remote.noPosts") }) }}
{% else %}
{{ prose({ text: __("activitypub.profile.remote.followToSee") }) }}
{% endif %}
</div>
</div>
{% endblock %}

9
views/layouts/reader.njk Normal file
View File

@@ -0,0 +1,9 @@
{% extends "document.njk" %}
{% block head %}
{# Alpine.js for client-side reactivity #}
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.14/dist/cdn.min.js"></script>
{# Reader stylesheet #}
<link rel="stylesheet" href="/assets/@rmdes-indiekit-endpoint-activitypub/reader.css">
{% endblock %}

View File

@@ -0,0 +1,157 @@
{# Timeline item card partial - reusable across timeline and profile views #}
<article class="ap-card">
{# Boost header if this is a boosted post #}
{% if item.type == "boost" and item.boostedBy %}
<div class="ap-card__boost">
🔁 <a href="{{ item.boostedBy.url }}">{{ item.boostedBy.name }}</a> {{ __("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="{{ item.inReplyTo }}">{{ item.inReplyTo }}</a>
</div>
{% endif %}
{# Author header #}
<header class="ap-card__author">
<img src="{{ item.author.photo }}" alt="{{ item.author.name }}" class="ap-card__avatar">
<div class="ap-card__author-info">
<div class="ap-card__author-name">
<a href="{{ item.author.url }}">{{ item.author.name }}</a>
</div>
<div class="ap-card__author-handle">{{ item.author.handle }}</div>
</div>
<time datetime="{{ item.published }}" class="ap-card__timestamp">
{{ item.published | date("PPp") }}
</time>
</header>
{# Post title (articles only) #}
{% if item.name %}
<h2 class="ap-card__title">
<a href="{{ item.url }}">{{ item.name }}</a>
</h2>
{% endif %}
{# Determine if content should be hidden behind CW #}
{% set hasCW = item.summary or item.sensitive %}
{% set cwLabel = item.summary if item.summary else __("activitypub.reader.sensitiveContent") %}
{% if hasCW %}
<div class="ap-card__cw" x-data="{ shown: false }">
<button @click="shown = !shown" class="ap-card__cw-toggle">
<span x-show="!shown">⚠️ {{ cwLabel }} — {{ __("activitypub.reader.showContent") }}</span>
<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 %}
{# Media hidden behind CW #}
{% include "partials/ap-item-media.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 %}
{# Media visible directly #}
{% include "partials/ap-item-media.njk" %}
{% endif %}
{# Tags/categories #}
{% if item.category and item.category.length > 0 %}
<div class="ap-card__tags">
{% for tag in item.category %}
<a href="?tag={{ tag }}" class="ap-card__tag">#{{ tag }}</a>
{% endfor %}
</div>
{% endif %}
{# Interaction buttons — Alpine.js for optimistic updates #}
{# Dynamic data moved to data-* attributes to prevent XSS from inline interpolation #}
{% set itemUrl = item.url or item.originalUrl %}
{% set isLiked = interactionMap[itemUrl].like if interactionMap[itemUrl] else false %}
{% set isBoosted = interactionMap[itemUrl].boost if interactionMap[itemUrl] else false %}
<footer class="ap-card__actions"
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' }},
loading: false,
error: '',
async interact(action) {
if (this.loading) return;
this.loading = true;
this.error = '';
const el = this.$root;
const itemUrl = el.dataset.itemUrl;
const csrfToken = el.dataset.csrfToken;
const basePath = el.dataset.mountPath;
const prev = { liked: this.liked, boosted: this.boosted };
if (action === 'like') this.liked = true;
else if (action === 'unlike') this.liked = false;
else if (action === 'boost') this.boosted = true;
else if (action === 'unboost') this.boosted = false;
try {
const res = await fetch(basePath + '/admin/reader/' + action, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': csrfToken
},
body: JSON.stringify({ url: itemUrl })
});
const data = await res.json();
if (!data.success) {
this.liked = prev.liked;
this.boosted = prev.boosted;
this.error = data.error || 'Failed';
}
} catch (e) {
this.liked = prev.liked;
this.boosted = prev.boosted;
this.error = e.message;
}
this.loading = false;
if (this.error) setTimeout(() => this.error = '', 3000);
}
}">
<a href="{{ mountPath }}/admin/reader/compose?replyTo={{ itemUrl | urlencode }}"
class="ap-card__action ap-card__action--reply"
title="{{ __('activitypub.reader.actions.reply') }}">
↩ {{ __("activitypub.reader.actions.reply") }}
</a>
<button class="ap-card__action ap-card__action--boost"
:class="{ 'ap-card__action--active': boosted }"
: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>
</button>
<button class="ap-card__action ap-card__action--like"
:class="{ 'ap-card__action--active': liked }"
:title="liked ? '{{ __('activitypub.reader.actions.unlike') }}' : '{{ __('activitypub.reader.actions.like') }}'"
:disabled="loading"
@click="interact(liked ? 'unlike' : 'like')">
<span x-text="liked ? '❤️' : '♥'"></span>
<span x-text="liked ? '{{ __('activitypub.reader.actions.liked') }}' : '{{ __('activitypub.reader.actions.like') }}'"></span>
</button>
<a href="{{ itemUrl }}" class="ap-card__action ap-card__action--link" target="_blank" rel="noopener">
🔗 {{ __("activitypub.reader.actions.viewOriginal") }}
</a>
<div x-show="error" x-text="error" class="ap-card__action-error" x-transition></div>
</footer>
</article>

View File

@@ -0,0 +1,37 @@
{# Media attachments partial — included from ap-item-card.njk #}
{# Photo gallery #}
{% if item.photo and item.photo.length > 0 %}
{% set displayCount = [item.photo.length, 4] | min %}
{% set extraCount = item.photo.length - 4 %}
<div class="ap-card__gallery ap-card__gallery--{{ displayCount }}">
{% for photoUrl in item.photo %}
{% if loop.index0 < 4 %}
<a href="{{ photoUrl }}" target="_blank" rel="noopener" class="ap-card__gallery-link{% if loop.index0 == 3 and extraCount > 0 %} ap-card__gallery-link--more{% endif %}">
<img src="{{ photoUrl }}" alt="" loading="lazy">
{% if loop.index0 == 3 and extraCount > 0 %}
<span class="ap-card__gallery-more">+{{ extraCount }}</span>
{% endif %}
</a>
{% endif %}
{% endfor %}
</div>
{% endif %}
{# Video embed #}
{% if item.video and item.video.length > 0 %}
<div class="ap-card__video">
<video controls preload="metadata" src="{{ item.video[0] }}">
{{ __("activitypub.reader.videoNotSupported") }}
</video>
</div>
{% endif %}
{# Audio player #}
{% if item.audio and item.audio.length > 0 %}
<div class="ap-card__audio">
<audio controls preload="metadata" src="{{ item.audio[0] }}">
{{ __("activitypub.reader.audioNotSupported") }}
</audio>
</div>
{% endif %}

View File

@@ -0,0 +1,58 @@
{# Notification card partial #}
<div class="ap-notification{% if not item.read %} ap-notification--unread{% endif %}">
{# Type icon #}
<div class="ap-notification__icon">
{% if item.type == "like" %}
{% elif item.type == "boost" %}
🔁
{% elif item.type == "follow" %}
👤
{% elif item.type == "reply" %}
💬
{% elif item.type == "mention" %}
@
{% endif %}
</div>
{# Notification body #}
<div class="ap-notification__body">
<span class="ap-notification__actor">
<a href="{{ item.actorUrl }}">{{ item.actorName }}</a>
</span>
<span class="ap-notification__action">
{% if item.type == "like" %}
{{ __("activitypub.notifications.liked") }}
{% elif item.type == "boost" %}
{{ __("activitypub.notifications.boostedPost") }}
{% elif item.type == "follow" %}
{{ __("activitypub.notifications.followedYou") }}
{% elif item.type == "reply" %}
{{ __("activitypub.notifications.repliedTo") }}
{% elif item.type == "mention" %}
{{ __("activitypub.notifications.mentionedYou") }}
{% endif %}
</span>
{% if item.targetUrl %}
<a href="{{ item.targetUrl }}" class="ap-notification__target">
{{ item.targetName or item.targetUrl }}
</a>
{% endif %}
{% if item.content and item.content.text %}
<div class="ap-notification__excerpt">
{{ item.content.text | truncate(200) }}
</div>
{% endif %}
</div>
{# Timestamp #}
{% if item.published %}
<time datetime="{{ item.published }}" class="ap-notification__time">
{{ item.published | date("PPp") }}
</time>
{% endif %}
</div>