fix: comprehensive security, performance, and architecture audit fixes

27 issues fixed from multi-dimensional code review (4 Critical, 6 High, 11 Medium, 6 Low):

Security (Critical):
- Escape HTML in OAuth authorization page to prevent XSS (C1)
- Add CSRF protection to OAuth authorize flow (C2)
- Replace bypassable regex sanitizer with sanitize-html library (C3)
- Enforce OAuth scopes on all Mastodon API routes (C4)

Security (Medium/Low):
- Fix SSRF via DNS resolution before private IP check (M1)
- Add rate limiting to API, auth, and app registration endpoints (M2)
- Validate redirect_uri on POST /oauth/authorize (M4)
- Fix custom emoji URL injection with scheme validation + escaping (M5)
- Remove data: scheme from allowed image sources (L6)
- Add access token expiry (1hr) and refresh token rotation (90d) (M3)
- Hash client secrets before storage (L3)

Architecture:
- Extract batch-broadcast.js — shared delivery logic (H1a)
- Extract init-indexes.js — MongoDB index creation (H1b)
- Extract syndicator.js — syndication logic (H1c)
- Create federation-actions.js facade for controllers (M6)
- index.js reduced from 1810 to ~1169 lines (35%)

Performance:
- Cache moderation data with 30s TTL + write invalidation (H6)
- Increase inbox queue throughput to 10 items/sec (H5)
- Make account enrichment non-blocking with fire-and-forget (H4)
- Remove ephemeral getReplies/getLikes/getShares from ingest (M11)
- Fix LRU caches to use true LRU eviction (L1)
- Fix N+1 backfill queries with batch $in lookup (L2)

UI/UX:
- Split 3441-line reader.css into 15 feature-scoped files (H2)
- Extract inline Alpine.js interaction component (H3)
- Reduce sidebar navigation from 7 to 3 items (M7)
- Add ARIA live regions for dynamic content updates (M8)
- Extract shared CW/non-CW content partial (M9)
- Document form handling pattern convention (M10)
- Add accessible labels to functional emoji icons (L4)
- Convert profile editor to Alpine.js (L5)

Audit: documentation-central/audits/2026-03-24-activitypub-code-review.md
Plan: documentation-central/plans/2026-03-24-activitypub-audit-fixes.md
This commit is contained in:
Ricardo
2026-03-25 07:41:20 +01:00
parent 3ace60a1c8
commit 12454749ad
55 changed files with 4845 additions and 4731 deletions

2
.gitignore vendored
View File

@@ -1 +1,3 @@
node_modules node_modules
.playwright-cli/
.playwright-mcp/

View File

@@ -618,6 +618,37 @@ curl -s "https://rmendes.net/nodeinfo/2.1" | jq .
- `@_followback@tags.pub` does not send Follow activities back despite accepting ours - `@_followback@tags.pub` does not send Follow activities back despite accepting ours
- Both suggest tags.pub's outbound delivery is broken — zero inbound requests from `activitypub-bot` user-agent have been observed - Both suggest tags.pub's outbound delivery is broken — zero inbound requests from `activitypub-bot` user-agent have been observed
## Form Handling Convention
Two form patterns are used in this plugin. New forms should follow the appropriate pattern.
### Pattern 1: Traditional POST (data mutation forms)
Used for: compose, profile editor, migration alias, notification mark-read/clear.
- Standard `<form method="POST" action="...">`
- CSRF via `<input type="hidden" name="_csrf" value="...">`
- Server processes, then redirects (PRG pattern)
- Success/error feedback via Indiekit's notification banner system
- Uses Indiekit form macros (`input`, `textarea`, `button`) where available
### Pattern 2: Alpine.js Fetch (in-page CRUD operations)
Used for: moderation add/remove keyword/server, tab management, federation actions.
- Alpine.js `@submit.prevent` or `@click` handlers
- CSRF via `X-CSRF-Token` header in `fetch()` call
- Inline error display with `x-show="error"` and `role="alert"`
- Optimistic UI with rollback on failure
- No page reload — DOM updates in place
### Rules
- Do NOT mix patterns on the same page (one pattern per form)
- All forms MUST include CSRF protection (hidden field OR header)
- Error feedback: Pattern 1 uses redirect + banner, Pattern 2 uses inline `x-show="error"`
- Success feedback: Pattern 1 uses redirect + banner, Pattern 2 uses inline DOM update or element removal
## CSS Conventions ## CSS Conventions
The reader CSS (`assets/reader.css`) uses Indiekit's theme custom properties for automatic dark mode support: The reader CSS (`assets/reader.css`) uses Indiekit's theme custom properties for automatic dark mode support:

144
assets/css/base.css Normal file
View File

@@ -0,0 +1,144 @@
/**
* ActivityPub Reader Styles
* Card-based layout inspired by Phanpy/Elk
* Uses Indiekit CSS custom properties for automatic dark mode support
*/
/* ==========================================================================
Breadcrumb Navigation
========================================================================== */
.ap-breadcrumb {
display: flex;
align-items: center;
gap: var(--space-xs);
margin-bottom: var(--space-m);
font-size: var(--font-size-s);
color: var(--color-on-offset);
}
.ap-breadcrumb a {
color: var(--color-primary-on-background);
text-decoration: none;
}
.ap-breadcrumb a:hover {
text-decoration: underline;
}
.ap-breadcrumb__separator {
color: var(--color-on-offset);
}
.ap-breadcrumb__current {
color: var(--color-on-background);
font-weight: 600;
}
/* ==========================================================================
Fediverse Lookup
========================================================================== */
.ap-lookup {
display: flex;
gap: var(--space-xs);
margin-bottom: var(--space-m);
}
.ap-lookup__input {
border: var(--border-width-thin) solid var(--color-outline);
border-radius: var(--border-radius-small);
background: var(--color-offset);
box-sizing: border-box;
color: var(--color-on-background);
font-family: inherit;
font-size: var(--font-size-m);
padding: var(--space-s) var(--space-m);
width: 100%;
}
.ap-lookup__input::placeholder {
color: var(--color-on-offset);
}
.ap-lookup__input:focus {
outline: 2px solid var(--color-primary);
outline-offset: -1px;
border-color: var(--color-primary);
}
.ap-lookup__btn {
padding: var(--space-s) var(--space-m);
border: var(--border-width-thin) solid var(--color-primary);
border-radius: var(--border-radius-small);
background: var(--color-primary);
color: var(--color-on-primary);
font-size: var(--font-size-m);
font-family: inherit;
font-weight: 600;
cursor: pointer;
white-space: nowrap;
}
.ap-lookup__btn:hover {
opacity: 0.9;
}
/* ==========================================================================
Tab Navigation
========================================================================== */
.ap-tabs {
border-bottom: var(--border-width-thin) solid var(--color-outline);
display: flex;
gap: var(--space-xs);
margin-bottom: var(--space-m);
overflow-x: auto;
-webkit-overflow-scrolling: touch;
}
.ap-tab {
border-bottom: var(--border-width-thick) solid transparent;
color: var(--color-on-offset);
font-size: var(--font-size-m);
padding: var(--space-s) var(--space-m);
text-decoration: none;
transition:
color 0.2s ease,
border-color 0.2s ease;
white-space: nowrap;
}
.ap-tab:hover {
color: var(--color-on-background);
}
.ap-tab--active {
border-bottom-color: var(--color-primary);
color: var(--color-primary-on-background);
font-weight: 600;
}
.ap-tab__count {
background: var(--color-offset-variant);
border-radius: var(--border-radius-large);
font-size: var(--font-size-xs);
font-weight: 600;
margin-left: var(--space-xs);
padding: 1px 6px;
}
.ap-tab--active .ap-tab__count {
background: var(--color-primary);
color: var(--color-on-primary, var(--color-neutral99));
}
/* ==========================================================================
Timeline Layout
========================================================================== */
.ap-timeline {
display: flex;
flex-direction: column;
gap: var(--space-m);
}

377
assets/css/card.css Normal file
View File

@@ -0,0 +1,377 @@
/* ==========================================================================
Item Card — Base
========================================================================== */
.ap-card {
background: var(--color-offset);
border: var(--border-width-thin) solid var(--color-outline);
border-left: 3px solid var(--color-outline);
border-radius: var(--border-radius-small);
overflow: hidden;
padding: var(--space-m);
box-shadow: 0 1px 2px hsl(var(--tint-neutral) 10% / 0.04);
transition:
box-shadow 0.2s ease,
border-color 0.2s ease;
}
.ap-card:hover {
border-color: var(--color-outline-variant);
border-left-color: var(--color-outline-variant);
box-shadow: 0 2px 8px hsl(var(--tint-neutral) 10% / 0.08);
}
/* ==========================================================================
Item Card — Post Type Differentiation
========================================================================== */
/* Notes: default purple-ish accent (the most common type) */
.ap-card--note {
border-left-color: var(--color-purple45);
}
.ap-card--note:hover {
border-left-color: var(--color-purple45);
}
/* Articles: green accent (long-form content stands out) */
.ap-card--article {
border-left-color: var(--color-green50);
}
.ap-card--article:hover {
border-left-color: var(--color-green50);
}
/* Boosts: yellow accent (shared content) */
.ap-card--boost {
border-left-color: var(--color-yellow50);
}
.ap-card--boost:hover {
border-left-color: var(--color-yellow50);
}
/* Replies: blue accent (via primary color) */
.ap-card--reply {
border-left-color: var(--color-primary);
}
.ap-card--reply:hover {
border-left-color: var(--color-primary);
}
/* ==========================================================================
Boost Header
========================================================================== */
.ap-card__boost {
color: var(--color-on-offset);
font-size: var(--font-size-s);
margin-bottom: var(--space-s);
padding-bottom: var(--space-xs);
}
.ap-card__boost a {
color: var(--color-on-offset);
font-weight: 600;
text-decoration: none;
}
.ap-card__boost a:hover {
color: var(--color-on-background);
text-decoration: underline;
}
/* ==========================================================================
Reply Context
========================================================================== */
.ap-card__reply-to {
color: var(--color-on-offset);
font-size: var(--font-size-s);
margin-bottom: var(--space-s);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.ap-card__reply-to a {
color: var(--color-primary-on-background);
text-decoration: none;
}
.ap-card__reply-to a:hover {
text-decoration: underline;
}
/* ==========================================================================
Author Header
========================================================================== */
.ap-card__author {
align-items: center;
display: flex;
gap: var(--space-s);
margin-bottom: var(--space-s);
}
.ap-card__avatar-wrap {
flex-shrink: 0;
height: 44px;
position: relative;
width: 44px;
}
.ap-card__avatar {
border: var(--border-width-thin) solid var(--color-outline);
border-radius: 50%;
height: 44px;
object-fit: cover;
width: 44px;
}
.ap-card__avatar-wrap > img {
position: absolute;
inset: 0;
z-index: 1;
}
.ap-card__avatar--default {
align-items: center;
background: var(--color-offset-variant);
color: var(--color-on-offset);
display: inline-flex;
font-size: 1.1em;
font-weight: 600;
justify-content: center;
}
.ap-card__author-info {
display: flex;
flex-direction: column;
flex: 1;
gap: 1px;
min-width: 0;
}
.ap-card__author-name {
font-size: 0.95em;
font-weight: 600;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.ap-card__author-name a {
color: inherit;
text-decoration: none;
}
.ap-card__author-name a:hover {
text-decoration: underline;
}
.ap-card__bot-badge {
display: inline-block;
font-size: 0.6rem;
font-weight: 700;
line-height: 1;
padding: 0.15em 0.35em;
margin-left: 0.3em;
border: var(--border-width-thin) solid var(--color-on-offset);
border-radius: var(--border-radius-small);
color: var(--color-on-offset);
vertical-align: middle;
text-transform: uppercase;
letter-spacing: 0.03em;
}
.ap-card__author-handle {
color: var(--color-on-offset);
font-size: var(--font-size-s);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.ap-card__timestamp {
color: var(--color-on-offset);
flex-shrink: 0;
font-size: var(--font-size-s);
}
.ap-card__edited {
font-size: var(--font-size-xs);
margin-left: 0.2em;
}
.ap-card__visibility {
font-size: var(--font-size-xs);
margin-left: 0.3em;
opacity: 0.7;
}
.ap-card__timestamp-link {
color: inherit;
text-decoration: none;
display: flex;
align-items: center;
gap: 0;
}
.ap-card__timestamp-link:hover {
text-decoration: underline;
color: var(--color-primary-on-background);
}
/* ==========================================================================
Post Title (Articles)
========================================================================== */
.ap-card__title {
font-size: var(--font-size-l);
font-weight: 600;
line-height: var(--line-height-tight);
margin-bottom: var(--space-s);
}
.ap-card__title a {
color: inherit;
text-decoration: none;
}
.ap-card__title a:hover {
text-decoration: underline;
}
/* ==========================================================================
Content
========================================================================== */
.ap-card__content {
color: var(--color-on-background);
line-height: calc(4 / 3 * 1em);
margin-bottom: var(--space-s);
overflow-wrap: break-word;
word-break: break-word;
}
.ap-card__content a {
color: var(--color-primary-on-background);
}
.ap-card__content p {
margin-bottom: var(--space-xs);
}
.ap-card__content p:last-child {
margin-bottom: 0;
}
.ap-card__content blockquote {
border-left: var(--border-width-thickest) solid var(--color-outline);
margin: var(--space-s) 0;
padding-left: var(--space-m);
}
.ap-card__content pre {
background: var(--color-offset-variant);
border-radius: var(--border-radius-small);
overflow-x: auto;
padding: var(--space-s);
}
.ap-card__content code {
background: var(--color-offset-variant);
border-radius: var(--border-radius-small);
font-size: 0.9em;
padding: 1px 4px;
}
.ap-card__content pre code {
background: none;
padding: 0;
}
.ap-card__content img {
border-radius: var(--border-radius-small);
height: auto;
max-width: 100%;
}
/* @mentions — keep inline, style as subtle links */
.ap-card__content .h-card {
display: inline;
}
.ap-card__content .h-card a,
.ap-card__content a.u-url.mention {
display: inline;
color: var(--color-on-offset);
text-decoration: none;
white-space: nowrap;
}
.ap-card__content .h-card a span,
.ap-card__content a.u-url.mention span {
display: inline;
}
.ap-card__content .h-card a:hover,
.ap-card__content a.u-url.mention:hover {
color: var(--color-primary-on-background);
text-decoration: underline;
}
/* Hashtag mentions — keep inline, subtle styling */
.ap-card__content a.mention.hashtag {
display: inline;
color: var(--color-on-offset);
text-decoration: none;
white-space: nowrap;
}
.ap-card__content a.mention.hashtag span {
display: inline;
}
.ap-card__content a.mention.hashtag:hover {
color: var(--color-primary-on-background);
text-decoration: underline;
}
/* Mastodon's invisible/ellipsis spans for long URLs */
.ap-card__content .invisible {
display: none;
}
.ap-card__content .ellipsis::after {
content: "…";
}
/* ==========================================================================
Content Warning
========================================================================== */
.ap-card__cw {
margin-bottom: var(--space-s);
}
.ap-card__cw-toggle {
background: var(--color-offset-variant);
border: var(--border-width-thin) solid var(--color-outline);
border-radius: var(--border-radius-small);
color: var(--color-on-background);
cursor: pointer;
display: block;
font-size: var(--font-size-s);
padding: var(--space-s) var(--space-m);
text-align: left;
transition: background 0.2s ease;
width: 100%;
}
.ap-card__cw-toggle:hover {
background: var(--color-offset-variant-darker);
}

169
assets/css/compose.css Normal file
View File

@@ -0,0 +1,169 @@
/* ==========================================================================
Compose Form
========================================================================== */
.ap-compose__context {
background: var(--color-offset);
border-left: var(--border-width-thickest) solid var(--color-primary);
border-radius: var(--border-radius-small);
margin-bottom: var(--space-m);
padding: var(--space-m);
}
.ap-compose__context-label {
color: var(--color-on-offset);
font-size: var(--font-size-s);
margin-bottom: var(--space-xs);
}
.ap-compose__context-author a {
font-weight: 600;
text-decoration: none;
}
.ap-compose__context-text {
border: 0;
font-size: var(--font-size-s);
line-height: var(--line-height-loose);
margin: var(--space-xs) 0;
padding: 0;
}
.ap-compose__context-link {
color: var(--color-on-offset);
font-size: var(--font-size-s);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.ap-compose__form {
display: flex;
flex-direction: column;
gap: var(--space-m);
}
.ap-compose__editor {
position: relative;
}
.ap-compose__textarea {
background: var(--color-background);
border: var(--border-width-thick) solid var(--color-outline);
border-radius: var(--border-radius-small);
color: var(--color-on-background);
font-family: inherit;
font-size: var(--font-size-m);
line-height: var(--line-height-prose);
padding: var(--space-s);
resize: vertical;
width: 100%;
}
.ap-compose__textarea:focus {
border-color: var(--color-primary);
outline: var(--border-width-thick) solid var(--color-primary);
outline-offset: -2px;
}
.ap-compose__cw {
display: flex;
flex-direction: column;
gap: var(--space-xs);
}
.ap-compose__cw-toggle {
cursor: pointer;
display: flex;
align-items: center;
gap: var(--space-xs);
font-size: var(--font-size-s);
color: var(--color-on-offset);
}
.ap-compose__cw-input {
border: var(--border-width-thin) solid var(--color-outline);
border-radius: var(--border-radius-small);
background: var(--color-offset);
color: var(--color-on-background);
font: inherit;
font-size: var(--font-size-s);
padding: var(--space-s);
width: 100%;
}
.ap-compose__cw-input:focus {
border-color: var(--color-primary);
outline: none;
}
.ap-compose__visibility {
border: var(--border-width-thin) solid var(--color-outline);
border-radius: var(--border-radius-small);
display: flex;
flex-wrap: wrap;
gap: var(--space-s) var(--space-m);
padding: var(--space-m);
}
.ap-compose__visibility legend {
font-weight: 600;
}
.ap-compose__visibility-option {
cursor: pointer;
display: flex;
align-items: center;
gap: var(--space-xs);
font-size: var(--font-size-s);
}
.ap-compose__syndication {
border: var(--border-width-thin) solid var(--color-outline);
border-radius: var(--border-radius-small);
display: flex;
flex-direction: column;
gap: var(--space-xs);
padding: var(--space-m);
}
.ap-compose__syndication legend {
font-weight: 600;
}
.ap-compose__syndication-target {
cursor: pointer;
display: flex;
gap: var(--space-xs);
}
.ap-compose__actions {
align-items: center;
display: flex;
gap: var(--space-m);
}
.ap-compose__submit {
background: var(--color-primary);
border: 0;
border-radius: var(--border-radius-small);
color: var(--color-on-primary, var(--color-neutral99));
cursor: pointer;
font-size: var(--font-size-m);
font-weight: 600;
padding: var(--space-s) var(--space-l);
}
.ap-compose__submit:hover {
opacity: 0.9;
}
.ap-compose__cancel {
color: var(--color-on-offset);
text-decoration: none;
}
.ap-compose__cancel:hover {
color: var(--color-on-background);
text-decoration: underline;
}

94
assets/css/dark-mode.css Normal file
View File

@@ -0,0 +1,94 @@
/* ==========================================================================
Dark Mode Overrides
Softens saturated colors that are uncomfortable on dark backgrounds.
Uses Indiekit's existing light-variant tokens (red80, green90, yellow90)
which are designed for dark surfaces.
========================================================================== */
@media (prefers-color-scheme: dark) {
/* --- Action button hover states: softer colors, more visible tinted backgrounds --- */
.ap-card__action--reply:hover {
background: color-mix(in srgb, var(--color-primary) 18%, transparent);
color: var(--color-primary-on-background);
}
.ap-card__action--boost:hover {
background: color-mix(in srgb, var(--color-green50) 18%, transparent);
color: var(--color-green90);
}
.ap-card__action--like:hover {
background: color-mix(in srgb, var(--color-red45) 18%, transparent);
color: var(--color-red80);
}
.ap-card__action--save:hover {
background: color-mix(in srgb, var(--color-primary) 18%, transparent);
color: var(--color-primary-on-background);
}
/* --- Active interaction states --- */
.ap-card__action--like.ap-card__action--active {
background: color-mix(in srgb, var(--color-red45) 18%, transparent);
color: var(--color-red80);
}
.ap-card__action--boost.ap-card__action--active {
background: color-mix(in srgb, var(--color-green50) 18%, transparent);
color: var(--color-green90);
}
.ap-card__action--save.ap-card__action--active {
background: color-mix(in srgb, var(--color-primary) 18%, transparent);
color: var(--color-primary-on-background);
}
/* --- Post-type left border accents: desaturated for dark surfaces --- */
.ap-card--note,
.ap-card--note:hover {
border-left-color: var(--color-purple90);
}
.ap-card--article,
.ap-card--article:hover {
border-left-color: var(--color-green90);
}
.ap-card--boost,
.ap-card--boost:hover {
border-left-color: var(--color-yellow90);
}
.ap-card--reply,
.ap-card--reply:hover {
border-left-color: var(--color-primary-on-background);
}
/* --- Notification unread glow: toned down --- */
.ap-notification--unread {
border-color: var(--color-yellow90);
box-shadow: 0 0 6px 0 color-mix(in srgb, var(--color-yellow50) 15%, transparent);
}
/* --- Post detail highlight ring: softened --- */
.ap-post-detail__main .ap-card {
border-color: color-mix(in srgb, var(--color-primary) 50%, transparent);
box-shadow: 0 0 0 1px color-mix(in srgb, var(--color-primary) 50%, transparent);
}
/* --- Card shadows: use light tint instead of black --- */
.ap-card {
box-shadow: 0 1px 2px hsl(var(--tint-neutral) 90% / 0.04);
}
.ap-card:hover {
box-shadow: 0 2px 8px hsl(var(--tint-neutral) 90% / 0.06);
}
/* --- Tab badge federated: soften purple --- */
.ap-tab__badge--federated {
color: var(--color-purple90);
background: color-mix(in srgb, var(--color-purple45) 18%, transparent);
}
}

530
assets/css/explore.css Normal file
View File

@@ -0,0 +1,530 @@
/* ==========================================================================
Explore Page
========================================================================== */
.ap-explore-header {
margin-bottom: var(--space-m);
}
.ap-explore-header__title {
font-size: var(--font-size-xl);
margin: 0 0 var(--space-xs);
}
.ap-explore-header__desc {
color: var(--color-on-offset);
font-size: var(--font-size-s);
margin: 0;
}
.ap-explore-form {
background: var(--color-offset);
border: var(--border-width-thin) solid var(--color-outline);
border-radius: var(--border-radius-small);
margin-bottom: var(--space-m);
padding: var(--space-m);
}
.ap-explore-form__row {
align-items: center;
display: flex;
gap: var(--space-s);
flex-wrap: wrap;
}
.ap-explore-form__input {
border: var(--border-width-thin) solid var(--color-outline);
border-radius: var(--border-radius-small);
box-sizing: border-box;
font-size: var(--font-size-m);
min-width: 0;
padding: var(--space-xs) var(--space-s);
width: 100%;
}
.ap-explore-form__scope {
display: flex;
gap: var(--space-s);
}
.ap-explore-form__scope-label {
align-items: center;
cursor: pointer;
display: flex;
font-size: var(--font-size-s);
gap: var(--space-xs);
}
.ap-explore-form__btn {
background: var(--color-primary);
border: none;
border-radius: var(--border-radius-small);
color: var(--color-on-primary);
cursor: pointer;
font-size: var(--font-size-s);
padding: var(--space-xs) var(--space-m);
white-space: nowrap;
}
.ap-explore-form__btn:hover {
opacity: 0.85;
}
.ap-explore-error {
background: color-mix(in srgb, var(--color-error) 10%, transparent);
border: var(--border-width-thin) solid var(--color-error);
border-radius: var(--border-radius-small);
color: var(--color-error);
margin-bottom: var(--space-m);
padding: var(--space-s) var(--space-m);
}
@media (max-width: 640px) {
.ap-explore-form__row {
flex-direction: column;
align-items: stretch;
}
.ap-explore-form__btn {
width: 100%;
}
}
/* ---------- Autocomplete dropdown ---------- */
.ap-explore-autocomplete {
flex: 1;
min-width: 0;
position: relative;
}
.ap-explore-autocomplete__dropdown {
background: var(--color-background);
border: var(--border-width-thin) solid var(--color-outline);
border-radius: var(--border-radius-small);
box-shadow: 0 4px 12px hsl(var(--tint-neutral) 10% / 0.15);
left: 0;
max-height: 320px;
overflow-y: auto;
position: absolute;
right: 0;
top: 100%;
z-index: 100;
}
.ap-explore-autocomplete__item {
align-items: center;
background: none;
border: none;
color: var(--color-on-background);
cursor: pointer;
display: flex;
font-family: inherit;
font-size: var(--font-size-s);
gap: var(--space-s);
padding: var(--space-s) var(--space-m);
text-align: left;
width: 100%;
}
.ap-explore-autocomplete__item:hover,
.ap-explore-autocomplete__item--highlighted {
background: var(--color-offset);
}
.ap-explore-autocomplete__domain {
flex-shrink: 0;
font-weight: 600;
}
.ap-explore-autocomplete__meta {
color: var(--color-on-offset);
display: flex;
flex: 1;
gap: var(--space-xs);
min-width: 0;
}
.ap-explore-autocomplete__software {
background: color-mix(in srgb, var(--color-primary) 12%, transparent);
border-radius: var(--border-radius-small);
font-size: var(--font-size-xs);
padding: 1px 6px;
white-space: nowrap;
}
.ap-explore-autocomplete__mau {
font-size: var(--font-size-xs);
white-space: nowrap;
}
.ap-explore-autocomplete__status {
flex-shrink: 0;
font-size: var(--font-size-s);
}
.ap-explore-autocomplete__checking {
opacity: 0.5;
}
/* ---------- Popular accounts autocomplete ---------- */
.ap-lookup-autocomplete {
flex: 1;
min-width: 0;
position: relative;
}
.ap-lookup-autocomplete__dropdown {
background: var(--color-background);
border: var(--border-width-thin) solid var(--color-outline);
border-radius: var(--border-radius-small);
box-shadow: 0 4px 12px hsl(var(--tint-neutral) 10% / 0.15);
left: 0;
max-height: 320px;
overflow-y: auto;
position: absolute;
right: 0;
top: 100%;
z-index: 100;
}
.ap-lookup-autocomplete__item {
align-items: center;
background: none;
border: none;
color: var(--color-on-background);
cursor: pointer;
display: flex;
font-family: inherit;
font-size: var(--font-size-s);
gap: var(--space-s);
padding: var(--space-s) var(--space-m);
text-align: left;
width: 100%;
}
.ap-lookup-autocomplete__item:hover,
.ap-lookup-autocomplete__item--highlighted {
background: var(--color-offset);
}
.ap-lookup-autocomplete__avatar {
border-radius: 50%;
flex-shrink: 0;
height: 28px;
object-fit: cover;
width: 28px;
}
.ap-lookup-autocomplete__info {
display: flex;
flex: 1;
flex-direction: column;
min-width: 0;
}
.ap-lookup-autocomplete__name {
font-weight: 600;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.ap-lookup-autocomplete__handle {
color: var(--color-on-offset);
font-size: var(--font-size-xs);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.ap-lookup-autocomplete__followers {
color: var(--color-on-offset);
flex-shrink: 0;
font-size: var(--font-size-xs);
white-space: nowrap;
}
/* ==========================================================================
Explore: Tabbed Design
========================================================================== */
/* Tab bar wrapper: enables position:relative for fade gradient overlay */
.ap-explore-tabs-container {
position: relative;
}
/* Tab bar with right-edge fade to indicate horizontal overflow */
.ap-explore-tabs-nav {
padding-right: var(--space-l);
position: relative;
}
.ap-explore-tabs-nav::after {
background: linear-gradient(to right, transparent, var(--color-background) 80%);
content: "";
height: 100%;
pointer-events: none;
position: absolute;
right: 0;
top: 0;
width: 40px;
}
/* Tab wrapper: holds tab button + reorder/close controls together */
.ap-tab-wrapper {
align-items: stretch;
display: inline-flex;
position: relative;
}
/* Show controls on hover or when the tab is active */
.ap-tab-controls {
align-items: center;
display: none;
gap: 1px;
}
.ap-tab-wrapper:hover .ap-tab-controls,
.ap-tab-wrapper:focus-within .ap-tab-controls {
display: flex;
}
/* Individual control buttons (↑ ↓ ×) */
.ap-tab-control {
background: none;
border: none;
color: var(--color-on-offset);
cursor: pointer;
font-size: var(--font-size-xs);
line-height: 1;
padding: 2px 4px;
}
.ap-tab-control:hover {
color: var(--color-on-background);
}
.ap-tab-control:disabled {
cursor: default;
opacity: 0.3;
}
.ap-tab-control--remove {
color: var(--color-on-offset);
font-size: var(--font-size-s);
}
.ap-tab-control--remove:hover {
color: var(--color-error);
}
/* Truncate long domain names in tab labels */
.ap-tab__label {
display: inline-block;
max-width: 150px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* Scope badges on instance tabs */
.ap-tab__badge {
border-radius: var(--border-radius-small);
font-size: 0.65em;
font-weight: 700;
letter-spacing: 0.02em;
margin-left: var(--space-xs);
padding: 1px 4px;
text-transform: uppercase;
vertical-align: middle;
}
.ap-tab__badge--local {
background: color-mix(in srgb, var(--color-primary) 15%, transparent);
color: var(--color-primary-on-background);
}
.ap-tab__badge--federated {
background: color-mix(in srgb, var(--color-purple45) 15%, transparent);
color: var(--color-purple45);
}
/* +# button for adding hashtag tabs */
.ap-tab--add {
font-family: monospace;
font-weight: 700;
letter-spacing: -0.05em;
}
/* Inline hashtag form that appears when +# is clicked */
.ap-tab-add-hashtag {
align-items: center;
display: inline-flex;
gap: var(--space-xs);
}
.ap-tab-hashtag-form {
align-items: center;
display: flex;
gap: var(--space-xs);
}
.ap-tab-hashtag-form__prefix {
color: var(--color-on-offset);
font-weight: 600;
}
.ap-tab-hashtag-form__input {
border: var(--border-width-thin) solid var(--color-outline);
border-radius: var(--border-radius-small);
font-family: inherit;
font-size: var(--font-size-s);
padding: 2px var(--space-s);
width: 8em;
}
.ap-tab-hashtag-form__input:focus {
border-color: var(--color-primary);
outline: 2px solid var(--color-primary);
outline-offset: -1px;
}
.ap-tab-hashtag-form__btn {
background: var(--color-primary);
border: none;
border-radius: var(--border-radius-small);
color: var(--color-on-primary);
cursor: pointer;
font-family: inherit;
font-size: var(--font-size-s);
padding: 2px var(--space-s);
white-space: nowrap;
}
.ap-tab-hashtag-form__btn:hover {
opacity: 0.85;
}
/* "Pin as tab" button in search results area */
.ap-explore-pin-bar {
margin-bottom: var(--space-s);
}
.ap-explore-pin-btn {
background: none;
border: var(--border-width-thin) solid var(--color-primary-on-background);
border-radius: var(--border-radius-small);
color: var(--color-primary-on-background);
cursor: pointer;
font-family: inherit;
font-size: var(--font-size-s);
padding: var(--space-xs) var(--space-m);
}
.ap-explore-pin-btn:hover {
background: color-mix(in srgb, var(--color-primary) 10%, transparent);
}
.ap-explore-pin-btn:disabled {
cursor: default;
opacity: 0.6;
}
/* Hashtag form row inside the search form */
.ap-explore-form__hashtag-row {
align-items: center;
display: flex;
flex-wrap: wrap;
gap: var(--space-xs);
margin-top: var(--space-s);
}
.ap-explore-form__hashtag-label {
color: var(--color-on-offset);
font-size: var(--font-size-s);
white-space: nowrap;
}
.ap-explore-form__hashtag-prefix {
color: var(--color-on-offset);
font-weight: 600;
}
.ap-explore-form__hashtag-hint {
color: var(--color-on-offset);
font-size: var(--font-size-xs);
flex-basis: 100%;
}
.ap-explore-form__input--hashtag {
max-width: 200px;
width: auto;
}
/* Tab panel containers */
.ap-explore-instance-panel,
.ap-explore-hashtag-panel {
min-height: 120px;
}
/* Loading state */
.ap-explore-tab-loading {
align-items: center;
color: var(--color-on-offset);
display: flex;
justify-content: center;
padding: var(--space-xl);
}
.ap-explore-tab-loading--more {
padding-block: var(--space-m);
}
.ap-explore-tab-loading__text {
font-size: var(--font-size-s);
}
/* Error state */
.ap-explore-tab-error {
align-items: center;
display: flex;
flex-direction: column;
gap: var(--space-s);
padding: var(--space-xl);
}
.ap-explore-tab-error__message {
color: var(--color-error);
font-size: var(--font-size-s);
margin: 0;
}
.ap-explore-tab-error__retry {
background: none;
border: var(--border-width-thin) solid var(--color-primary-on-background);
border-radius: var(--border-radius-small);
color: var(--color-primary-on-background);
cursor: pointer;
font-size: var(--font-size-s);
padding: var(--space-xs) var(--space-s);
}
.ap-explore-tab-error__retry:hover {
background: color-mix(in srgb, var(--color-primary) 10%, transparent);
}
/* Empty state */
.ap-explore-tab-empty {
color: var(--color-on-offset);
font-size: var(--font-size-s);
padding: var(--space-xl);
text-align: center;
}
/* Infinite scroll sentinel — zero height, invisible */
.ap-tab-sentinel {
height: 1px;
visibility: hidden;
}

436
assets/css/features.css Normal file
View File

@@ -0,0 +1,436 @@
/* ==========================================================================
Post Detail View — Thread Layout
========================================================================== */
.ap-post-detail__back {
margin-bottom: var(--space-m);
}
.ap-post-detail__back-link {
color: var(--color-primary-on-background);
font-size: var(--font-size-s);
text-decoration: none;
}
.ap-post-detail__back-link:hover {
text-decoration: underline;
}
.ap-post-detail__not-found {
background: var(--color-offset);
border-radius: var(--border-radius-small);
color: var(--color-on-offset);
padding: var(--space-l);
text-align: center;
}
.ap-post-detail__section-title {
color: var(--color-on-offset);
font-size: var(--font-size-s);
font-weight: 600;
margin: var(--space-m) 0 var(--space-s);
padding-bottom: var(--space-xs);
text-transform: uppercase;
letter-spacing: 0.05em;
}
/* Parent posts — indented with left border to show thread chain */
.ap-post-detail__parents {
border-left: 3px solid var(--color-outline);
margin-bottom: var(--space-s);
padding-left: var(--space-m);
}
.ap-post-detail__parent-item .ap-card {
opacity: 0.85;
}
/* Main post — highlighted */
.ap-post-detail__main {
margin-bottom: var(--space-m);
}
.ap-post-detail__main .ap-card {
border-color: var(--color-primary);
box-shadow: 0 0 0 1px var(--color-primary);
}
/* Replies — indented from the other side */
.ap-post-detail__replies {
margin-left: var(--space-l);
}
.ap-post-detail__reply-item {
border-left: 2px solid var(--color-outline);
padding-left: var(--space-m);
margin-bottom: var(--space-xs);
}
/* ==========================================================================
Tag Timeline Header
========================================================================== */
.ap-tag-header {
align-items: flex-start;
background: var(--color-offset);
border-bottom: var(--border-width-thin) solid var(--color-outline);
border-radius: var(--border-radius-small);
display: flex;
gap: var(--space-m);
justify-content: space-between;
margin-bottom: var(--space-m);
padding: var(--space-m);
}
.ap-tag-header__title {
font-size: var(--font-size-xl);
font-weight: 600;
margin: 0 0 var(--space-xs);
}
.ap-tag-header__count {
color: var(--color-on-offset);
font-size: var(--font-size-s);
margin: 0;
}
.ap-tag-header__actions {
align-items: center;
display: flex;
flex-shrink: 0;
gap: var(--space-s);
}
.ap-tag-header__follow-btn {
background: var(--color-primary);
border: none;
border-radius: var(--border-radius-small);
color: var(--color-on-primary, var(--color-neutral99));
cursor: pointer;
font-size: var(--font-size-s);
padding: var(--space-xs) var(--space-s);
}
.ap-tag-header__follow-btn:hover {
opacity: 0.85;
}
.ap-tag-header__unfollow-btn {
background: transparent;
border: var(--border-width-thin) solid var(--color-outline);
border-radius: var(--border-radius-small);
color: var(--color-on-background);
cursor: pointer;
font-size: var(--font-size-s);
padding: var(--space-xs) var(--space-s);
}
.ap-tag-header__unfollow-btn:hover {
border-color: var(--color-on-background);
}
.ap-tag-header__back {
color: var(--color-on-offset);
font-size: var(--font-size-s);
text-decoration: none;
}
.ap-tag-header__back:hover {
color: var(--color-on-background);
text-decoration: underline;
}
@media (max-width: 640px) {
.ap-tag-header {
flex-direction: column;
gap: var(--space-s);
}
.ap-tag-header__actions {
flex-wrap: wrap;
}
}
/* ==========================================================================
Reader Tools Bar (Explore link, etc.)
========================================================================== */
.ap-reader-tools {
display: flex;
gap: var(--space-s);
justify-content: flex-end;
margin-bottom: var(--space-s);
}
.ap-reader-tools__explore {
color: var(--color-on-offset);
font-size: var(--font-size-s);
text-decoration: none;
}
.ap-reader-tools__explore:hover {
color: var(--color-on-background);
text-decoration: underline;
}
/* Followed tags bar */
.ap-followed-tags {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--space-xs);
padding: var(--space-xs) 0;
margin-bottom: var(--space-s);
font-size: var(--font-size-s);
}
.ap-followed-tags__label {
color: var(--color-on-offset);
font-weight: 600;
}
/* ==========================================================================
New Posts Banner
========================================================================== */
.ap-new-posts-banner {
left: 0;
position: sticky;
right: 0;
top: 0;
z-index: 10;
}
.ap-new-posts-banner__btn {
background: var(--color-primary);
border: none;
border-radius: var(--border-radius-small);
color: var(--color-on-primary);
cursor: pointer;
display: block;
font-family: inherit;
font-size: var(--font-size-s);
margin: 0 auto var(--space-s);
padding: var(--space-xs) var(--space-m);
text-align: center;
width: auto;
}
.ap-new-posts-banner__btn:hover {
opacity: 0.9;
}
/* ==========================================================================
Read State
========================================================================== */
.ap-card--read {
opacity: 0.7;
transition: opacity 0.3s ease;
}
.ap-card--read:hover {
opacity: 1;
}
/* ==========================================================================
Unread Toggle
========================================================================== */
.ap-unread-toggle {
margin-left: auto;
}
.ap-unread-toggle--active {
background: color-mix(in srgb, var(--color-primary) 12%, transparent);
font-weight: 600;
}
/* ==========================================================================
Quote Embeds
========================================================================== */
.ap-quote-embed {
border: var(--border-width-thin) solid var(--color-outline);
border-radius: var(--border-radius-small);
margin-top: var(--space-s);
overflow: hidden;
transition: border-color 0.15s ease;
}
.ap-quote-embed:hover {
border-color: var(--color-outline-variant);
}
.ap-quote-embed--pending {
border-style: dashed;
}
.ap-quote-embed__link {
color: inherit;
display: block;
padding: var(--space-s) var(--space-m);
text-decoration: none;
}
.ap-quote-embed__link:hover {
background: color-mix(in srgb, var(--color-offset) 50%, transparent);
}
.ap-quote-embed__author {
align-items: center;
display: flex;
gap: var(--space-xs);
margin-bottom: var(--space-xs);
}
.ap-quote-embed__avatar {
border-radius: 50%;
flex-shrink: 0;
height: 24px;
object-fit: cover;
width: 24px;
}
.ap-quote-embed__avatar--default {
align-items: center;
background: var(--color-offset);
color: var(--color-on-offset);
display: inline-flex;
font-size: var(--font-size-xs);
font-weight: 600;
justify-content: center;
}
.ap-quote-embed__author-info {
flex: 1;
min-width: 0;
}
.ap-quote-embed__name {
font-size: var(--font-size-s);
font-weight: 600;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.ap-quote-embed__handle {
color: var(--color-on-offset);
font-size: var(--font-size-xs);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.ap-quote-embed__time {
color: var(--color-on-offset);
flex-shrink: 0;
font-size: var(--font-size-xs);
white-space: nowrap;
}
.ap-quote-embed__title {
font-size: var(--font-size-s);
font-weight: 600;
margin: 0 0 var(--space-xs);
}
.ap-quote-embed__content {
color: var(--color-on-background);
font-size: var(--font-size-s);
line-height: calc(4 / 3 * 1em);
max-height: calc(1.333em * 6);
overflow: hidden;
}
.ap-quote-embed__content a {
display: inline;
}
.ap-quote-embed__content a span {
display: inline;
}
.ap-quote-embed__content p {
margin: 0 0 var(--space-xs);
}
.ap-quote-embed__content p:last-child {
margin-bottom: 0;
}
.ap-quote-embed__media {
margin-top: var(--space-xs);
}
.ap-quote-embed__photo {
border-radius: var(--border-radius-small);
max-height: 160px;
max-width: 100%;
object-fit: cover;
}
/* ==========================================================================
Poll / Question
========================================================================== */
.ap-poll {
margin-top: var(--space-s);
}
.ap-poll__option {
position: relative;
padding: var(--space-xs) var(--space-s);
margin-bottom: var(--space-xs);
border-radius: var(--border-radius-small);
background: var(--color-offset);
overflow: hidden;
}
.ap-poll__bar {
position: absolute;
top: 0;
left: 0;
bottom: 0;
background: var(--color-primary);
opacity: 0.15;
border-radius: var(--border-radius-small);
}
.ap-poll__label {
position: relative;
font-size: var(--font-size-s);
color: var(--color-on-background);
}
.ap-poll__votes {
position: relative;
float: right;
font-size: var(--font-size-s);
font-weight: 600;
color: var(--color-on-offset);
}
.ap-poll__footer {
font-size: var(--font-size-xs);
color: var(--color-on-offset);
margin-top: var(--space-xs);
}
/* Hashtag tab sources info line */
.ap-hashtag-sources {
color: var(--color-on-offset);
font-size: var(--font-size-s);
margin: 0;
padding: var(--space-s) 0 var(--space-xs);
}
/* Custom emoji */
.ap-custom-emoji {
height: 1.2em;
width: auto;
vertical-align: middle;
display: inline;
margin: 0 0.05em;
}

242
assets/css/federation.css Normal file
View File

@@ -0,0 +1,242 @@
/* ==========================================================================
Federation Management
========================================================================== */
.ap-federation__section {
margin-block-end: var(--space-l);
}
.ap-federation__section h2 {
margin-block-end: var(--space-s);
}
.ap-federation__stats-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(9rem, 1fr));
gap: var(--space-s);
}
.ap-federation__stat-card {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--space-xs);
padding: var(--space-s);
background: var(--color-offset);
border-radius: var(--border-radius-small);
text-align: center;
}
.ap-federation__stat-count {
font-size: var(--font-size-xl);
font-weight: 600;
color: var(--color-on-background);
}
.ap-federation__stat-label {
font-size: var(--font-size-s);
color: var(--color-on-offset);
word-break: break-word;
}
.ap-federation__actions-row {
display: flex;
flex-wrap: wrap;
gap: var(--space-s);
align-items: center;
}
.ap-federation__result {
margin-block-start: var(--space-xs);
color: var(--color-green50);
font-size: var(--font-size-s);
}
.ap-federation__error {
margin-block-start: var(--space-xs);
color: var(--color-red45);
font-size: var(--font-size-s);
}
.ap-federation__lookup-form {
display: flex;
gap: var(--space-s);
}
.ap-federation__lookup-input {
flex: 1;
min-width: 0;
padding: 0.5rem 0.75rem;
border: var(--border-width-thin) solid var(--color-outline);
border-radius: var(--border-radius-small);
font: inherit;
color: var(--color-on-background);
background: var(--color-background);
}
.ap-federation__json-view {
margin-block-start: var(--space-s);
padding: var(--space-m);
background: var(--color-offset);
border-radius: var(--border-radius-small);
font-family: monospace;
font-size: var(--font-size-s);
color: var(--color-on-background);
max-height: 24rem;
overflow: auto;
white-space: pre-wrap;
word-break: break-word;
}
.ap-federation__posts-list {
display: flex;
flex-direction: column;
gap: var(--space-xs);
}
.ap-federation__post-row {
display: flex;
justify-content: space-between;
align-items: center;
gap: var(--space-m);
padding: var(--space-s);
background: var(--color-offset);
border-radius: var(--border-radius-small);
}
.ap-federation__post-info {
display: flex;
flex-direction: column;
gap: var(--space-xs);
min-width: 0;
}
.ap-federation__post-title {
font-weight: 500;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.ap-federation__post-meta {
display: flex;
align-items: center;
gap: var(--space-xs);
font-size: var(--font-size-s);
color: var(--color-on-offset);
}
.ap-federation__post-actions {
display: flex;
gap: var(--space-xs);
flex-shrink: 0;
}
.ap-federation__post-btn {
padding: var(--space-xs) var(--space-s);
font-size: var(--font-size-s);
border: var(--border-width-thin) solid var(--color-outline);
border-radius: var(--border-radius-small);
background: var(--color-background);
color: var(--color-on-background);
cursor: pointer;
}
.ap-federation__post-btn:hover {
background: var(--color-offset);
}
.ap-federation__post-btn--danger {
color: var(--color-red45);
border-color: var(--color-red45);
}
.ap-federation__post-btn--danger:hover {
background: color-mix(in srgb, var(--color-red45) 10%, transparent);
}
.ap-federation__modal-overlay {
position: fixed;
inset: 0;
z-index: 1000;
display: flex;
align-items: center;
justify-content: center;
background: hsl(var(--tint-neutral) 10% / 0.5);
}
.ap-federation__modal {
width: min(90vw, 48rem);
max-height: 80vh;
display: flex;
flex-direction: column;
background: var(--color-background);
border-radius: var(--border-radius-small);
box-shadow: 0 4px 24px hsl(var(--tint-neutral) 10% / 0.2);
}
.ap-federation__modal-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: var(--space-s) var(--space-m);
border-block-end: var(--border-width-thin) solid var(--color-outline);
}
.ap-federation__modal-header h3 {
margin: 0;
font-size: var(--font-size-m);
}
.ap-federation__modal-close {
font-size: var(--font-size-xl);
line-height: 1;
padding: 0 var(--space-xs);
border: none;
background: none;
color: var(--color-on-offset);
cursor: pointer;
}
.ap-federation__modal .ap-federation__json-view {
margin: 0;
border-radius: 0 0 var(--border-radius-small) var(--border-radius-small);
flex: 1;
overflow: auto;
}
@media (max-width: 40rem) {
.ap-federation__post-row {
flex-direction: column;
align-items: flex-start;
}
.ap-federation__lookup-form {
flex-direction: column;
}
}
/* Follow request approve/reject actions */
.ap-follow-request {
margin-block-end: var(--space-m);
}
.ap-follow-request__actions {
display: flex;
gap: var(--space-s);
margin-block-start: var(--space-xs);
padding-inline-start: var(--space-l);
}
.ap-follow-request__form {
display: inline;
}
.button--danger {
background-color: var(--color-red45);
color: white;
}
.button--danger:hover {
background-color: var(--color-red35, #c0392b);
}

236
assets/css/interactions.css Normal file
View File

@@ -0,0 +1,236 @@
/* ==========================================================================
Tags
========================================================================== */
.ap-card__tags {
display: flex;
flex-wrap: wrap;
gap: var(--space-xs);
margin-bottom: var(--space-s);
}
.ap-card__tag {
background: var(--color-offset-variant);
border-radius: var(--border-radius-large);
color: var(--color-on-offset);
font-size: var(--font-size-s);
padding: 2px var(--space-xs);
text-decoration: none;
}
.ap-card__tag:hover {
background: var(--color-offset-variant-darker);
color: var(--color-on-background);
}
.ap-card__mention {
background: color-mix(in srgb, var(--color-primary) 12%, transparent);
border-radius: var(--border-radius-large);
color: var(--color-primary-on-background);
font-size: var(--font-size-s);
padding: 2px var(--space-xs);
text-decoration: none;
}
.ap-card__mention:hover {
background: color-mix(in srgb, var(--color-primary) 22%, transparent);
color: var(--color-primary-on-background);
}
.ap-card__mention--legacy {
cursor: default;
opacity: 0.7;
}
/* Hashtag stuffing collapse */
.ap-hashtag-overflow {
margin: var(--space-xs) 0;
font-size: var(--font-size-s);
}
.ap-hashtag-overflow summary {
cursor: pointer;
color: var(--color-on-offset);
list-style: none;
}
.ap-hashtag-overflow summary::before {
content: "▸ ";
}
.ap-hashtag-overflow[open] summary::before {
content: "▾ ";
}
.ap-hashtag-overflow p {
margin-top: var(--space-xs);
}
/* ==========================================================================
Interaction Buttons
========================================================================== */
.ap-card__actions {
border-top: var(--border-width-thin) solid var(--color-outline);
display: flex;
flex-wrap: wrap;
gap: 2px;
padding-top: var(--space-s);
}
.ap-card__action {
align-items: center;
background: transparent;
border: 0;
border-radius: var(--border-radius-small);
color: var(--color-on-offset);
cursor: pointer;
display: inline-flex;
font-size: var(--font-size-s);
gap: 0.3em;
min-height: 36px;
padding: 0.25em 0.6em;
text-decoration: none;
transition:
background-color 0.15s ease,
color 0.15s ease;
}
.ap-card__action:hover {
background: var(--color-offset-variant);
color: var(--color-on-background);
}
/* Color-coded hover states per action type */
.ap-card__action--reply:hover {
background: color-mix(in srgb, var(--color-primary) 12%, transparent);
color: var(--color-primary);
}
.ap-card__action--boost:hover {
background: color-mix(in srgb, var(--color-green50) 12%, transparent);
color: var(--color-green50);
}
.ap-card__action--like:hover {
background: color-mix(in srgb, var(--color-red45) 12%, transparent);
color: var(--color-red45);
}
.ap-card__action--link:hover {
background: var(--color-offset-variant);
color: var(--color-on-background);
}
.ap-card__action--save:hover {
background: color-mix(in srgb, var(--color-primary) 12%, transparent);
color: var(--color-primary);
}
/* Active interaction states */
.ap-card__action--like.ap-card__action--active {
background: color-mix(in srgb, var(--color-red45) 12%, transparent);
color: var(--color-red45);
}
.ap-card__action--boost.ap-card__action--active {
background: color-mix(in srgb, var(--color-green50) 12%, transparent);
color: var(--color-green50);
}
.ap-card__action--save.ap-card__action--active {
background: color-mix(in srgb, var(--color-primary) 12%, transparent);
color: var(--color-primary);
}
.ap-card__action:disabled {
cursor: wait;
opacity: 0.5;
}
/* Interaction counts */
.ap-card__count {
font-size: var(--font-size-xs);
color: inherit;
opacity: 0.7;
margin-left: 0.1em;
font-variant-numeric: tabular-nums;
}
/* Error message */
.ap-card__action-error {
color: var(--color-error);
font-size: var(--font-size-s);
width: 100%;
}
/* ==========================================================================
Pagination
========================================================================== */
.ap-pagination {
border-top: var(--border-width-thin) solid var(--color-outline);
display: flex;
gap: var(--space-m);
justify-content: space-between;
margin-top: var(--space-m);
padding-top: var(--space-m);
}
.ap-pagination a {
color: var(--color-primary-on-background);
text-decoration: none;
}
.ap-pagination a:hover {
text-decoration: underline;
}
/* Hidden once Alpine is active (JS replaces with infinite scroll) */
.ap-pagination--js-hidden {
/* Shown by default for no-JS fallback — Alpine hides via display:none */
}
/* ==========================================================================
Infinite Scroll / Load More
========================================================================== */
.ap-load-more {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--space-s);
padding: var(--space-m) 0;
}
.ap-load-more__sentinel {
height: 1px;
width: 100%;
}
.ap-load-more__btn {
background: var(--color-offset);
border: var(--border-width-thin) solid var(--color-outline);
border-radius: var(--border-radius-small);
color: var(--color-on-background);
cursor: pointer;
font-size: var(--font-size-s);
padding: var(--space-xs) var(--space-m);
transition: background 0.15s;
}
.ap-load-more__btn:hover:not(:disabled) {
background: var(--color-offset-variant);
}
.ap-load-more__btn:disabled {
cursor: wait;
opacity: 0.6;
}
.ap-load-more__done {
color: var(--color-on-offset);
font-size: var(--font-size-s);
margin: 0;
text-align: center;
}

315
assets/css/media.css Normal file
View File

@@ -0,0 +1,315 @@
/* ==========================================================================
Photo Gallery
========================================================================== */
.ap-card__gallery {
border-radius: var(--border-radius-small);
display: grid;
gap: 2px;
margin-bottom: var(--space-s);
overflow: hidden;
}
.ap-card__gallery-link {
appearance: none;
background: none;
border: 0;
cursor: pointer;
display: block;
padding: 0;
position: relative;
}
.ap-card__gallery img {
background: var(--color-offset-variant);
display: block;
height: 280px;
object-fit: cover;
width: 100%;
transition: filter 0.2s ease;
}
@media (max-width: 480px) {
.ap-card__gallery img {
height: 180px;
}
}
.ap-card__gallery-link:hover img {
filter: brightness(0.92);
}
.ap-card__gallery-link--more::after {
background: hsl(var(--tint-neutral) 10% / 0.5);
bottom: 0;
content: "";
left: 0;
position: absolute;
right: 0;
top: 0;
}
.ap-card__gallery-more {
color: var(--color-neutral99);
font-size: 1.5em;
font-weight: 600;
left: 50%;
position: absolute;
top: 50%;
transform: translate(-50%, -50%);
z-index: 1;
}
/* 1 photo */
.ap-card__gallery--1 {
grid-template-columns: 1fr;
}
.ap-card__gallery--1 img {
height: auto;
max-height: 500px;
}
/* 2 photos — side by side */
.ap-card__gallery--2 {
grid-template-columns: 1fr 1fr;
}
/* 3 photos — one large, two small */
.ap-card__gallery--3 {
grid-template-columns: 2fr 1fr;
grid-template-rows: 1fr 1fr;
}
.ap-card__gallery--3 img:first-child {
grid-row: 1 / 3;
height: 100%;
}
/* 4+ photos — 2x2 grid */
.ap-card__gallery--4 {
grid-template-columns: 1fr 1fr;
grid-template-rows: 1fr 1fr;
}
/* ==========================================================================
Photo Lightbox
========================================================================== */
[x-cloak] {
display: none !important;
}
.ap-lightbox {
align-items: center;
background: hsl(var(--tint-neutral) 10% / 0.92);
display: flex;
inset: 0;
justify-content: center;
position: fixed;
z-index: 9999;
}
.ap-lightbox__img {
max-height: 90vh;
max-width: 95vw;
object-fit: contain;
}
.ap-lightbox__close {
background: none;
border: 0;
color: white;
cursor: pointer;
font-size: 2rem;
line-height: 1;
padding: var(--space-s);
position: absolute;
right: var(--space-m);
top: var(--space-m);
}
.ap-lightbox__close:hover {
opacity: 0.7;
}
.ap-lightbox__prev,
.ap-lightbox__next {
background: none;
border: 0;
color: white;
cursor: pointer;
font-size: 3rem;
line-height: 1;
padding: var(--space-m);
position: absolute;
top: 50%;
transform: translateY(-50%);
}
.ap-lightbox__prev {
left: var(--space-s);
}
.ap-lightbox__next {
right: var(--space-s);
}
.ap-lightbox__prev:hover,
.ap-lightbox__next:hover {
opacity: 0.7;
}
.ap-lightbox__counter {
bottom: var(--space-m);
color: white;
font-size: var(--font-size-s);
left: 50%;
position: absolute;
transform: translateX(-50%);
}
/* ==========================================================================
Link Preview Card
========================================================================== */
.ap-link-previews {
margin-bottom: var(--space-s);
}
.ap-link-preview {
display: flex;
border: var(--border-width-thin) solid var(--color-outline);
border-radius: var(--border-radius-small);
overflow: hidden;
text-decoration: none;
color: inherit;
transition: border-color 0.2s ease;
}
.ap-link-preview:hover {
border-color: var(--color-primary);
}
.ap-link-preview__text {
flex: 1;
min-width: 0;
padding: var(--space-s) var(--space-m);
display: flex;
flex-direction: column;
justify-content: center;
gap: 0.2em;
}
.ap-link-preview__title {
font-weight: 600;
font-size: var(--font-size-s);
margin: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.ap-link-preview__desc {
font-size: var(--font-size-s);
color: var(--color-on-offset);
margin: 0;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.ap-link-preview__domain {
font-size: var(--font-size-xs);
color: var(--color-on-offset);
margin: 0;
display: flex;
align-items: center;
gap: 0.3em;
}
.ap-link-preview__favicon {
width: 14px;
height: 14px;
}
.ap-link-preview__image {
flex-shrink: 0;
width: 120px;
}
.ap-link-preview__image img {
display: block;
width: 100%;
height: 100%;
object-fit: cover;
}
/* ==========================================================================
Video Embed
========================================================================== */
.ap-card__video {
margin-bottom: var(--space-s);
}
.ap-card__video video {
border-radius: var(--border-radius-small);
max-height: 400px;
width: 100%;
}
/* ==========================================================================
Audio Player
========================================================================== */
.ap-card__audio {
margin-bottom: var(--space-s);
}
.ap-card__audio audio {
width: 100%;
}
/* Gallery items — positioned for ALT badge overlay */
.ap-card__gallery-item {
position: relative;
}
/* ALT text badges */
.ap-media__alt-badge {
position: absolute;
bottom: 0.5rem;
left: 0.5rem;
background: hsl(var(--tint-neutral) 10% / 0.7);
color: var(--color-neutral99);
font-size: 0.65rem;
font-weight: 700;
padding: 0.15rem 0.35rem;
border-radius: var(--border-radius-small);
border: none;
cursor: pointer;
text-transform: uppercase;
letter-spacing: 0.03em;
z-index: 1;
}
.ap-media__alt-badge:hover {
background: hsl(var(--tint-neutral) 10% / 0.9);
}
.ap-media__alt-text {
position: absolute;
bottom: 2.2rem;
left: 0.5rem;
right: 0.5rem;
background: hsl(var(--tint-neutral) 10% / 0.85);
color: var(--color-neutral99);
font-size: var(--font-size-s);
padding: 0.5rem;
border-radius: var(--border-radius-small);
max-height: 8rem;
overflow-y: auto;
z-index: 2;
}

158
assets/css/messages.css Normal file
View File

@@ -0,0 +1,158 @@
/* ==========================================================================
Messages
========================================================================== */
.ap-messages__layout {
display: grid;
grid-template-columns: 240px 1fr;
gap: var(--space-m);
min-height: 300px;
}
.ap-messages__sidebar {
border-right: var(--border-width-thin) solid var(--color-outline);
display: flex;
flex-direction: column;
gap: 2px;
padding-right: var(--space-m);
overflow-y: auto;
max-height: 70vh;
}
.ap-messages__partner {
align-items: center;
border-radius: var(--border-radius-small);
color: var(--color-on-background);
display: flex;
gap: var(--space-s);
padding: var(--space-s);
text-decoration: none;
transition: background 0.15s ease;
}
.ap-messages__partner:hover {
background: var(--color-offset);
}
.ap-messages__partner--active {
background: var(--color-offset);
border-left: 3px solid var(--color-primary);
font-weight: var(--font-weight-bold);
}
.ap-messages__partner-avatar {
flex-shrink: 0;
height: 32px;
position: relative;
width: 32px;
}
.ap-messages__partner-avatar img {
border-radius: 50%;
height: 100%;
object-fit: cover;
position: absolute;
inset: 0;
width: 100%;
z-index: 1;
}
.ap-messages__partner-initial {
align-items: center;
background: var(--color-offset-variant);
border-radius: 50%;
color: var(--color-on-offset);
display: flex;
font-size: var(--font-size-s);
height: 100%;
justify-content: center;
width: 100%;
}
.ap-messages__partner-info {
display: flex;
flex-direction: column;
min-width: 0;
overflow: hidden;
}
.ap-messages__partner-name {
font-size: var(--font-size-s);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.ap-messages__partner-handle {
color: var(--color-on-offset);
font-size: var(--font-size-xs);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.ap-messages__content {
min-width: 0;
}
.ap-message--outbound {
border-left: 3px solid var(--color-primary);
}
.ap-message .ap-notification__time {
padding-right: var(--space-l);
}
.ap-message__direction {
color: var(--color-on-offset);
font-size: var(--font-size-s);
margin-right: var(--space-xs);
}
.ap-message__content {
color: var(--color-on-background);
font-size: var(--font-size-s);
line-height: 1.5;
margin-top: var(--space-xs);
}
.ap-message__content p {
margin: 0 0 var(--space-xs);
}
.ap-message__content p:last-child {
margin-bottom: 0;
}
/* Inline mention links in DM content (Mastodon wraps @user in span inside a link) */
.ap-message__content .h-card,
.ap-message__content a.mention,
.ap-message__content a span {
display: inline;
}
.ap-message__content a {
overflow-wrap: break-word;
}
@media (max-width: 640px) {
.ap-messages__layout {
grid-template-columns: 1fr;
}
.ap-messages__sidebar {
border-bottom: var(--border-width-thin) solid var(--color-outline);
border-right: none;
flex-direction: row;
max-height: none;
overflow-x: auto;
padding-bottom: var(--space-s);
padding-right: 0;
-webkit-overflow-scrolling: touch;
}
.ap-messages__partner {
flex-shrink: 0;
white-space: nowrap;
}
}

119
assets/css/moderation.css Normal file
View File

@@ -0,0 +1,119 @@
/* ==========================================================================
Moderation
========================================================================== */
.ap-moderation__section {
margin-bottom: var(--space-l);
}
.ap-moderation__section h2 {
font-size: var(--font-size-l);
margin-bottom: var(--space-s);
}
.ap-moderation__list {
list-style: none;
margin: 0;
padding: 0;
}
.ap-moderation__entry {
align-items: center;
border-bottom: var(--border-width-thin) solid var(--color-outline);
display: flex;
gap: var(--space-s);
justify-content: space-between;
padding: var(--space-s) 0;
}
.ap-moderation__entry a {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.ap-moderation__remove {
background: transparent;
border: var(--border-width-thin) solid var(--color-outline);
border-radius: var(--border-radius-small);
color: var(--color-on-offset);
cursor: pointer;
flex-shrink: 0;
font-size: var(--font-size-s);
padding: var(--space-xs) var(--space-s);
}
.ap-moderation__remove:hover {
border-color: var(--color-error);
color: var(--color-error);
}
.ap-moderation__add-form {
display: flex;
gap: var(--space-s);
}
.ap-moderation__input {
background: var(--color-background);
border: var(--border-width-thick) solid var(--color-outline);
border-radius: var(--border-radius-small);
color: var(--color-on-background);
flex: 1;
font-size: var(--font-size-m);
padding: var(--space-xs) var(--space-s);
}
.ap-moderation__add-btn {
background: var(--color-offset);
border: var(--border-width-thin) solid var(--color-outline);
border-radius: var(--border-radius-small);
color: var(--color-on-background);
cursor: pointer;
font-size: var(--font-size-m);
padding: var(--space-xs) var(--space-m);
}
.ap-moderation__add-btn:hover {
background: var(--color-offset-variant);
}
.ap-moderation__add-btn:disabled {
cursor: not-allowed;
opacity: 0.5;
}
.ap-moderation__error {
color: var(--color-error);
font-size: var(--font-size-s);
margin-top: var(--space-xs);
}
.ap-moderation__empty {
color: var(--color-on-offset);
font-size: var(--font-size-s);
font-style: italic;
}
.ap-moderation__hint {
color: var(--color-on-offset);
font-size: var(--font-size-s);
margin-bottom: var(--space-s);
}
.ap-moderation__filter-toggle {
display: flex;
gap: var(--space-m);
}
.ap-moderation__radio {
align-items: center;
cursor: pointer;
display: flex;
gap: var(--space-xs);
}
.ap-moderation__radio input {
accent-color: var(--color-primary);
cursor: pointer;
}

View File

@@ -0,0 +1,191 @@
/* ==========================================================================
Notifications
========================================================================== */
/* Notifications Toolbar */
.ap-notifications__toolbar {
display: flex;
gap: var(--space-s);
margin-bottom: var(--space-m);
}
.ap-notifications__btn {
background: var(--color-offset);
border: var(--border-width-thin) solid var(--color-outline);
border-radius: var(--border-radius-small);
color: var(--color-on-background);
cursor: pointer;
font-size: var(--font-size-s);
padding: var(--space-xs) var(--space-m);
transition: all 0.2s ease;
}
.ap-notifications__btn:hover {
background: var(--color-offset-variant);
border-color: var(--color-outline-variant);
}
.ap-notifications__btn--danger {
color: var(--color-error);
}
.ap-notifications__btn--danger:hover {
border-color: var(--color-error);
}
.ap-notification {
align-items: flex-start;
background: var(--color-offset);
border: var(--border-width-thin) solid var(--color-outline);
border-radius: var(--border-radius-small);
display: flex;
gap: var(--space-s);
padding: var(--space-m);
position: relative;
}
.ap-notification--unread {
border-color: var(--color-yellow50);
box-shadow: 0 0 8px 0 hsl(var(--tint-yellow) 50% / 0.3);
}
.ap-notification__avatar-wrap {
flex-shrink: 0;
position: relative;
}
.ap-notification__avatar-wrap {
height: 40px;
width: 40px;
}
.ap-notification__avatar {
border: var(--border-width-thin) solid var(--color-outline);
border-radius: 50%;
height: 40px;
object-fit: cover;
width: 40px;
}
.ap-notification__avatar-wrap > img {
position: absolute;
inset: 0;
z-index: 1;
}
.ap-notification__avatar--default {
align-items: center;
background: var(--color-offset-variant);
color: var(--color-on-offset);
display: inline-flex;
font-size: 1.1em;
font-weight: 600;
justify-content: center;
}
.ap-notification__type-badge {
bottom: -2px;
font-size: 0.75em;
position: absolute;
right: -4px;
}
.ap-notification__body {
flex: 1;
min-width: 0;
}
.ap-notification__actor {
font-weight: 600;
}
.ap-notification__action {
color: var(--color-on-offset);
}
.ap-notification__target {
color: var(--color-on-offset);
display: block;
font-size: var(--font-size-s);
margin-top: var(--space-xs);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.ap-notification__excerpt {
background: var(--color-offset-variant);
border-radius: var(--border-radius-small);
font-size: var(--font-size-s);
margin-top: var(--space-xs);
padding: var(--space-xs) var(--space-s);
}
.ap-notification__time {
color: var(--color-on-offset);
flex-shrink: 0;
font-size: var(--font-size-xs);
}
.ap-notification__dismiss {
position: absolute;
right: var(--space-xs);
top: var(--space-xs);
}
.ap-notification__dismiss-btn {
background: transparent;
border: 0;
border-radius: var(--border-radius-small);
color: var(--color-on-offset);
cursor: pointer;
font-size: var(--font-size-m);
line-height: 1;
padding: 2px 6px;
transition: all 0.2s ease;
}
.ap-notification__dismiss-btn:hover {
background: var(--color-offset-variant);
color: var(--color-error);
}
.ap-notification__actions {
display: flex;
gap: var(--space-s);
margin-top: var(--space-s);
}
.ap-notification__reply-btn,
.ap-notification__thread-btn {
border: var(--border-width-thin) solid var(--color-outline);
border-radius: var(--border-radius-small);
color: var(--color-on-offset);
font-size: var(--font-size-s);
padding: var(--space-xs) var(--space-s);
text-decoration: none;
transition: all 0.2s ease;
}
.ap-notification__reply-btn:hover,
.ap-notification__thread-btn:hover {
background: var(--color-offset-variant);
border-color: var(--color-outline-variant);
color: var(--color-on-background);
}
.ap-notification__handle {
color: var(--color-on-offset);
font-size: var(--font-size-s);
margin-left: var(--space-xs);
}
.ap-notifications__btn--primary {
background: var(--color-primary);
color: var(--color-on-primary, #fff);
text-decoration: none;
}
.ap-notifications__btn--primary:hover {
opacity: 0.9;
}

308
assets/css/profile.css Normal file
View File

@@ -0,0 +1,308 @@
/* ==========================================================================
Remote Profile
========================================================================== */
.ap-profile__header {
border-radius: var(--border-radius-small);
height: 200px;
margin-bottom: var(--space-m);
overflow: hidden;
}
.ap-profile__header-img {
height: 100%;
object-fit: cover;
width: 100%;
}
.ap-profile__info {
margin-bottom: var(--space-l);
}
.ap-profile__avatar-wrap {
height: 80px;
margin-bottom: var(--space-s);
position: relative;
width: 80px;
}
.ap-profile__avatar-wrap > img {
position: absolute;
inset: 0;
z-index: 1;
}
.ap-profile__avatar {
border: var(--border-width-thickest) solid var(--color-background);
border-radius: 50%;
height: 80px;
object-fit: cover;
width: 80px;
}
.ap-profile__avatar--placeholder {
align-items: center;
background: var(--color-offset-variant);
color: var(--color-on-offset);
display: flex;
font-size: 2em;
font-weight: 600;
justify-content: center;
}
.ap-profile__name {
font-size: var(--font-size-xl);
margin-bottom: var(--space-xs);
}
.ap-profile__handle {
color: var(--color-on-offset);
margin-bottom: var(--space-s);
}
.ap-profile__bio {
line-height: var(--line-height-prose);
margin-bottom: var(--space-s);
}
.ap-profile__bio a {
color: var(--color-primary-on-background);
}
/* Override upstream .mention { display: grid } for bio content */
.ap-profile__bio .h-card {
display: inline;
}
.ap-profile__bio .h-card a,
.ap-profile__bio a.u-url.mention {
display: inline;
white-space: nowrap;
}
.ap-profile__bio .h-card a span,
.ap-profile__bio a.u-url.mention span {
display: inline;
}
.ap-profile__bio a.mention.hashtag {
display: inline;
white-space: nowrap;
}
.ap-profile__bio a.mention.hashtag span {
display: inline;
}
/* Mastodon invisible/ellipsis spans for long URLs in bios */
.ap-profile__bio .invisible {
display: none;
}
.ap-profile__bio .ellipsis::after {
content: "…";
}
.ap-profile__actions {
display: flex;
flex-wrap: wrap;
gap: var(--space-s);
margin-top: var(--space-m);
}
.ap-profile__action {
background: transparent;
border: var(--border-width-thin) solid var(--color-outline);
border-radius: var(--border-radius-small);
color: var(--color-on-background);
cursor: pointer;
font-size: var(--font-size-s);
padding: var(--space-xs) var(--space-m);
text-decoration: none;
}
.ap-profile__action:hover {
background: var(--color-offset);
}
.ap-profile__action--follow.ap-profile__action--active {
background: var(--color-primary);
border-color: var(--color-primary);
color: var(--color-on-primary, var(--color-neutral99));
}
.ap-profile__action--danger:hover {
border-color: var(--color-error);
color: var(--color-error);
}
.ap-profile__posts {
margin-top: var(--space-l);
}
.ap-profile__posts h3 {
border-bottom: var(--border-width-thin) solid var(--color-outline);
font-size: var(--font-size-l);
margin-bottom: var(--space-m);
padding-bottom: var(--space-s);
}
/* ==========================================================================
My Profile — Admin Profile Header
========================================================================== */
.ap-my-profile {
border: var(--border-width-thin) solid var(--color-outline);
border-radius: var(--border-radius-small);
margin-bottom: var(--space-m);
overflow: hidden;
}
.ap-my-profile__header {
height: 160px;
overflow: hidden;
}
.ap-my-profile__header-img {
height: 100%;
object-fit: cover;
width: 100%;
}
.ap-my-profile__info {
padding: var(--space-m);
}
.ap-my-profile__avatar-wrap {
margin-bottom: var(--space-s);
margin-top: -40px;
}
.ap-my-profile__avatar {
border: 3px solid var(--color-background);
border-radius: 50%;
height: 72px;
object-fit: cover;
width: 72px;
}
.ap-my-profile__avatar--placeholder {
align-items: center;
background: var(--color-offset-variant);
color: var(--color-on-offset);
display: flex;
font-size: 1.8em;
font-weight: 600;
justify-content: center;
}
.ap-my-profile__name {
font-size: var(--font-size-xl);
margin-bottom: 0;
}
.ap-my-profile__handle {
color: var(--color-on-offset);
font-size: var(--font-size-s);
margin-bottom: var(--space-s);
}
.ap-my-profile__bio {
line-height: var(--line-height-prose);
margin-bottom: var(--space-s);
}
.ap-my-profile__bio a {
color: var(--color-primary-on-background);
}
/* Override upstream .mention { display: grid } for bio content */
.ap-my-profile__bio .h-card { display: inline; }
.ap-my-profile__bio .h-card a,
.ap-my-profile__bio a.u-url.mention { display: inline; white-space: nowrap; }
.ap-my-profile__bio .h-card a span,
.ap-my-profile__bio a.u-url.mention span { display: inline; }
.ap-my-profile__bio a.mention.hashtag { display: inline; white-space: nowrap; }
.ap-my-profile__bio a.mention.hashtag span { display: inline; }
.ap-my-profile__bio .invisible { display: none; }
.ap-my-profile__bio .ellipsis::after { content: "…"; }
.ap-my-profile__fields {
border: var(--border-width-thin) solid var(--color-outline);
border-radius: var(--border-radius-small);
margin: var(--space-s) 0;
overflow: hidden;
}
.ap-my-profile__field {
border-bottom: var(--border-width-thin) solid var(--color-outline);
display: grid;
grid-template-columns: 120px 1fr;
}
.ap-my-profile__field:last-child {
border-bottom: 0;
}
.ap-my-profile__field-name {
background: var(--color-offset);
color: var(--color-on-offset);
font-size: var(--font-size-s);
font-weight: 600;
padding: var(--space-xs) var(--space-s);
text-transform: uppercase;
letter-spacing: 0.03em;
}
.ap-my-profile__field-value {
font-size: var(--font-size-s);
overflow: hidden;
padding: var(--space-xs) var(--space-s);
text-overflow: ellipsis;
white-space: nowrap;
}
.ap-my-profile__field-value a {
color: var(--color-primary-on-background);
}
.ap-my-profile__stats {
display: flex;
gap: var(--space-m);
margin-bottom: var(--space-s);
}
.ap-my-profile__stat {
color: var(--color-on-offset);
font-size: var(--font-size-s);
text-decoration: none;
}
.ap-my-profile__stat:hover {
color: var(--color-on-background);
}
.ap-my-profile__stat strong {
color: var(--color-on-background);
font-weight: 600;
}
.ap-my-profile__edit {
border: var(--border-width-thin) solid var(--color-outline);
border-radius: var(--border-radius-small);
color: var(--color-on-background);
display: inline-block;
font-size: var(--font-size-s);
padding: var(--space-xs) var(--space-m);
text-decoration: none;
}
.ap-my-profile__edit:hover {
background: var(--color-offset);
border-color: var(--color-outline-variant);
}
/* When no header image, don't offset avatar */
.ap-my-profile__info:first-child .ap-my-profile__avatar-wrap {
margin-top: 0;
}

33
assets/css/responsive.css Normal file
View File

@@ -0,0 +1,33 @@
/* ==========================================================================
Responsive
========================================================================== */
@media (max-width: 640px) {
.ap-tabs {
gap: 0;
}
.ap-tab {
padding: var(--space-xs) var(--space-s);
}
.ap-card__gallery--3 {
grid-template-columns: 1fr 1fr;
grid-template-rows: auto auto;
}
.ap-card__gallery--3 img:first-child {
grid-column: 1 / 3;
grid-row: 1;
height: 200px;
}
.ap-card__actions {
gap: var(--space-xs);
}
.ap-card__action {
font-size: 0.75rem;
padding: var(--space-xs);
}
}

74
assets/css/skeleton.css vendored Normal file
View File

@@ -0,0 +1,74 @@
/* ==========================================================================
Skeleton Loaders
========================================================================== */
@keyframes ap-skeleton-shimmer {
0% { background-position: 200% 0; }
100% { background-position: -200% 0; }
}
.ap-skeleton {
background: linear-gradient(90deg,
var(--color-offset) 25%,
var(--color-background) 50%,
var(--color-offset) 75%);
background-size: 200% 100%;
animation: ap-skeleton-shimmer 1.5s ease-in-out infinite;
border-radius: var(--border-radius-small);
}
.ap-card--skeleton {
pointer-events: none;
}
.ap-card--skeleton .ap-card__author {
display: flex;
align-items: center;
gap: var(--space-s);
}
.ap-skeleton--avatar {
width: 2.5rem;
height: 2.5rem;
border-radius: 50%;
flex-shrink: 0;
}
.ap-skeleton-lines {
flex: 1;
display: flex;
flex-direction: column;
gap: 0.4rem;
}
.ap-skeleton--name {
height: 0.85rem;
width: 40%;
}
.ap-skeleton--handle {
height: 0.7rem;
width: 25%;
}
.ap-skeleton-body {
display: flex;
flex-direction: column;
gap: 0.5rem;
margin-top: var(--space-s);
}
.ap-skeleton--line {
height: 0.75rem;
width: 100%;
}
.ap-skeleton--short {
width: 60%;
}
.ap-skeleton-group {
display: flex;
flex-direction: column;
gap: var(--space-m);
}

View File

@@ -0,0 +1,115 @@
/**
* Card interaction Alpine.js component.
* Handles like, boost, and save-for-later actions with optimistic UI and
* rollback on failure.
*
* Configured via data-* attributes on the container element (the <footer>):
* data-item-uid="..." canonical AP UID used for like/boost API calls
* data-item-url="..." display URL used for saveLater and links
* data-csrf-token="..."
* data-mount-path="..."
* data-liked="true|false"
* data-boosted="true|false"
* data-like-count="N" omit or empty string for null
* data-boost-count="N" omit or empty string for null
*/
document.addEventListener("alpine:init", () => {
Alpine.data("apCardInteraction", () => ({
liked: false,
boosted: false,
saved: false,
loading: false,
error: "",
likeCount: null,
boostCount: null,
init() {
this.liked = this.$el.dataset.liked === "true";
this.boosted = this.$el.dataset.boosted === "true";
const lc = this.$el.dataset.likeCount;
const bc = this.$el.dataset.boostCount;
this.likeCount = lc != null && lc !== "" ? Number(lc) : null;
this.boostCount = bc != null && bc !== "" ? Number(bc) : null;
},
async saveLater() {
if (this.saved) return;
const el = this.$el;
const itemUrl = el.dataset.itemUrl;
try {
const res = await fetch("/readlater/save", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
url: itemUrl,
title:
el.closest("article")?.querySelector("p")?.textContent?.substring(0, 80) ||
itemUrl,
source: "activitypub",
}),
credentials: "same-origin",
});
if (res.ok) this.saved = true;
else this.error = "Failed to save";
} catch (e) {
this.error = e.message;
}
if (this.error) setTimeout(() => (this.error = ""), 3000);
},
async interact(action) {
if (this.loading) return;
this.loading = true;
this.error = "";
const el = this.$el;
const itemUid = el.dataset.itemUid;
const csrfToken = el.dataset.csrfToken;
const basePath = el.dataset.mountPath;
const prev = {
liked: this.liked,
boosted: this.boosted,
boostCount: this.boostCount,
likeCount: this.likeCount,
};
if (action === "like") {
this.liked = true;
if (this.likeCount !== null) this.likeCount++;
} else if (action === "unlike") {
this.liked = false;
if (this.likeCount !== null && this.likeCount > 0) this.likeCount--;
} else if (action === "boost") {
this.boosted = true;
if (this.boostCount !== null) this.boostCount++;
} else if (action === "unboost") {
this.boosted = false;
if (this.boostCount !== null && this.boostCount > 0) this.boostCount--;
}
try {
const res = await fetch(basePath + "/admin/reader/" + action, {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-CSRF-Token": csrfToken,
},
body: JSON.stringify({ url: itemUid }),
});
const data = await res.json();
if (!data.success) {
this.liked = prev.liked;
this.boosted = prev.boosted;
this.boostCount = prev.boostCount;
this.likeCount = prev.likeCount;
this.error = data.error || "Failed";
}
} catch (e) {
this.liked = prev.liked;
this.boosted = prev.boosted;
this.boostCount = prev.boostCount;
this.likeCount = prev.likeCount;
this.error = e.message;
}
this.loading = false;
if (this.error) setTimeout(() => (this.error = ""), 3000);
},
}));
});

File diff suppressed because it is too large Load Diff

728
index.js
View File

@@ -4,6 +4,7 @@ import { setupFederation, buildPersonActor } from "./lib/federation-setup.js";
import { createMastodonRouter } from "./lib/mastodon/router.js"; import { createMastodonRouter } from "./lib/mastodon/router.js";
import { setLocalIdentity } from "./lib/mastodon/entities/status.js"; import { setLocalIdentity } from "./lib/mastodon/entities/status.js";
import { initRedisCache } from "./lib/redis-cache.js"; import { initRedisCache } from "./lib/redis-cache.js";
import { createIndexes } from "./lib/init-indexes.js";
import { lookupWithSecurity } from "./lib/lookup-helpers.js"; import { lookupWithSecurity } from "./lib/lookup-helpers.js";
import { import {
needsDirectFollow, needsDirectFollow,
@@ -16,8 +17,8 @@ import {
import { import {
jf2ToActivityStreams, jf2ToActivityStreams,
jf2ToAS2Activity, jf2ToAS2Activity,
parseMentions,
} from "./lib/jf2-to-as2.js"; } from "./lib/jf2-to-as2.js";
import { createSyndicator } from "./lib/syndicator.js";
import { dashboardController } from "./lib/controllers/dashboard.js"; import { dashboardController } from "./lib/controllers/dashboard.js";
import { import {
readerController, readerController,
@@ -115,6 +116,7 @@ import {
} from "./lib/controllers/refollow.js"; } from "./lib/controllers/refollow.js";
import { startBatchRefollow } from "./lib/batch-refollow.js"; import { startBatchRefollow } from "./lib/batch-refollow.js";
import { logActivity } from "./lib/activity-log.js"; import { logActivity } from "./lib/activity-log.js";
import { batchBroadcast } from "./lib/batch-broadcast.js";
import { scheduleCleanup } from "./lib/timeline-cleanup.js"; import { scheduleCleanup } from "./lib/timeline-cleanup.js";
import { runSeparateMentionsMigration } from "./lib/migrations/separate-mentions.js"; import { runSeparateMentionsMigration } from "./lib/migrations/separate-mentions.js";
import { loadBlockedServersToRedis } from "./lib/storage/server-blocks.js"; import { loadBlockedServersToRedis } from "./lib/storage/server-blocks.js";
@@ -178,26 +180,6 @@ export default class ActivityPubEndpoint {
text: "activitypub.reader.title", text: "activitypub.reader.title",
requiresDatabase: true, requiresDatabase: true,
}, },
{
href: `${this.options.mountPath}/admin/reader/notifications`,
text: "activitypub.notifications.title",
requiresDatabase: true,
},
{
href: `${this.options.mountPath}/admin/reader/messages`,
text: "activitypub.messages.title",
requiresDatabase: true,
},
{
href: `${this.options.mountPath}/admin/reader/moderation`,
text: "activitypub.moderation.title",
requiresDatabase: true,
},
{
href: `${this.options.mountPath}/admin/my-profile`,
text: "activitypub.myProfile.title",
requiresDatabase: true,
},
{ {
href: `${this.options.mountPath}/admin/federation`, href: `${this.options.mountPath}/admin/federation`,
text: "activitypub.federationMgmt.title", text: "activitypub.federationMgmt.title",
@@ -465,228 +447,7 @@ export default class ActivityPubEndpoint {
* Syndicator — delivers posts to ActivityPub followers via Fedify. * Syndicator — delivers posts to ActivityPub followers via Fedify.
*/ */
get syndicator() { get syndicator() {
const self = this; return createSyndicator(this);
return {
name: "ActivityPub syndicator",
options: { checked: self.options.checked },
get info() {
const hostname = self._publicationUrl
? new URL(self._publicationUrl).hostname
: "example.com";
return {
checked: self.options.checked,
name: `@${self.options.actor.handle}@${hostname}`,
uid: self._publicationUrl || "https://example.com/",
service: {
name: "ActivityPub (Fediverse)",
photo: "/assets/@rmdes-indiekit-endpoint-activitypub/icon.svg",
url: self._publicationUrl || "https://example.com/",
},
};
},
async syndicate(properties) {
if (!self._federation) {
return undefined;
}
try {
const actorUrl = self._getActorUrl();
const handle = self.options.actor.handle;
const ctx = self._federation.createContext(
new URL(self._publicationUrl),
{ handle, publicationUrl: self._publicationUrl },
);
// For replies, resolve the original post author for proper
// addressing (CC) and direct inbox delivery
let replyToActor = null;
if (properties["in-reply-to"]) {
try {
const remoteObject = await lookupWithSecurity(ctx,
new URL(properties["in-reply-to"]),
);
if (remoteObject && typeof remoteObject.getAttributedTo === "function") {
const author = await remoteObject.getAttributedTo();
const authorActor = Array.isArray(author) ? author[0] : author;
if (authorActor?.id) {
replyToActor = {
url: authorActor.id.href,
handle: authorActor.preferredUsername || null,
recipient: authorActor,
};
console.info(
`[ActivityPub] Reply to ${properties["in-reply-to"]} — resolved author: ${replyToActor.url}`,
);
}
}
} catch (error) {
console.warn(
`[ActivityPub] Could not resolve reply-to author for ${properties["in-reply-to"]}: ${error.message}`,
);
}
}
// Resolve @user@domain mentions in content via WebFinger
const contentText = properties.content?.html || properties.content || "";
const mentionHandles = parseMentions(contentText);
const resolvedMentions = [];
const mentionRecipients = [];
for (const { handle } of mentionHandles) {
try {
const mentionedActor = await lookupWithSecurity(ctx,
new URL(`acct:${handle}`),
);
if (mentionedActor?.id) {
resolvedMentions.push({
handle,
actorUrl: mentionedActor.id.href,
profileUrl: mentionedActor.url?.href || null,
});
mentionRecipients.push({
handle,
actorUrl: mentionedActor.id.href,
actor: mentionedActor,
});
console.info(
`[ActivityPub] Resolved mention @${handle}${mentionedActor.id.href}`,
);
}
} catch (error) {
console.warn(
`[ActivityPub] Could not resolve mention @${handle}: ${error.message}`,
);
// Still add with no actorUrl so it gets a fallback link
resolvedMentions.push({ handle, actorUrl: null });
}
}
const activity = jf2ToAS2Activity(
properties,
actorUrl,
self._publicationUrl,
{
replyToActorUrl: replyToActor?.url,
replyToActorHandle: replyToActor?.handle,
visibility: self.options.defaultVisibility,
mentions: resolvedMentions,
},
);
if (!activity) {
await logActivity(self._collections.ap_activities, {
direction: "outbound",
type: "Syndicate",
actorUrl: self._publicationUrl,
objectUrl: properties.url,
summary: `Syndication skipped: could not convert post to AS2`,
});
return undefined;
}
// Count followers for logging
const followerCount =
await self._collections.ap_followers.countDocuments();
console.info(
`[ActivityPub] Sending ${activity.constructor?.name || "activity"} for ${properties.url} to ${followerCount} followers`,
);
// Send to followers via shared inboxes with collection sync (FEP-8fcf)
await ctx.sendActivity(
{ identifier: handle },
"followers",
activity,
{
preferSharedInbox: true,
syncCollection: true,
orderingKey: properties.url,
},
);
// For replies, also deliver to the original post author's inbox
// so their server can thread the reply under the original post
if (replyToActor?.recipient) {
try {
await ctx.sendActivity(
{ identifier: handle },
replyToActor.recipient,
activity,
{ orderingKey: properties.url },
);
console.info(
`[ActivityPub] Reply delivered to author: ${replyToActor.url}`,
);
} catch (error) {
console.warn(
`[ActivityPub] Failed to deliver reply to ${replyToActor.url}: ${error.message}`,
);
}
}
// Deliver to mentioned actors' inboxes (skip reply-to author, already delivered above)
for (const { handle: mHandle, actorUrl: mUrl, actor: mActor } of mentionRecipients) {
if (replyToActor?.url === mUrl) continue;
try {
await ctx.sendActivity(
{ identifier: handle },
mActor,
activity,
{ orderingKey: properties.url },
);
console.info(
`[ActivityPub] Mention delivered to @${mHandle}: ${mUrl}`,
);
} catch (error) {
console.warn(
`[ActivityPub] Failed to deliver mention to @${mHandle}: ${error.message}`,
);
}
}
// Determine activity type name
const typeName =
activity.constructor?.name || "Create";
const replyNote = replyToActor
? ` (reply to ${replyToActor.url})`
: "";
const mentionNote = mentionRecipients.length > 0
? ` (mentions: ${mentionRecipients.map(m => `@${m.handle}`).join(", ")})`
: "";
await logActivity(self._collections.ap_activities, {
direction: "outbound",
type: typeName,
actorUrl: self._publicationUrl,
objectUrl: properties.url,
targetUrl: properties["in-reply-to"] || undefined,
summary: `Sent ${typeName} for ${properties.url} to ${followerCount} followers${replyNote}${mentionNote}`,
});
console.info(
`[ActivityPub] Syndication queued: ${typeName} for ${properties.url}${replyNote}`,
);
return properties.url || undefined;
} catch (error) {
console.error("[ActivityPub] Syndication failed:", error.message);
await logActivity(self._collections.ap_activities, {
direction: "outbound",
type: "Syndicate",
actorUrl: self._publicationUrl,
objectUrl: properties.url,
summary: `Syndication failed: ${error.message}`,
}).catch(() => {});
return undefined;
}
},
delete: async (url) => this.delete(url),
update: async (properties) => this.update(properties),
};
} }
/** /**
@@ -959,9 +720,6 @@ export default class ActivityPubEndpoint {
{ handle, publicationUrl: this._publicationUrl }, { handle, publicationUrl: this._publicationUrl },
); );
// Build the full actor object (same as what the dispatcher serves).
// Note: ctx.getActor() only exists on RequestContext, not the base
// Context returned by createContext(), so we use the shared helper.
const actor = await buildPersonActor( const actor = await buildPersonActor(
ctx, ctx,
handle, handle,
@@ -978,86 +736,17 @@ export default class ActivityPubEndpoint {
object: actor, object: actor,
}); });
// Fetch followers and deduplicate by shared inbox so each remote await batchBroadcast({
// server only gets one delivery (same as preferSharedInbox but federation: this._federation,
// gives us control over batching). collections: this._collections,
const followers = await this._collections.ap_followers publicationUrl: this._publicationUrl,
.find({}) handle,
.project({ actorUrl: 1, inbox: 1, sharedInbox: 1 }) activity: update,
.toArray(); label: "Update(Person)",
// Group by shared inbox (or direct inbox if none)
const inboxMap = new Map();
for (const f of followers) {
const key = f.sharedInbox || f.inbox;
if (key && !inboxMap.has(key)) {
inboxMap.set(key, f);
}
}
const uniqueRecipients = [...inboxMap.values()];
const BATCH_SIZE = 25;
const BATCH_DELAY_MS = 5000;
let delivered = 0;
let failed = 0;
console.info(
`[ActivityPub] Broadcasting Update(Person) to ${uniqueRecipients.length} ` +
`unique inboxes (${followers.length} followers) in batches of ${BATCH_SIZE}`,
);
for (let i = 0; i < uniqueRecipients.length; i += BATCH_SIZE) {
const batch = uniqueRecipients.slice(i, i + BATCH_SIZE);
// Build Fedify-compatible Recipient objects:
// extractInboxes() reads: recipient.id, recipient.inboxId,
// recipient.endpoints?.sharedInbox
const recipients = batch.map((f) => ({
id: new URL(f.actorUrl),
inboxId: new URL(f.inbox || f.sharedInbox),
endpoints: f.sharedInbox
? { sharedInbox: new URL(f.sharedInbox) }
: undefined,
}));
try {
await ctx.sendActivity(
{ identifier: handle },
recipients,
update,
{ preferSharedInbox: true },
);
delivered += batch.length;
} catch (error) {
failed += batch.length;
console.warn(
`[ActivityPub] Batch ${Math.floor(i / BATCH_SIZE) + 1} failed: ${error.message}`,
);
}
// Stagger batches so remote servers don't all re-fetch at once
if (i + BATCH_SIZE < uniqueRecipients.length) {
await new Promise((resolve) => setTimeout(resolve, BATCH_DELAY_MS));
}
}
console.info(
`[ActivityPub] Update(Person) broadcast complete: ` +
`${delivered} delivered, ${failed} failed`,
);
await logActivity(this._collections.ap_activities, {
direction: "outbound",
type: "Update",
actorUrl: this._publicationUrl,
objectUrl: this._getActorUrl(), objectUrl: this._getActorUrl(),
summary: `Sent Update(Person) to ${delivered}/${uniqueRecipients.length} inboxes`, });
}).catch(() => {});
} catch (error) { } catch (error) {
console.error( console.error("[ActivityPub] broadcastActorUpdate failed:", error.message);
"[ActivityPub] broadcastActorUpdate failed:",
error.message,
);
} }
} }
@@ -1082,71 +771,15 @@ export default class ActivityPubEndpoint {
object: new URL(postUrl), object: new URL(postUrl),
}); });
const followers = await this._collections.ap_followers await batchBroadcast({
.find({}) federation: this._federation,
.project({ actorUrl: 1, inbox: 1, sharedInbox: 1 }) collections: this._collections,
.toArray(); publicationUrl: this._publicationUrl,
handle,
const inboxMap = new Map(); activity: del,
for (const f of followers) { label: "Delete",
const key = f.sharedInbox || f.inbox;
if (key && !inboxMap.has(key)) {
inboxMap.set(key, f);
}
}
const uniqueRecipients = [...inboxMap.values()];
const BATCH_SIZE = 25;
const BATCH_DELAY_MS = 5000;
let delivered = 0;
let failed = 0;
console.info(
`[ActivityPub] Broadcasting Delete for ${postUrl} to ${uniqueRecipients.length} ` +
`unique inboxes (${followers.length} followers)`,
);
for (let i = 0; i < uniqueRecipients.length; i += BATCH_SIZE) {
const batch = uniqueRecipients.slice(i, i + BATCH_SIZE);
const recipients = batch.map((f) => ({
id: new URL(f.actorUrl),
inboxId: new URL(f.inbox || f.sharedInbox),
endpoints: f.sharedInbox
? { sharedInbox: new URL(f.sharedInbox) }
: undefined,
}));
try {
await ctx.sendActivity(
{ identifier: handle },
recipients,
del,
{ preferSharedInbox: true },
);
delivered += batch.length;
} catch (error) {
failed += batch.length;
console.warn(
`[ActivityPub] Delete batch ${Math.floor(i / BATCH_SIZE) + 1} failed: ${error.message}`,
);
}
if (i + BATCH_SIZE < uniqueRecipients.length) {
await new Promise((resolve) => setTimeout(resolve, BATCH_DELAY_MS));
}
}
console.info(
`[ActivityPub] Delete broadcast complete for ${postUrl}: ${delivered} delivered, ${failed} failed`,
);
await logActivity(this._collections.ap_activities, {
direction: "outbound",
type: "Delete",
actorUrl: this._publicationUrl,
objectUrl: postUrl, objectUrl: postUrl,
summary: `Sent Delete for ${postUrl} to ${delivered} inboxes`, });
}).catch(() => {});
} catch (error) { } catch (error) {
console.warn("[ActivityPub] broadcastDelete failed:", error.message); console.warn("[ActivityPub] broadcastDelete failed:", error.message);
} }
@@ -1191,10 +824,6 @@ export default class ActivityPubEndpoint {
{ handle, publicationUrl: this._publicationUrl }, { handle, publicationUrl: this._publicationUrl },
); );
// Build the Note/Article object by calling jf2ToAS2Activity() and
// extracting the wrapped object from the returned Create activity.
// For post edits, Fediverse servers expect an Update activity wrapping
// the updated object — NOT a second Create activity.
const createActivity = jf2ToAS2Activity( const createActivity = jf2ToAS2Activity(
properties, properties,
actorUrl, actorUrl,
@@ -1207,78 +836,21 @@ export default class ActivityPubEndpoint {
return; return;
} }
// Extract the Note/Article object from the Create wrapper, then build
// an Update activity around it — matching broadcastActorUpdate() pattern.
const noteObject = await createActivity.getObject(); const noteObject = await createActivity.getObject();
const activity = new Update({ const activity = new Update({
actor: ctx.getActorUri(handle), actor: ctx.getActorUri(handle),
object: noteObject, object: noteObject,
}); });
const followers = await this._collections.ap_followers await batchBroadcast({
.find({}) federation: this._federation,
.project({ actorUrl: 1, inbox: 1, sharedInbox: 1 }) collections: this._collections,
.toArray(); publicationUrl: this._publicationUrl,
handle,
const inboxMap = new Map(); activity,
for (const f of followers) { label: "Update(Note)",
const key = f.sharedInbox || f.inbox;
if (key && !inboxMap.has(key)) {
inboxMap.set(key, f);
}
}
const uniqueRecipients = [...inboxMap.values()];
const BATCH_SIZE = 25;
const BATCH_DELAY_MS = 5000;
let delivered = 0;
let failed = 0;
console.info(
`[ActivityPub] Broadcasting Update for ${properties.url} to ${uniqueRecipients.length} unique inboxes`,
);
for (let i = 0; i < uniqueRecipients.length; i += BATCH_SIZE) {
const batch = uniqueRecipients.slice(i, i + BATCH_SIZE);
const recipients = batch.map((f) => ({
id: new URL(f.actorUrl),
inboxId: new URL(f.inbox || f.sharedInbox),
endpoints: f.sharedInbox
? { sharedInbox: new URL(f.sharedInbox) }
: undefined,
}));
try {
await ctx.sendActivity(
{ identifier: handle },
recipients,
activity,
{ preferSharedInbox: true },
);
delivered += batch.length;
} catch (error) {
failed += batch.length;
console.warn(
`[ActivityPub] Update batch ${Math.floor(i / BATCH_SIZE) + 1} failed: ${error.message}`,
);
}
if (i + BATCH_SIZE < uniqueRecipients.length) {
await new Promise((resolve) => setTimeout(resolve, BATCH_DELAY_MS));
}
}
console.info(
`[ActivityPub] Update broadcast complete for ${properties.url}: ${delivered} delivered, ${failed} failed`,
);
await logActivity(this._collections.ap_activities, {
direction: "outbound",
type: "Update",
actorUrl: this._publicationUrl,
objectUrl: properties.url, objectUrl: properties.url,
summary: `Sent Update for ${properties.url} to ${delivered} inboxes`, });
}).catch(() => {});
} catch (error) { } catch (error) {
console.warn("[ActivityPub] broadcastPostUpdate failed:", error.message); console.warn("[ActivityPub] broadcastPostUpdate failed:", error.message);
} }
@@ -1378,243 +950,11 @@ export default class ActivityPubEndpoint {
_publicationUrl: this._publicationUrl, _publicationUrl: this._publicationUrl,
}; };
// Create indexes — wrapped in try-catch because collection references // Create indexes (idempotent — safe on every startup)
// may be undefined if MongoDB hasn't finished connecting yet. createIndexes(this._collections, {
// Indexes are idempotent; they'll be created on next successful startup. activityRetentionDays: this.options.activityRetentionDays,
try { notificationRetentionDays: this.options.notificationRetentionDays,
// TTL index for activity cleanup (MongoDB handles expiry automatically) });
const retentionDays = this.options.activityRetentionDays;
if (retentionDays > 0) {
this._collections.ap_activities.createIndex(
{ receivedAt: 1 },
{ expireAfterSeconds: retentionDays * 86_400 },
);
}
// Performance indexes for inbox handlers and batch refollow
this._collections.ap_followers.createIndex(
{ actorUrl: 1 },
{ unique: true, background: true },
);
this._collections.ap_following.createIndex(
{ actorUrl: 1 },
{ unique: true, background: true },
);
this._collections.ap_following.createIndex(
{ source: 1 },
{ background: true },
);
this._collections.ap_activities.createIndex(
{ objectUrl: 1 },
{ background: true },
);
this._collections.ap_activities.createIndex(
{ type: 1, actorUrl: 1, objectUrl: 1 },
{ background: true },
);
// Reader indexes (timeline, notifications, moderation, interactions)
this._collections.ap_timeline.createIndex(
{ uid: 1 },
{ unique: true, background: true },
);
this._collections.ap_timeline.createIndex(
{ published: -1 },
{ background: true },
);
this._collections.ap_timeline.createIndex(
{ "author.url": 1 },
{ background: true },
);
this._collections.ap_timeline.createIndex(
{ type: 1, published: -1 },
{ background: true },
);
this._collections.ap_notifications.createIndex(
{ uid: 1 },
{ unique: true, background: true },
);
this._collections.ap_notifications.createIndex(
{ published: -1 },
{ background: true },
);
this._collections.ap_notifications.createIndex(
{ read: 1 },
{ background: true },
);
this._collections.ap_notifications.createIndex(
{ type: 1, published: -1 },
{ background: true },
);
// TTL index for notification cleanup
const notifRetention = this.options.notificationRetentionDays;
if (notifRetention > 0) {
this._collections.ap_notifications.createIndex(
{ createdAt: 1 },
{ expireAfterSeconds: notifRetention * 86_400 },
);
}
// Message indexes
this._collections.ap_messages.createIndex(
{ uid: 1 },
{ unique: true, background: true },
);
this._collections.ap_messages.createIndex(
{ published: -1 },
{ background: true },
);
this._collections.ap_messages.createIndex(
{ read: 1 },
{ background: true },
);
this._collections.ap_messages.createIndex(
{ conversationId: 1, published: -1 },
{ background: true },
);
this._collections.ap_messages.createIndex(
{ direction: 1 },
{ background: true },
);
// TTL index for message cleanup (reuse notification retention)
if (notifRetention > 0) {
this._collections.ap_messages.createIndex(
{ createdAt: 1 },
{ expireAfterSeconds: notifRetention * 86_400 },
);
}
// Muted collection — sparse unique indexes (allow multiple null values)
this._collections.ap_muted
.dropIndex("url_1")
.catch(() => {})
.then(() =>
this._collections.ap_muted.createIndex(
{ url: 1 },
{ unique: true, sparse: true, background: true },
),
)
.catch(() => {});
this._collections.ap_muted
.dropIndex("keyword_1")
.catch(() => {})
.then(() =>
this._collections.ap_muted.createIndex(
{ keyword: 1 },
{ unique: true, sparse: true, background: true },
),
)
.catch(() => {});
this._collections.ap_blocked.createIndex(
{ url: 1 },
{ unique: true, background: true },
);
this._collections.ap_interactions.createIndex(
{ objectUrl: 1, type: 1 },
{ unique: true, background: true },
);
this._collections.ap_interactions.createIndex(
{ type: 1 },
{ background: true },
);
// Followed hashtags — unique on tag (case-insensitive via normalization at write time)
this._collections.ap_followed_tags.createIndex(
{ tag: 1 },
{ unique: true, background: true },
);
// Tag filtering index on timeline
this._collections.ap_timeline.createIndex(
{ category: 1, published: -1 },
{ background: true },
);
// Explore tab indexes
// Compound unique on (type, domain, scope, hashtag) prevents duplicate tabs.
// ALL insertions must explicitly set all four fields (unused fields = null)
// because MongoDB treats missing fields differently from null in unique indexes.
this._collections.ap_explore_tabs.createIndex(
{ type: 1, domain: 1, scope: 1, hashtag: 1 },
{ unique: true, background: true },
);
// Order index for efficient sorting of tab bar
this._collections.ap_explore_tabs.createIndex(
{ order: 1 },
{ background: true },
);
// ap_reports indexes
if (notifRetention > 0) {
this._collections.ap_reports.createIndex(
{ createdAt: 1 },
{ expireAfterSeconds: notifRetention * 86_400 },
);
}
this._collections.ap_reports.createIndex(
{ reporterUrl: 1 },
{ background: true },
);
this._collections.ap_reports.createIndex(
{ reportedUrls: 1 },
{ background: true },
);
// Pending follow requests — unique on actorUrl
this._collections.ap_pending_follows.createIndex(
{ actorUrl: 1 },
{ unique: true, background: true },
);
this._collections.ap_pending_follows.createIndex(
{ requestedAt: -1 },
{ background: true },
);
// Server-level blocks
this._collections.ap_blocked_servers.createIndex(
{ hostname: 1 },
{ unique: true, background: true },
);
// Key freshness tracking
this._collections.ap_key_freshness.createIndex(
{ actorUrl: 1 },
{ unique: true, background: true },
);
// Inbox queue indexes
this._collections.ap_inbox_queue.createIndex(
{ status: 1, receivedAt: 1 },
{ background: true },
);
// TTL: auto-prune completed items after 24h
this._collections.ap_inbox_queue.createIndex(
{ processedAt: 1 },
{ expireAfterSeconds: 86_400, background: true },
);
// Mastodon Client API indexes
this._collections.ap_oauth_apps.createIndex(
{ clientId: 1 },
{ unique: true, background: true },
);
this._collections.ap_oauth_tokens.createIndex(
{ accessToken: 1 },
{ unique: true, sparse: true, background: true },
);
this._collections.ap_oauth_tokens.createIndex(
{ code: 1 },
{ unique: true, sparse: true, background: true },
);
this._collections.ap_markers.createIndex(
{ userId: 1, timeline: 1 },
{ unique: true, background: true },
);
} catch {
// Index creation failed — collections not yet available.
// Indexes already exist from previous startups; non-fatal.
}
// Seed actor profile from config on first run // Seed actor profile from config on first run
this._seedProfile().catch((error) => { this._seedProfile().catch((error) => {

98
lib/batch-broadcast.js Normal file
View File

@@ -0,0 +1,98 @@
/**
* Shared batch broadcast for delivering activities to all followers.
* Deduplicates by shared inbox and delivers in batches with delay.
* @module batch-broadcast
*/
import { logActivity } from "./activity-log.js";
const BATCH_SIZE = 25;
const BATCH_DELAY_MS = 5000;
/**
* Broadcast an activity to all followers via batch delivery.
*
* @param {object} options
* @param {object} options.federation - Fedify Federation instance
* @param {object} options.collections - MongoDB collections (needs ap_followers, ap_activities)
* @param {string} options.publicationUrl - Our publication URL
* @param {string} options.handle - Our actor handle
* @param {object} options.activity - Fedify activity object to send
* @param {string} options.label - Human-readable label for logging (e.g. "Update(Person)")
* @param {string} [options.objectUrl] - URL of the object being broadcast about
*/
export async function batchBroadcast({
federation,
collections,
publicationUrl,
handle,
activity,
label,
objectUrl,
}) {
const ctx = federation.createContext(new URL(publicationUrl), {
handle,
publicationUrl,
});
const followers = await collections.ap_followers
.find({})
.project({ actorUrl: 1, inbox: 1, sharedInbox: 1 })
.toArray();
// Deduplicate by shared inbox
const inboxMap = new Map();
for (const f of followers) {
const key = f.sharedInbox || f.inbox;
if (key && !inboxMap.has(key)) {
inboxMap.set(key, f);
}
}
const uniqueRecipients = [...inboxMap.values()];
let delivered = 0;
let failed = 0;
console.info(
`[ActivityPub] Broadcasting ${label} to ${uniqueRecipients.length} ` +
`unique inboxes (${followers.length} followers) in batches of ${BATCH_SIZE}`,
);
for (let i = 0; i < uniqueRecipients.length; i += BATCH_SIZE) {
const batch = uniqueRecipients.slice(i, i + BATCH_SIZE);
const recipients = batch.map((f) => ({
id: new URL(f.actorUrl),
inboxId: new URL(f.inbox || f.sharedInbox),
endpoints: f.sharedInbox
? { sharedInbox: new URL(f.sharedInbox) }
: undefined,
}));
try {
await ctx.sendActivity({ identifier: handle }, recipients, activity, {
preferSharedInbox: true,
});
delivered += batch.length;
} catch (error) {
failed += batch.length;
console.warn(
`[ActivityPub] ${label} batch ${Math.floor(i / BATCH_SIZE) + 1} failed: ${error.message}`,
);
}
if (i + BATCH_SIZE < uniqueRecipients.length) {
await new Promise((resolve) => setTimeout(resolve, BATCH_DELAY_MS));
}
}
console.info(
`[ActivityPub] ${label} broadcast complete: ${delivered} delivered, ${failed} failed`,
);
await logActivity(collections.ap_activities, {
direction: "outbound",
type: label.includes("(") ? label.split("(")[0] : label,
actorUrl: publicationUrl,
objectUrl: objectUrl || "",
summary: `Sent ${label} to ${delivered}/${uniqueRecipients.length} inboxes`,
}).catch(() => {});
}

View File

@@ -5,6 +5,7 @@
import { getToken, validateToken } from "../csrf.js"; import { getToken, validateToken } from "../csrf.js";
import { sanitizeContent } from "../timeline-store.js"; import { sanitizeContent } from "../timeline-store.js";
import { lookupWithSecurity } from "../lookup-helpers.js"; import { lookupWithSecurity } from "../lookup-helpers.js";
import { createContext, getHandle, isFederationReady } from "../federation-actions.js";
/** /**
* Fetch syndication targets from the Micropub config endpoint. * Fetch syndication targets from the Micropub config endpoint.
@@ -69,18 +70,15 @@ export function composeController(mountPath, plugin) {
: null; : null;
// If not in timeline, try to look up remotely // If not in timeline, try to look up remotely
if (!replyContext && plugin._federation) { if (!replyContext && isFederationReady(plugin)) {
try { try {
const handle = plugin.options.actor.handle; const handle = getHandle(plugin);
const ctx = plugin._federation.createContext( const ctx = createContext(plugin);
new URL(plugin._publicationUrl),
{ handle, publicationUrl: plugin._publicationUrl },
);
// Use authenticated document loader for Authorized Fetch // Use authenticated document loader for Authorized Fetch
const documentLoader = await ctx.getDocumentLoader({ const documentLoader = await ctx.getDocumentLoader({
identifier: handle, identifier: handle,
}); });
const remoteObject = await lookupWithSecurity(ctx,new URL(replyTo), { const remoteObject = await lookupWithSecurity(ctx, new URL(replyTo), {
documentLoader, documentLoader,
}); });

View File

@@ -5,6 +5,7 @@
import { validateToken } from "../csrf.js"; import { validateToken } from "../csrf.js";
import { resolveAuthor } from "../resolve-author.js"; import { resolveAuthor } from "../resolve-author.js";
import { createContext, getHandle, getPublicationUrl, isFederationReady } from "../federation-actions.js";
/** /**
* POST /admin/reader/boost — send an Announce activity to followers. * POST /admin/reader/boost — send an Announce activity to followers.
@@ -28,7 +29,7 @@ export function boostController(mountPath, plugin) {
}); });
} }
if (!plugin._federation) { if (!isFederationReady(plugin)) {
return response.status(503).json({ return response.status(503).json({
success: false, success: false,
error: "Federation not initialized", error: "Federation not initialized",
@@ -36,14 +37,11 @@ export function boostController(mountPath, plugin) {
} }
const { Announce } = await import("@fedify/fedify/vocab"); const { Announce } = await import("@fedify/fedify/vocab");
const handle = plugin.options.actor.handle; const handle = getHandle(plugin);
const ctx = plugin._federation.createContext( const ctx = createContext(plugin);
new URL(plugin._publicationUrl),
{ handle, publicationUrl: plugin._publicationUrl },
);
const uuid = crypto.randomUUID(); const uuid = crypto.randomUUID();
const baseUrl = plugin._publicationUrl.replace(/\/$/, ""); const baseUrl = getPublicationUrl(plugin).replace(/\/$/, "");
const activityId = `${baseUrl}/activitypub/boosts/${uuid}`; const activityId = `${baseUrl}/activitypub/boosts/${uuid}`;
const publicAddress = new URL( const publicAddress = new URL(
@@ -160,7 +158,7 @@ export function unboostController(mountPath, plugin) {
}); });
} }
if (!plugin._federation) { if (!isFederationReady(plugin)) {
return response.status(503).json({ return response.status(503).json({
success: false, success: false,
error: "Federation not initialized", error: "Federation not initialized",
@@ -182,11 +180,8 @@ export function unboostController(mountPath, plugin) {
} }
const { Announce, Undo } = await import("@fedify/fedify/vocab"); const { Announce, Undo } = await import("@fedify/fedify/vocab");
const handle = plugin.options.actor.handle; const handle = getHandle(plugin);
const ctx = plugin._federation.createContext( const ctx = createContext(plugin);
new URL(plugin._publicationUrl),
{ handle, publicationUrl: plugin._publicationUrl },
);
// Construct Undo(Announce) // Construct Undo(Announce)
const announce = new Announce({ const announce = new Announce({

View File

@@ -5,6 +5,7 @@
import { validateToken } from "../csrf.js"; import { validateToken } from "../csrf.js";
import { resolveAuthor } from "../resolve-author.js"; import { resolveAuthor } from "../resolve-author.js";
import { createContext, getHandle, getPublicationUrl, isFederationReady } from "../federation-actions.js";
/** /**
* POST /admin/reader/like — send a Like activity to the post author. * POST /admin/reader/like — send a Like activity to the post author.
@@ -30,7 +31,7 @@ export function likeController(mountPath, plugin) {
}); });
} }
if (!plugin._federation) { if (!isFederationReady(plugin)) {
return response.status(503).json({ return response.status(503).json({
success: false, success: false,
error: "Federation not initialized", error: "Federation not initialized",
@@ -38,11 +39,8 @@ export function likeController(mountPath, plugin) {
} }
const { Like } = await import("@fedify/fedify/vocab"); const { Like } = await import("@fedify/fedify/vocab");
const handle = plugin.options.actor.handle; const handle = getHandle(plugin);
const ctx = plugin._federation.createContext( const ctx = createContext(plugin);
new URL(plugin._publicationUrl),
{ handle, publicationUrl: plugin._publicationUrl },
);
const documentLoader = await ctx.getDocumentLoader({ const documentLoader = await ctx.getDocumentLoader({
identifier: handle, identifier: handle,
@@ -70,7 +68,7 @@ export function likeController(mountPath, plugin) {
// Generate a unique activity ID // Generate a unique activity ID
const uuid = crypto.randomUUID(); const uuid = crypto.randomUUID();
const baseUrl = plugin._publicationUrl.replace(/\/$/, ""); const baseUrl = getPublicationUrl(plugin).replace(/\/$/, "");
const activityId = `${baseUrl}/activitypub/likes/${uuid}`; const activityId = `${baseUrl}/activitypub/likes/${uuid}`;
// Construct and send Like activity // Construct and send Like activity
@@ -142,7 +140,7 @@ export function unlikeController(mountPath, plugin) {
}); });
} }
if (!plugin._federation) { if (!isFederationReady(plugin)) {
return response.status(503).json({ return response.status(503).json({
success: false, success: false,
error: "Federation not initialized", error: "Federation not initialized",
@@ -165,11 +163,8 @@ export function unlikeController(mountPath, plugin) {
} }
const { Like, Undo } = await import("@fedify/fedify/vocab"); const { Like, Undo } = await import("@fedify/fedify/vocab");
const handle = plugin.options.actor.handle; const handle = getHandle(plugin);
const ctx = plugin._federation.createContext( const ctx = createContext(plugin);
new URL(plugin._publicationUrl),
{ handle, publicationUrl: plugin._publicationUrl },
);
const documentLoader = await ctx.getDocumentLoader({ const documentLoader = await ctx.getDocumentLoader({
identifier: handle, identifier: handle,

70
lib/federation-actions.js Normal file
View File

@@ -0,0 +1,70 @@
/**
* Facade for federation operations used by controllers.
* Centralizes Fedify context creation and common patterns
* so controllers don't access plugin._federation directly.
* @module federation-actions
*/
import { lookupWithSecurity } from "./lookup-helpers.js";
/**
* Create a Fedify context from the plugin reference.
* @param {object} plugin - ActivityPubEndpoint instance
* @returns {object} Fedify Context
*/
export function createContext(plugin) {
const handle = plugin.options.actor.handle;
return plugin._federation.createContext(new URL(plugin._publicationUrl), {
handle,
publicationUrl: plugin._publicationUrl,
});
}
/**
* Get an authenticated document loader for signed HTTP fetches.
* @param {object} plugin - ActivityPubEndpoint instance
* @returns {Promise<object>} Fedify DocumentLoader
*/
export async function getAuthLoader(plugin) {
const ctx = createContext(plugin);
return ctx.getDocumentLoader({ identifier: plugin.options.actor.handle });
}
/**
* Resolve a remote actor with signed→unsigned fallback.
* @param {object} plugin - ActivityPubEndpoint instance
* @param {string|URL} target - Actor URL or acct: URI
* @param {object} [options] - Additional options for lookupWithSecurity
* @returns {Promise<object|null>} Resolved actor or null
*/
export async function resolveActor(plugin, target, options = {}) {
const ctx = createContext(plugin);
const documentLoader = await ctx.getDocumentLoader({
identifier: plugin.options.actor.handle,
});
const url = target instanceof URL ? target : new URL(target);
return lookupWithSecurity(ctx, url, { documentLoader, ...options });
}
/**
* Check if federation is initialized and ready.
* @param {object} plugin - ActivityPubEndpoint instance
* @returns {boolean}
*/
export function isFederationReady(plugin) {
return !!plugin._federation;
}
/** @returns {string} Our actor handle */
export function getHandle(plugin) {
return plugin.options.actor.handle;
}
/** @returns {string} Our publication URL */
export function getPublicationUrl(plugin) {
return plugin._publicationUrl;
}
/** @returns {object} MongoDB collections */
export function getCollections(plugin) {
return plugin._collections;
}

View File

@@ -17,21 +17,20 @@ import { routeToHandler } from "./inbox-handlers.js";
*/ */
async function processNextItem(collections, ctx, handle) { async function processNextItem(collections, ctx, handle) {
const { ap_inbox_queue } = collections; const { ap_inbox_queue } = collections;
if (!ap_inbox_queue) return; if (!ap_inbox_queue) return false;
const item = await ap_inbox_queue.findOneAndUpdate( const item = await ap_inbox_queue.findOneAndUpdate(
{ status: "pending" }, { status: "pending" },
{ $set: { status: "processing" } }, { $set: { status: "processing" } },
{ sort: { receivedAt: 1 }, returnDocument: "after" }, { sort: { receivedAt: 1 }, returnDocument: "after" },
); );
if (!item) return; if (!item) return false;
try { try {
await routeToHandler(item, collections, ctx, handle); await routeToHandler(item, collections, ctx, handle);
await ap_inbox_queue.updateOne( // Delete completed items immediately — prevents unbounded collection growth
{ _id: item._id }, // that caused the inbox processor to hang on restart (95K+ documents).
{ $set: { status: "completed", processedAt: new Date().toISOString() } }, await ap_inbox_queue.deleteOne({ _id: item._id });
);
} catch (error) { } catch (error) {
const attempts = (item.attempts || 0) + 1; const attempts = (item.attempts || 0) + 1;
await ap_inbox_queue.updateOne( await ap_inbox_queue.updateOne(
@@ -46,6 +45,8 @@ async function processNextItem(collections, ctx, handle) {
); );
console.error(`[inbox-queue] Failed processing ${item.activityType} from ${item.actorUrl}: ${error.message}`); console.error(`[inbox-queue] Failed processing ${item.activityType} from ${item.actorUrl}: ${error.message}`);
} }
return true;
} }
/** /**
@@ -75,6 +76,9 @@ export async function enqueueActivity(collections, { activityType, actorUrl, obj
}); });
} }
const BATCH_SIZE = 10;
const POLL_INTERVAL_MS = 1_000;
/** /**
* Start the background inbox processor. * Start the background inbox processor.
* @param {object} collections - MongoDB collections * @param {object} collections - MongoDB collections
@@ -86,14 +90,16 @@ export function startInboxProcessor(collections, getCtx, handle) {
const intervalId = setInterval(async () => { const intervalId = setInterval(async () => {
try { try {
const ctx = getCtx(); const ctx = getCtx();
if (ctx) { if (!ctx) return;
await processNextItem(collections, ctx, handle); for (let i = 0; i < BATCH_SIZE; i++) {
const hadWork = await processNextItem(collections, ctx, handle);
if (!hadWork) break; // Queue empty, stop early
} }
} catch (error) { } catch (error) {
console.error("[inbox-queue] Processor error:", error.message); console.error("[inbox-queue] Processor error:", error.message);
} }
}, 3_000); // Every 3 seconds }, POLL_INTERVAL_MS);
console.info("[ActivityPub] Inbox queue processor started (3s interval)"); console.info(`[ActivityPub] Inbox queue processor started (${POLL_INTERVAL_MS}ms interval, batch size ${BATCH_SIZE})`);
return intervalId; return intervalId;
} }

251
lib/init-indexes.js Normal file
View File

@@ -0,0 +1,251 @@
/**
* Create MongoDB indexes for all ActivityPub collections.
* Idempotent — safe to run on every startup.
* @module init-indexes
*
* @param {object} collections - MongoDB collections object
* @param {object} options
* @param {number} options.activityRetentionDays - TTL for ap_activities (0 = forever)
* @param {number} options.notificationRetentionDays - TTL for notifications (0 = forever)
*/
export function createIndexes(collections, options) {
const { activityRetentionDays, notificationRetentionDays } = options;
// Create indexes — wrapped in try-catch because collection references
// may be undefined if MongoDB hasn't finished connecting yet.
// Indexes are idempotent; they'll be created on next successful startup.
try {
// TTL index for activity cleanup (MongoDB handles expiry automatically)
const retentionDays = activityRetentionDays;
if (retentionDays > 0) {
collections.ap_activities.createIndex(
{ receivedAt: 1 },
{ expireAfterSeconds: retentionDays * 86_400 },
);
}
// Performance indexes for inbox handlers and batch refollow
collections.ap_followers.createIndex(
{ actorUrl: 1 },
{ unique: true, background: true },
);
collections.ap_following.createIndex(
{ actorUrl: 1 },
{ unique: true, background: true },
);
collections.ap_following.createIndex(
{ source: 1 },
{ background: true },
);
collections.ap_activities.createIndex(
{ objectUrl: 1 },
{ background: true },
);
collections.ap_activities.createIndex(
{ type: 1, actorUrl: 1, objectUrl: 1 },
{ background: true },
);
// Reader indexes (timeline, notifications, moderation, interactions)
collections.ap_timeline.createIndex(
{ uid: 1 },
{ unique: true, background: true },
);
collections.ap_timeline.createIndex(
{ published: -1 },
{ background: true },
);
collections.ap_timeline.createIndex(
{ "author.url": 1 },
{ background: true },
);
collections.ap_timeline.createIndex(
{ type: 1, published: -1 },
{ background: true },
);
collections.ap_notifications.createIndex(
{ uid: 1 },
{ unique: true, background: true },
);
collections.ap_notifications.createIndex(
{ published: -1 },
{ background: true },
);
collections.ap_notifications.createIndex(
{ read: 1 },
{ background: true },
);
collections.ap_notifications.createIndex(
{ type: 1, published: -1 },
{ background: true },
);
// TTL index for notification cleanup
const notifRetention = notificationRetentionDays;
if (notifRetention > 0) {
collections.ap_notifications.createIndex(
{ createdAt: 1 },
{ expireAfterSeconds: notifRetention * 86_400 },
);
}
// Message indexes
collections.ap_messages.createIndex(
{ uid: 1 },
{ unique: true, background: true },
);
collections.ap_messages.createIndex(
{ published: -1 },
{ background: true },
);
collections.ap_messages.createIndex(
{ read: 1 },
{ background: true },
);
collections.ap_messages.createIndex(
{ conversationId: 1, published: -1 },
{ background: true },
);
collections.ap_messages.createIndex(
{ direction: 1 },
{ background: true },
);
// TTL index for message cleanup (reuse notification retention)
if (notifRetention > 0) {
collections.ap_messages.createIndex(
{ createdAt: 1 },
{ expireAfterSeconds: notifRetention * 86_400 },
);
}
// Muted collection — sparse unique indexes (allow multiple null values)
collections.ap_muted
.dropIndex("url_1")
.catch(() => {})
.then(() =>
collections.ap_muted.createIndex(
{ url: 1 },
{ unique: true, sparse: true, background: true },
),
)
.catch(() => {});
collections.ap_muted
.dropIndex("keyword_1")
.catch(() => {})
.then(() =>
collections.ap_muted.createIndex(
{ keyword: 1 },
{ unique: true, sparse: true, background: true },
),
)
.catch(() => {});
collections.ap_blocked.createIndex(
{ url: 1 },
{ unique: true, background: true },
);
collections.ap_interactions.createIndex(
{ objectUrl: 1, type: 1 },
{ unique: true, background: true },
);
collections.ap_interactions.createIndex(
{ type: 1 },
{ background: true },
);
// Followed hashtags — unique on tag (case-insensitive via normalization at write time)
collections.ap_followed_tags.createIndex(
{ tag: 1 },
{ unique: true, background: true },
);
// Tag filtering index on timeline
collections.ap_timeline.createIndex(
{ category: 1, published: -1 },
{ background: true },
);
// Explore tab indexes
// Compound unique on (type, domain, scope, hashtag) prevents duplicate tabs.
// ALL insertions must explicitly set all four fields (unused fields = null)
// because MongoDB treats missing fields differently from null in unique indexes.
collections.ap_explore_tabs.createIndex(
{ type: 1, domain: 1, scope: 1, hashtag: 1 },
{ unique: true, background: true },
);
// Order index for efficient sorting of tab bar
collections.ap_explore_tabs.createIndex(
{ order: 1 },
{ background: true },
);
// ap_reports indexes
if (notifRetention > 0) {
collections.ap_reports.createIndex(
{ createdAt: 1 },
{ expireAfterSeconds: notifRetention * 86_400 },
);
}
collections.ap_reports.createIndex(
{ reporterUrl: 1 },
{ background: true },
);
collections.ap_reports.createIndex(
{ reportedUrls: 1 },
{ background: true },
);
// Pending follow requests — unique on actorUrl
collections.ap_pending_follows.createIndex(
{ actorUrl: 1 },
{ unique: true, background: true },
);
collections.ap_pending_follows.createIndex(
{ requestedAt: -1 },
{ background: true },
);
// Server-level blocks
collections.ap_blocked_servers.createIndex(
{ hostname: 1 },
{ unique: true, background: true },
);
// Key freshness tracking
collections.ap_key_freshness.createIndex(
{ actorUrl: 1 },
{ unique: true, background: true },
);
// Inbox queue indexes
collections.ap_inbox_queue.createIndex(
{ status: 1, receivedAt: 1 },
{ background: true },
);
// TTL: auto-prune completed items after 24h
collections.ap_inbox_queue.createIndex(
{ processedAt: 1 },
{ expireAfterSeconds: 86_400, background: true },
);
// Mastodon Client API indexes
collections.ap_oauth_apps.createIndex(
{ clientId: 1 },
{ unique: true, background: true },
);
collections.ap_oauth_tokens.createIndex(
{ accessToken: 1 },
{ unique: true, sparse: true, background: true },
);
collections.ap_oauth_tokens.createIndex(
{ code: 1 },
{ unique: true, sparse: true, background: true },
);
collections.ap_markers.createIndex(
{ userId: 1, timeline: 1 },
{ unique: true, background: true },
);
} catch {
// Index creation failed — collections not yet available.
// Indexes already exist from previous startups; non-fatal.
}
}

View File

@@ -268,14 +268,32 @@ export async function renderItemCards(items, request, templateData) {
return htmlParts.join(""); return htmlParts.join("");
} }
// ─── Moderation data cache ──────────────────────────────────────────────────
let _moderationCache = null;
let _moderationCacheAt = 0;
const MODERATION_CACHE_TTL = 30_000; // 30 seconds
/**
* Invalidate the moderation data cache.
* Call this from any write operation that changes muted/blocked data.
*/
export function invalidateModerationCache() {
_moderationCache = null;
_moderationCacheAt = 0;
}
/** /**
* Load moderation data from MongoDB collections. * Load moderation data from MongoDB collections.
* Convenience wrapper to reduce boilerplate in controllers. * Results are cached in memory for 30 seconds to avoid redundant queries.
* *
* @param {object} modCollections - { ap_muted, ap_blocked, ap_profile } * @param {object} modCollections - { ap_muted, ap_blocked, ap_profile }
* @returns {Promise<object>} moderation data for postProcessItems() * @returns {Promise<object>} moderation data for postProcessItems()
*/ */
export async function loadModerationData(modCollections) { export async function loadModerationData(modCollections) {
if (_moderationCache && Date.now() - _moderationCacheAt < MODERATION_CACHE_TTL) {
return _moderationCache;
}
// Dynamic import to avoid circular dependency // Dynamic import to avoid circular dependency
const { getMutedUrls, getMutedKeywords, getBlockedUrls, getFilterMode } = const { getMutedUrls, getMutedKeywords, getBlockedUrls, getFilterMode } =
await import("./storage/moderation.js"); await import("./storage/moderation.js");
@@ -287,5 +305,7 @@ export async function loadModerationData(modCollections) {
getFilterMode(modCollections), getFilterMode(modCollections),
]); ]);
return { mutedUrls, mutedKeywords, blockedUrls, filterMode }; _moderationCache = { mutedUrls, mutedKeywords, blockedUrls, filterMode };
_moderationCacheAt = Date.now();
return _moderationCache;
} }

View File

@@ -20,6 +20,9 @@ export function getCached(url) {
lookupCache.delete(url); lookupCache.delete(url);
return null; return null;
} }
// Promote to end of Map (true LRU)
lookupCache.delete(url);
lookupCache.set(url, entry);
return entry.data; return entry.data;
} }

View File

@@ -43,6 +43,16 @@ export async function backfillTimeline(collections) {
let inserted = 0; let inserted = 0;
let skipped = 0; let skipped = 0;
// Batch-fetch existing UIDs to avoid N+1 per-post queries
const allUids = allPosts
.map((p) => p.properties?.url)
.filter(Boolean);
const existingDocs = await ap_timeline
.find({ uid: { $in: allUids } })
.project({ uid: 1 })
.toArray();
const existingUids = new Set(existingDocs.map((d) => d.uid));
for (const post of allPosts) { for (const post of allPosts) {
const props = post.properties; const props = post.properties;
if (!props?.url) { if (!props?.url) {
@@ -53,8 +63,7 @@ export async function backfillTimeline(collections) {
const uid = props.url; const uid = props.url;
// Check if already in timeline (fast path to avoid unnecessary upserts) // Check if already in timeline (fast path to avoid unnecessary upserts)
const exists = await ap_timeline.findOne({ uid }, { projection: { _id: 1 } }); if (existingUids.has(uid)) {
if (exists) {
skipped++; skipped++;
continue; continue;
} }

View File

@@ -1,105 +1,33 @@
/** /**
* XSS HTML sanitizer for Mastodon Client API responses. * HTML sanitizer for Mastodon Client API responses.
* *
* Strips dangerous HTML while preserving safe markup that * Uses the sanitize-html library for robust XSS prevention.
* Mastodon clients expect (links, paragraphs, line breaks, * Preserves safe markup that Mastodon clients expect (links,
* inline formatting, mentions, hashtags). * paragraphs, line breaks, inline formatting, mentions, hashtags).
*/ */
import sanitizeHtmlLib from "sanitize-html";
/**
* Allowed HTML tags in Mastodon API content fields.
* Matches what Mastodon itself permits in status content.
*/
const ALLOWED_TAGS = new Set([
"a",
"br",
"p",
"span",
"strong",
"em",
"b",
"i",
"u",
"s",
"del",
"pre",
"code",
"blockquote",
"ul",
"ol",
"li",
]);
/**
* Allowed attributes per tag.
*/
const ALLOWED_ATTRS = {
a: new Set(["href", "rel", "class", "target"]),
span: new Set(["class"]),
};
/** /**
* Sanitize HTML content for safe inclusion in API responses. * Sanitize HTML content for safe inclusion in API responses.
*
* Strips all tags not in the allowlist and removes disallowed attributes.
* This is a lightweight sanitizer — for production, consider a
* battle-tested library like DOMPurify or sanitize-html.
*
* @param {string} html - Raw HTML string * @param {string} html - Raw HTML string
* @returns {string} Sanitized HTML * @returns {string} Sanitized HTML
*/ */
export function sanitizeHtml(html) { export function sanitizeHtml(html) {
if (!html || typeof html !== "string") return ""; if (!html || typeof html !== "string") return "";
return html.replace(/<\/?([a-z][a-z0-9]*)\b[^>]*>/gi, (match, tagName) => { return sanitizeHtmlLib(html, {
const tag = tagName.toLowerCase(); allowedTags: [
"a", "br", "p", "span", "strong", "em", "b", "i", "u", "s",
// Closing tag "del", "pre", "code", "blockquote", "ul", "ol", "li",
if (match.startsWith("</")) { ],
return ALLOWED_TAGS.has(tag) ? `</${tag}>` : ""; allowedAttributes: {
} a: ["href", "rel", "class", "target"],
span: ["class"],
// Opening tag — check if allowed },
if (!ALLOWED_TAGS.has(tag)) return ""; allowedSchemes: ["http", "https", "mailto"],
// Self-closing br
if (tag === "br") return "<br>";
// Strip disallowed attributes
const allowedAttrs = ALLOWED_ATTRS[tag];
if (!allowedAttrs) return `<${tag}>`;
const attrs = [];
const attrRegex = /([a-z][a-z0-9-]*)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|(\S+)))?/gi;
let attrMatch;
while ((attrMatch = attrRegex.exec(match)) !== null) {
const attrName = attrMatch[1].toLowerCase();
if (attrName === tag) continue; // skip tag name itself
const attrValue = attrMatch[2] ?? attrMatch[3] ?? attrMatch[4] ?? "";
if (allowedAttrs.has(attrName)) {
// Block javascript: URIs in href
if (attrName === "href" && /^\s*javascript:/i.test(attrValue)) continue;
attrs.push(`${attrName}="${escapeAttr(attrValue)}"`);
}
}
return attrs.length > 0 ? `<${tag} ${attrs.join(" ")}>` : `<${tag}>`;
}); });
} }
/**
* Escape HTML attribute value.
* @param {string} value
* @returns {string}
*/
function escapeAttr(value) {
return value
.replace(/&/g, "&amp;")
.replace(/"/g, "&quot;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;");
}
/** /**
* Strip all HTML tags, returning plain text. * Strip all HTML tags, returning plain text.
* @param {string} html * @param {string} html
@@ -107,5 +35,8 @@ function escapeAttr(value) {
*/ */
export function stripHtml(html) { export function stripHtml(html) {
if (!html || typeof html !== "string") return ""; if (!html || typeof html !== "string") return "";
return html.replace(/<[^>]*>/g, "").trim(); return sanitizeHtmlLib(html, {
allowedTags: [],
allowedAttributes: {},
}).trim();
} }

View File

@@ -57,6 +57,9 @@ export function getCachedAccountStats(actorUrl) {
return null; return null;
} }
// Promote to end of Map (true LRU)
cache.delete(actorUrl);
cache.set(actorUrl, entry);
return entry; return entry;
} }

View File

@@ -2,19 +2,17 @@
* Enrich embedded account objects in serialized statuses with real * Enrich embedded account objects in serialized statuses with real
* follower/following/post counts from remote AP collections. * follower/following/post counts from remote AP collections.
* *
* Phanpy (and some other clients) never call /accounts/:id — they * Applies cached stats immediately. Uncached accounts are resolved
* trust the account object embedded in each status. Without enrichment, * in the background (fire-and-forget) and will be populated for
* these show 0/0/0 for all remote accounts. * subsequent requests.
*
* Uses the account stats cache to avoid redundant fetches. Only resolves
* unique authors with 0 counts that aren't already cached.
*/ */
import { getCachedAccountStats } from "./account-cache.js"; import { getCachedAccountStats } from "./account-cache.js";
import { resolveRemoteAccount } from "./resolve-account.js"; import { resolveRemoteAccount } from "./resolve-account.js";
/** /**
* Enrich account objects in a list of serialized statuses. * Enrich account objects in a list of serialized statuses.
* Resolves unique authors in parallel (max 5 concurrent). * Applies cached stats synchronously. Uncached accounts are resolved
* in the background for future requests.
* *
* @param {Array} statuses - Serialized Mastodon Status objects (mutated in place) * @param {Array} statuses - Serialized Mastodon Status objects (mutated in place)
* @param {object} pluginOptions - Plugin options with federation context * @param {object} pluginOptions - Plugin options with federation context
@@ -23,63 +21,33 @@ import { resolveRemoteAccount } from "./resolve-account.js";
export async function enrichAccountStats(statuses, pluginOptions, baseUrl) { export async function enrichAccountStats(statuses, pluginOptions, baseUrl) {
if (!statuses?.length || !pluginOptions?.federation) return; if (!statuses?.length || !pluginOptions?.federation) return;
// Collect unique author URLs that need enrichment const uncachedUrls = [];
const accountsToEnrich = new Map(); // url -> [account references]
for (const status of statuses) { for (const status of statuses) {
collectAccount(status.account, accountsToEnrich); applyCachedOrCollect(status.account, uncachedUrls);
if (status.reblog?.account) { if (status.reblog?.account) {
collectAccount(status.reblog.account, accountsToEnrich); applyCachedOrCollect(status.reblog.account, uncachedUrls);
} }
} }
if (accountsToEnrich.size === 0) return; // Fire-and-forget background enrichment for uncached accounts.
// Next request will pick up the cached results.
// Resolve in parallel with concurrency limit if (uncachedUrls.length > 0) {
const entries = [...accountsToEnrich.entries()]; resolveInBackground(uncachedUrls, pluginOptions, baseUrl);
const CONCURRENCY = 5;
for (let i = 0; i < entries.length; i += CONCURRENCY) {
const batch = entries.slice(i, i + CONCURRENCY);
await Promise.all(
batch.map(async ([url, accounts]) => {
try {
const resolved = await resolveRemoteAccount(url, pluginOptions, baseUrl);
if (resolved) {
for (const account of accounts) {
account.followers_count = resolved.followers_count;
account.following_count = resolved.following_count;
account.statuses_count = resolved.statuses_count;
if (resolved.created_at && account.created_at) {
account.created_at = resolved.created_at;
}
if (resolved.note) account.note = resolved.note;
if (resolved.fields?.length) account.fields = resolved.fields;
if (resolved.avatar && resolved.avatar !== account.avatar) {
account.avatar = resolved.avatar;
account.avatar_static = resolved.avatar;
}
if (resolved.header) {
account.header = resolved.header;
account.header_static = resolved.header;
}
}
}
} catch {
// Silently skip failed resolutions
}
}),
);
} }
} }
/** /**
* Collect an account reference for enrichment if it has 0 counts * Apply cached stats to an account, or collect its URL for background resolution.
* and isn't already cached. * @param {object} account - Account object to enrich
* @param {string[]} uncachedUrls - Array to collect uncached URLs into
*/ */
function collectAccount(account, map) { function applyCachedOrCollect(account, uncachedUrls) {
if (!account?.url) return; if (!account?.url) return;
// Already has real counts — skip
if (account.followers_count > 0 || account.statuses_count > 0) return; if (account.followers_count > 0 || account.statuses_count > 0) return;
// Check cache first — if cached, apply immediately
const cached = getCachedAccountStats(account.url); const cached = getCachedAccountStats(account.url);
if (cached) { if (cached) {
account.followers_count = cached.followersCount || 0; account.followers_count = cached.followersCount || 0;
@@ -89,9 +57,28 @@ function collectAccount(account, map) {
return; return;
} }
// Queue for remote resolution if (!uncachedUrls.includes(account.url)) {
if (!map.has(account.url)) { uncachedUrls.push(account.url);
map.set(account.url, []);
} }
map.get(account.url).push(account); }
/**
* Resolve accounts in background. Fire-and-forget — errors are silently ignored.
* resolveRemoteAccount() populates the account cache as a side effect.
* @param {string[]} urls - Actor URLs to resolve
* @param {object} pluginOptions - Plugin options
* @param {string} baseUrl - Server base URL
*/
function resolveInBackground(urls, pluginOptions, baseUrl) {
const unique = [...new Set(urls)];
const CONCURRENCY = 5;
(async () => {
for (let i = 0; i < unique.length; i += CONCURRENCY) {
const batch = unique.slice(i, i + CONCURRENCY);
await Promise.allSettled(
batch.map((url) => resolveRemoteAccount(url, pluginOptions, baseUrl)),
);
}
})().catch(() => {});
} }

View File

@@ -6,6 +6,7 @@
* /api/v1/*, /api/v2/*, /oauth/* at the domain root. * /api/v1/*, /api/v2/*, /oauth/* at the domain root.
*/ */
import express from "express"; import express from "express";
import rateLimit from "express-rate-limit";
import { corsMiddleware } from "./middleware/cors.js"; import { corsMiddleware } from "./middleware/cors.js";
import { tokenRequired, optionalToken } from "./middleware/token-required.js"; import { tokenRequired, optionalToken } from "./middleware/token-required.js";
import { errorHandler, notImplementedHandler } from "./middleware/error-handler.js"; import { errorHandler, notImplementedHandler } from "./middleware/error-handler.js";
@@ -21,6 +22,31 @@ import searchRouter from "./routes/search.js";
import mediaRouter from "./routes/media.js"; import mediaRouter from "./routes/media.js";
import stubsRouter from "./routes/stubs.js"; import stubsRouter from "./routes/stubs.js";
// Rate limiters for different endpoint categories
const apiLimiter = rateLimit({
windowMs: 5 * 60 * 1000, // 5 minutes
max: 300,
standardHeaders: true,
legacyHeaders: false,
message: { error: "Too many requests, please try again later" },
});
const authLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 30,
standardHeaders: true,
legacyHeaders: false,
message: { error: "Too many authentication attempts" },
});
const appRegistrationLimiter = rateLimit({
windowMs: 60 * 60 * 1000, // 1 hour
max: 25,
standardHeaders: true,
legacyHeaders: false,
message: { error: "Too many app registrations" },
});
/** /**
* Create the combined Mastodon API router. * Create the combined Mastodon API router.
* *
@@ -46,6 +72,11 @@ export function createMastodonRouter({ collections, pluginOptions = {} }) {
router.use("/oauth/revoke", corsMiddleware); router.use("/oauth/revoke", corsMiddleware);
router.use("/.well-known/oauth-authorization-server", corsMiddleware); router.use("/.well-known/oauth-authorization-server", corsMiddleware);
// ─── Rate limiting ─────────────────────────────────────────────────────
router.use("/api", apiLimiter);
router.use("/oauth/token", authLimiter);
router.use("/api/v1/apps", appRegistrationLimiter);
// ─── Inject collections + plugin options into req ─────────────────────── // ─── Inject collections + plugin options into req ───────────────────────
router.use("/api", (req, res, next) => { router.use("/api", (req, res, next) => {
req.app.locals.mastodonCollections = collections; req.app.locals.mastodonCollections = collections;

View File

@@ -11,18 +11,15 @@ import { accountId, remoteActorId } from "../helpers/id-mapping.js";
import { buildPaginationQuery, parseLimit, setPaginationHeaders } from "../helpers/pagination.js"; import { buildPaginationQuery, parseLimit, setPaginationHeaders } from "../helpers/pagination.js";
import { resolveRemoteAccount } from "../helpers/resolve-account.js"; import { resolveRemoteAccount } from "../helpers/resolve-account.js";
import { getActorUrlFromId } from "../helpers/account-cache.js"; import { getActorUrlFromId } from "../helpers/account-cache.js";
import { tokenRequired } from "../middleware/token-required.js";
import { scopeRequired } from "../middleware/scope-required.js";
const router = express.Router(); // eslint-disable-line new-cap const router = express.Router(); // eslint-disable-line new-cap
// ─── GET /api/v1/accounts/verify_credentials ───────────────────────────────── // ─── GET /api/v1/accounts/verify_credentials ─────────────────────────────────
router.get("/api/v1/accounts/verify_credentials", async (req, res, next) => { router.get("/api/v1/accounts/verify_credentials", tokenRequired, scopeRequired("read", "read:accounts"), async (req, res, next) => {
try { try {
const token = req.mastodonToken;
if (!token) {
return res.status(401).json({ error: "The access token is invalid" });
}
const baseUrl = `${req.protocol}://${req.get("host")}`; const baseUrl = `${req.protocol}://${req.get("host")}`;
const collections = req.app.locals.mastodonCollections; const collections = req.app.locals.mastodonCollections;
const pluginOptions = req.app.locals.mastodonPluginOptions || {}; const pluginOptions = req.app.locals.mastodonPluginOptions || {};
@@ -62,7 +59,7 @@ router.get("/api/v1/accounts/verify_credentials", async (req, res, next) => {
// ─── GET /api/v1/preferences ───────────────────────────────────────────────── // ─── GET /api/v1/preferences ─────────────────────────────────────────────────
router.get("/api/v1/preferences", (req, res) => { router.get("/api/v1/preferences", tokenRequired, scopeRequired("read", "read:accounts"), (req, res) => {
res.json({ res.json({
"posting:default:visibility": "public", "posting:default:visibility": "public",
"posting:default:sensitive": false, "posting:default:sensitive": false,
@@ -159,7 +156,7 @@ router.get("/api/v1/accounts/lookup", async (req, res, next) => {
// ─── GET /api/v1/accounts/relationships ────────────────────────────────────── // ─── GET /api/v1/accounts/relationships ──────────────────────────────────────
// MUST be before /accounts/:id to prevent Express matching "relationships" as :id // MUST be before /accounts/:id to prevent Express matching "relationships" as :id
router.get("/api/v1/accounts/relationships", async (req, res, next) => { router.get("/api/v1/accounts/relationships", tokenRequired, scopeRequired("read", "read:follows"), async (req, res, next) => {
try { try {
let ids = req.query["id[]"] || req.query.id || []; let ids = req.query["id[]"] || req.query.id || [];
if (!Array.isArray(ids)) ids = [ids]; if (!Array.isArray(ids)) ids = [ids];
@@ -225,7 +222,7 @@ router.get("/api/v1/accounts/relationships", async (req, res, next) => {
// ─── GET /api/v1/accounts/familiar_followers ───────────────────────────────── // ─── GET /api/v1/accounts/familiar_followers ─────────────────────────────────
// MUST be before /accounts/:id // MUST be before /accounts/:id
router.get("/api/v1/accounts/familiar_followers", (req, res) => { router.get("/api/v1/accounts/familiar_followers", tokenRequired, scopeRequired("read", "read:follows"), (req, res) => {
let ids = req.query["id[]"] || req.query.id || []; let ids = req.query["id[]"] || req.query.id || [];
if (!Array.isArray(ids)) ids = [ids]; if (!Array.isArray(ids)) ids = [ids];
res.json(ids.map((id) => ({ id, accounts: [] }))); res.json(ids.map((id) => ({ id, accounts: [] })));
@@ -233,7 +230,7 @@ router.get("/api/v1/accounts/familiar_followers", (req, res) => {
// ─── GET /api/v1/accounts/:id ──────────────────────────────────────────────── // ─── GET /api/v1/accounts/:id ────────────────────────────────────────────────
router.get("/api/v1/accounts/:id", async (req, res, next) => { router.get("/api/v1/accounts/:id", tokenRequired, scopeRequired("read", "read:accounts"), async (req, res, next) => {
try { try {
const { id } = req.params; const { id } = req.params;
const baseUrl = `${req.protocol}://${req.get("host")}`; const baseUrl = `${req.protocol}://${req.get("host")}`;
@@ -285,7 +282,7 @@ router.get("/api/v1/accounts/:id", async (req, res, next) => {
// ─── GET /api/v1/accounts/:id/statuses ────────────────────────────────────── // ─── GET /api/v1/accounts/:id/statuses ──────────────────────────────────────
router.get("/api/v1/accounts/:id/statuses", async (req, res, next) => { router.get("/api/v1/accounts/:id/statuses", tokenRequired, scopeRequired("read", "read:statuses"), async (req, res, next) => {
try { try {
const { id } = req.params; const { id } = req.params;
const collections = req.app.locals.mastodonCollections; const collections = req.app.locals.mastodonCollections;
@@ -376,7 +373,7 @@ router.get("/api/v1/accounts/:id/statuses", async (req, res, next) => {
// ─── GET /api/v1/accounts/:id/followers ───────────────────────────────────── // ─── GET /api/v1/accounts/:id/followers ─────────────────────────────────────
router.get("/api/v1/accounts/:id/followers", async (req, res, next) => { router.get("/api/v1/accounts/:id/followers", tokenRequired, scopeRequired("read", "read:follows"), async (req, res, next) => {
try { try {
const { id } = req.params; const { id } = req.params;
const collections = req.app.locals.mastodonCollections; const collections = req.app.locals.mastodonCollections;
@@ -409,7 +406,7 @@ router.get("/api/v1/accounts/:id/followers", async (req, res, next) => {
// ─── GET /api/v1/accounts/:id/following ───────────────────────────────────── // ─── GET /api/v1/accounts/:id/following ─────────────────────────────────────
router.get("/api/v1/accounts/:id/following", async (req, res, next) => { router.get("/api/v1/accounts/:id/following", tokenRequired, scopeRequired("read", "read:follows"), async (req, res, next) => {
try { try {
const { id } = req.params; const { id } = req.params;
const collections = req.app.locals.mastodonCollections; const collections = req.app.locals.mastodonCollections;
@@ -442,13 +439,8 @@ router.get("/api/v1/accounts/:id/following", async (req, res, next) => {
// ─── POST /api/v1/accounts/:id/follow ─────────────────────────────────────── // ─── POST /api/v1/accounts/:id/follow ───────────────────────────────────────
router.post("/api/v1/accounts/:id/follow", async (req, res, next) => { router.post("/api/v1/accounts/:id/follow", tokenRequired, scopeRequired("write", "write:follows", "follow"), async (req, res, next) => {
try { try {
const token = req.mastodonToken;
if (!token) {
return res.status(401).json({ error: "The access token is invalid" });
}
const { id } = req.params; const { id } = req.params;
const collections = req.app.locals.mastodonCollections; const collections = req.app.locals.mastodonCollections;
const pluginOptions = req.app.locals.mastodonPluginOptions || {}; const pluginOptions = req.app.locals.mastodonPluginOptions || {};
@@ -504,13 +496,8 @@ router.post("/api/v1/accounts/:id/follow", async (req, res, next) => {
// ─── POST /api/v1/accounts/:id/unfollow ───────────────────────────────────── // ─── POST /api/v1/accounts/:id/unfollow ─────────────────────────────────────
router.post("/api/v1/accounts/:id/unfollow", async (req, res, next) => { router.post("/api/v1/accounts/:id/unfollow", tokenRequired, scopeRequired("write", "write:follows", "follow"), async (req, res, next) => {
try { try {
const token = req.mastodonToken;
if (!token) {
return res.status(401).json({ error: "The access token is invalid" });
}
const { id } = req.params; const { id } = req.params;
const collections = req.app.locals.mastodonCollections; const collections = req.app.locals.mastodonCollections;
const pluginOptions = req.app.locals.mastodonPluginOptions || {}; const pluginOptions = req.app.locals.mastodonPluginOptions || {};
@@ -557,13 +544,8 @@ router.post("/api/v1/accounts/:id/unfollow", async (req, res, next) => {
// ─── POST /api/v1/accounts/:id/mute ──────────────────────────────────────── // ─── POST /api/v1/accounts/:id/mute ────────────────────────────────────────
router.post("/api/v1/accounts/:id/mute", async (req, res, next) => { router.post("/api/v1/accounts/:id/mute", tokenRequired, scopeRequired("write", "write:mutes", "follow"), async (req, res, next) => {
try { try {
const token = req.mastodonToken;
if (!token) {
return res.status(401).json({ error: "The access token is invalid" });
}
const { id } = req.params; const { id } = req.params;
const collections = req.app.locals.mastodonCollections; const collections = req.app.locals.mastodonCollections;
@@ -600,13 +582,8 @@ router.post("/api/v1/accounts/:id/mute", async (req, res, next) => {
// ─── POST /api/v1/accounts/:id/unmute ─────────────────────────────────────── // ─── POST /api/v1/accounts/:id/unmute ───────────────────────────────────────
router.post("/api/v1/accounts/:id/unmute", async (req, res, next) => { router.post("/api/v1/accounts/:id/unmute", tokenRequired, scopeRequired("write", "write:mutes", "follow"), async (req, res, next) => {
try { try {
const token = req.mastodonToken;
if (!token) {
return res.status(401).json({ error: "The access token is invalid" });
}
const { id } = req.params; const { id } = req.params;
const collections = req.app.locals.mastodonCollections; const collections = req.app.locals.mastodonCollections;
@@ -639,13 +616,8 @@ router.post("/api/v1/accounts/:id/unmute", async (req, res, next) => {
// ─── POST /api/v1/accounts/:id/block ─────────────────────────────────────── // ─── POST /api/v1/accounts/:id/block ───────────────────────────────────────
router.post("/api/v1/accounts/:id/block", async (req, res, next) => { router.post("/api/v1/accounts/:id/block", tokenRequired, scopeRequired("write", "write:blocks", "follow"), async (req, res, next) => {
try { try {
const token = req.mastodonToken;
if (!token) {
return res.status(401).json({ error: "The access token is invalid" });
}
const { id } = req.params; const { id } = req.params;
const collections = req.app.locals.mastodonCollections; const collections = req.app.locals.mastodonCollections;
@@ -682,13 +654,8 @@ router.post("/api/v1/accounts/:id/block", async (req, res, next) => {
// ─── POST /api/v1/accounts/:id/unblock ────────────────────────────────────── // ─── POST /api/v1/accounts/:id/unblock ──────────────────────────────────────
router.post("/api/v1/accounts/:id/unblock", async (req, res, next) => { router.post("/api/v1/accounts/:id/unblock", tokenRequired, scopeRequired("write", "write:blocks", "follow"), async (req, res, next) => {
try { try {
const token = req.mastodonToken;
if (!token) {
return res.status(401).json({ error: "The access token is invalid" });
}
const { id } = req.params; const { id } = req.params;
const collections = req.app.locals.mastodonCollections; const collections = req.app.locals.mastodonCollections;

View File

@@ -7,12 +7,14 @@
* PUT /api/v1/media/:id — update media metadata (description/focus) * PUT /api/v1/media/:id — update media metadata (description/focus)
*/ */
import express from "express"; import express from "express";
import { tokenRequired } from "../middleware/token-required.js";
import { scopeRequired } from "../middleware/scope-required.js";
const router = express.Router(); // eslint-disable-line new-cap const router = express.Router(); // eslint-disable-line new-cap
// ─── POST /api/v2/media ───────────────────────────────────────────────────── // ─── POST /api/v2/media ─────────────────────────────────────────────────────
router.post("/api/v2/media", (req, res) => { router.post("/api/v2/media", tokenRequired, scopeRequired("write", "write:media"), (req, res) => {
// Media upload requires multer/multipart handling + storage backend. // Media upload requires multer/multipart handling + storage backend.
// For now, return 422 so clients show a user-friendly error. // For now, return 422 so clients show a user-friendly error.
res.status(422).json({ res.status(422).json({
@@ -22,7 +24,7 @@ router.post("/api/v2/media", (req, res) => {
// ─── POST /api/v1/media (legacy) ──────────────────────────────────────────── // ─── POST /api/v1/media (legacy) ────────────────────────────────────────────
router.post("/api/v1/media", (req, res) => { router.post("/api/v1/media", tokenRequired, scopeRequired("write", "write:media"), (req, res) => {
res.status(422).json({ res.status(422).json({
error: "Media uploads are not yet supported on this server", error: "Media uploads are not yet supported on this server",
}); });
@@ -30,13 +32,13 @@ router.post("/api/v1/media", (req, res) => {
// ─── GET /api/v1/media/:id ────────────────────────────────────────────────── // ─── GET /api/v1/media/:id ──────────────────────────────────────────────────
router.get("/api/v1/media/:id", (req, res) => { router.get("/api/v1/media/:id", tokenRequired, scopeRequired("read", "read:statuses"), (req, res) => {
res.status(404).json({ error: "Record not found" }); res.status(404).json({ error: "Record not found" });
}); });
// ─── PUT /api/v1/media/:id ────────────────────────────────────────────────── // ─── PUT /api/v1/media/:id ──────────────────────────────────────────────────
router.put("/api/v1/media/:id", (req, res) => { router.put("/api/v1/media/:id", tokenRequired, scopeRequired("write", "write:media"), (req, res) => {
res.status(404).json({ error: "Record not found" }); res.status(404).json({ error: "Record not found" });
}); });

View File

@@ -10,6 +10,8 @@ import express from "express";
import { ObjectId } from "mongodb"; import { ObjectId } from "mongodb";
import { serializeNotification } from "../entities/notification.js"; import { serializeNotification } from "../entities/notification.js";
import { buildPaginationQuery, parseLimit, setPaginationHeaders } from "../helpers/pagination.js"; import { buildPaginationQuery, parseLimit, setPaginationHeaders } from "../helpers/pagination.js";
import { tokenRequired } from "../middleware/token-required.js";
import { scopeRequired } from "../middleware/scope-required.js";
const router = express.Router(); // eslint-disable-line new-cap const router = express.Router(); // eslint-disable-line new-cap
@@ -29,13 +31,8 @@ const REVERSE_TYPE_MAP = {
// ─── GET /api/v1/notifications ────────────────────────────────────────────── // ─── GET /api/v1/notifications ──────────────────────────────────────────────
router.get("/api/v1/notifications", async (req, res, next) => { router.get("/api/v1/notifications", tokenRequired, scopeRequired("read", "read:notifications"), async (req, res, next) => {
try { try {
const token = req.mastodonToken;
if (!token) {
return res.status(401).json({ error: "The access token is invalid" });
}
const collections = req.app.locals.mastodonCollections; const collections = req.app.locals.mastodonCollections;
const baseUrl = `${req.protocol}://${req.get("host")}`; const baseUrl = `${req.protocol}://${req.get("host")}`;
const limit = parseLimit(req.query.limit); const limit = parseLimit(req.query.limit);
@@ -105,13 +102,8 @@ router.get("/api/v1/notifications", async (req, res, next) => {
// ─── GET /api/v1/notifications/:id ────────────────────────────────────────── // ─── GET /api/v1/notifications/:id ──────────────────────────────────────────
router.get("/api/v1/notifications/:id", async (req, res, next) => { router.get("/api/v1/notifications/:id", tokenRequired, scopeRequired("read", "read:notifications"), async (req, res, next) => {
try { try {
const token = req.mastodonToken;
if (!token) {
return res.status(401).json({ error: "The access token is invalid" });
}
const collections = req.app.locals.mastodonCollections; const collections = req.app.locals.mastodonCollections;
const baseUrl = `${req.protocol}://${req.get("host")}`; const baseUrl = `${req.protocol}://${req.get("host")}`;
@@ -147,13 +139,8 @@ router.get("/api/v1/notifications/:id", async (req, res, next) => {
// ─── POST /api/v1/notifications/clear ─────────────────────────────────────── // ─── POST /api/v1/notifications/clear ───────────────────────────────────────
router.post("/api/v1/notifications/clear", async (req, res, next) => { router.post("/api/v1/notifications/clear", tokenRequired, scopeRequired("write", "write:notifications"), async (req, res, next) => {
try { try {
const token = req.mastodonToken;
if (!token) {
return res.status(401).json({ error: "The access token is invalid" });
}
const collections = req.app.locals.mastodonCollections; const collections = req.app.locals.mastodonCollections;
await collections.ap_notifications.deleteMany({}); await collections.ap_notifications.deleteMany({});
res.json({}); res.json({});
@@ -164,13 +151,8 @@ router.post("/api/v1/notifications/clear", async (req, res, next) => {
// ─── POST /api/v1/notifications/:id/dismiss ───────────────────────────────── // ─── POST /api/v1/notifications/:id/dismiss ─────────────────────────────────
router.post("/api/v1/notifications/:id/dismiss", async (req, res, next) => { router.post("/api/v1/notifications/:id/dismiss", tokenRequired, scopeRequired("write", "write:notifications"), async (req, res, next) => {
try { try {
const token = req.mastodonToken;
if (!token) {
return res.status(401).json({ error: "The access token is invalid" });
}
const collections = req.app.locals.mastodonCollections; const collections = req.app.locals.mastodonCollections;
let objectId; let objectId;

View File

@@ -4,6 +4,7 @@
* Handles app registration, authorization, token exchange, and revocation. * Handles app registration, authorization, token exchange, and revocation.
*/ */
import crypto from "node:crypto"; import crypto from "node:crypto";
import { getToken as getCsrfToken, validateToken as validateCsrf } from "../../csrf.js";
import express from "express"; import express from "express";
const router = express.Router(); // eslint-disable-line new-cap const router = express.Router(); // eslint-disable-line new-cap
@@ -17,6 +18,29 @@ function randomHex(bytes) {
return crypto.randomBytes(bytes).toString("hex"); return crypto.randomBytes(bytes).toString("hex");
} }
/**
* Escape HTML special characters to prevent XSS.
* @param {string} str - Untrusted string
* @returns {string} Safe HTML text
*/
function escapeHtml(str) {
return String(str ?? "")
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#x27;");
}
/**
* Hash a client secret for secure storage.
* @param {string} secret - Plaintext secret
* @returns {string} SHA-256 hex hash
*/
function hashSecret(secret) {
return crypto.createHash("sha256").update(secret).digest("hex");
}
/** /**
* Parse redirect_uris from request — accepts space-separated string or array. * Parse redirect_uris from request — accepts space-separated string or array.
* @param {string|string[]} value * @param {string|string[]} value
@@ -57,7 +81,7 @@ router.post("/api/v1/apps", async (req, res, next) => {
const doc = { const doc = {
clientId, clientId,
clientSecret, clientSecretHash: hashSecret(clientSecret),
name: client_name || "", name: client_name || "",
redirectUris, redirectUris,
scopes: parsedScopes, scopes: parsedScopes,
@@ -251,7 +275,7 @@ router.get("/oauth/authorize", async (req, res, next) => {
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<title>Authorize ${appName}</title> <title>Authorize ${escapeHtml(appName)}</title>
<style> <style>
body { font-family: system-ui, sans-serif; max-width: 480px; margin: 2rem auto; padding: 0 1rem; } body { font-family: system-ui, sans-serif; max-width: 480px; margin: 2rem auto; padding: 0 1rem; }
h1 { font-size: 1.4rem; } h1 { font-size: 1.4rem; }
@@ -264,17 +288,18 @@ router.get("/oauth/authorize", async (req, res, next) => {
</style> </style>
</head> </head>
<body> <body>
<h1>Authorize ${appName}</h1> <h1>Authorize ${escapeHtml(appName)}</h1>
<p>${appName} wants to access your account with these permissions:</p> <p>${escapeHtml(appName)} wants to access your account with these permissions:</p>
<div class="scopes"> <div class="scopes">
${requestedScopes.map((s) => `<code>${s}</code>`).join("")} ${requestedScopes.map((s) => `<code>${escapeHtml(s)}</code>`).join("")}
</div> </div>
<form method="POST" action="/oauth/authorize"> <form method="POST" action="/oauth/authorize">
<input type="hidden" name="client_id" value="${client_id}"> <input type="hidden" name="_csrf" value="${escapeHtml(getCsrfToken(req.session))}">
<input type="hidden" name="redirect_uri" value="${resolvedRedirectUri}"> <input type="hidden" name="client_id" value="${escapeHtml(client_id)}">
<input type="hidden" name="scope" value="${requestedScopes.join(" ")}"> <input type="hidden" name="redirect_uri" value="${escapeHtml(resolvedRedirectUri)}">
<input type="hidden" name="code_challenge" value="${code_challenge || ""}"> <input type="hidden" name="scope" value="${escapeHtml(requestedScopes.join(" "))}">
<input type="hidden" name="code_challenge_method" value="${code_challenge_method || ""}"> <input type="hidden" name="code_challenge" value="${escapeHtml(code_challenge || "")}">
<input type="hidden" name="code_challenge_method" value="${escapeHtml(code_challenge_method || "")}">
<input type="hidden" name="response_type" value="code"> <input type="hidden" name="response_type" value="code">
<div class="actions"> <div class="actions">
<button type="submit" name="decision" value="approve" class="approve">Authorize</button> <button type="submit" name="decision" value="approve" class="approve">Authorize</button>
@@ -301,6 +326,36 @@ router.post("/oauth/authorize", async (req, res, next) => {
decision, decision,
} = req.body; } = req.body;
// Validate CSRF token
if (!validateCsrf(req)) {
return res.status(403).json({
error: "invalid_request",
error_description: "Invalid or missing CSRF token",
});
}
const collections = req.app.locals.mastodonCollections;
// Re-validate redirect_uri against registered app URIs.
// The GET handler validates this, but POST body can be tampered.
const app = await collections.ap_oauth_apps.findOne({ clientId: client_id });
if (!app) {
return res.status(400).json({
error: "invalid_client",
error_description: "Client application not found",
});
}
if (
redirect_uri &&
redirect_uri !== "urn:ietf:wg:oauth:2.0:oob" &&
!app.redirectUris.includes(redirect_uri)
) {
return res.status(400).json({
error: "invalid_redirect_uri",
error_description: "Redirect URI not registered for this application",
});
}
// User denied // User denied
if (decision === "deny") { if (decision === "deny") {
if (redirect_uri && redirect_uri !== "urn:ietf:wg:oauth:2.0:oob") { if (redirect_uri && redirect_uri !== "urn:ietf:wg:oauth:2.0:oob") {
@@ -320,7 +375,6 @@ router.post("/oauth/authorize", async (req, res, next) => {
// Generate authorization code // Generate authorization code
const code = randomHex(32); const code = randomHex(32);
const collections = req.app.locals.mastodonCollections;
// Note: accessToken is NOT set here — it's added later during token exchange. // Note: accessToken is NOT set here — it's added later during token exchange.
// The sparse unique index on accessToken skips documents where the field is // The sparse unique index on accessToken skips documents where the field is
@@ -354,7 +408,7 @@ router.post("/oauth/authorize", async (req, res, next) => {
<body> <body>
<h1>Authorization Code</h1> <h1>Authorization Code</h1>
<p>Copy this code and paste it into the application:</p> <p>Copy this code and paste it into the application:</p>
<code>${code}</code> <code>${escapeHtml(code)}</code>
</body> </body>
</html>`); </html>`);
} }
@@ -390,7 +444,7 @@ router.post("/oauth/token", async (req, res, next) => {
const app = await collections.ap_oauth_apps.findOne({ const app = await collections.ap_oauth_apps.findOne({
clientId, clientId,
clientSecret, clientSecretHash: hashSecret(clientSecret),
confidential: true, confidential: true,
}); });
@@ -410,6 +464,7 @@ router.post("/oauth/token", async (req, res, next) => {
accessToken, accessToken,
createdAt: new Date(), createdAt: new Date(),
grantType: "client_credentials", grantType: "client_credentials",
expiresAt: new Date(Date.now() + 3600 * 1000),
}); });
return res.json({ return res.json({
@@ -417,6 +472,7 @@ router.post("/oauth/token", async (req, res, next) => {
token_type: "Bearer", token_type: "Bearer",
scope: "read", scope: "read",
created_at: Math.floor(Date.now() / 1000), created_at: Math.floor(Date.now() / 1000),
expires_in: 3600,
}); });
} }
@@ -447,7 +503,14 @@ router.post("/oauth/token", async (req, res, next) => {
const newRefreshToken = randomHex(64); const newRefreshToken = randomHex(64);
await collections.ap_oauth_tokens.updateOne( await collections.ap_oauth_tokens.updateOne(
{ _id: existing._id }, { _id: existing._id },
{ $set: { accessToken: newAccessToken, refreshToken: newRefreshToken } }, {
$set: {
accessToken: newAccessToken,
refreshToken: newRefreshToken,
expiresAt: new Date(Date.now() + 3600 * 1000),
refreshExpiresAt: new Date(Date.now() + 90 * 24 * 3600 * 1000),
},
},
); );
return res.json({ return res.json({
@@ -456,6 +519,7 @@ router.post("/oauth/token", async (req, res, next) => {
scope: existing.scopes.join(" "), scope: existing.scopes.join(" "),
created_at: Math.floor(existing.createdAt.getTime() / 1000), created_at: Math.floor(existing.createdAt.getTime() / 1000),
refresh_token: newRefreshToken, refresh_token: newRefreshToken,
expires_in: 3600,
}); });
} }
@@ -523,13 +587,21 @@ router.post("/oauth/token", async (req, res, next) => {
} }
} }
// Generate access token and refresh token. // Generate access token and refresh token with expiry.
// Clear expiresAt — it was set for the auth code, not the access token. const ACCESS_TOKEN_TTL = 3600 * 1000; // 1 hour
const REFRESH_TOKEN_TTL = 90 * 24 * 3600 * 1000; // 90 days
const accessToken = randomHex(64); const accessToken = randomHex(64);
const refreshToken = randomHex(64); const refreshToken = randomHex(64);
await collections.ap_oauth_tokens.updateOne( await collections.ap_oauth_tokens.updateOne(
{ _id: grant._id }, { _id: grant._id },
{ $set: { accessToken, refreshToken, expiresAt: null } }, {
$set: {
accessToken,
refreshToken,
expiresAt: new Date(Date.now() + ACCESS_TOKEN_TTL),
refreshExpiresAt: new Date(Date.now() + REFRESH_TOKEN_TTL),
},
},
); );
res.json({ res.json({
@@ -538,6 +610,7 @@ router.post("/oauth/token", async (req, res, next) => {
scope: grant.scopes.join(" "), scope: grant.scopes.join(" "),
created_at: Math.floor(grant.createdAt.getTime() / 1000), created_at: Math.floor(grant.createdAt.getTime() / 1000),
refresh_token: refreshToken, refresh_token: refreshToken,
expires_in: 3600,
}); });
} catch (error) { } catch (error) {
next(error); next(error);
@@ -622,7 +695,7 @@ function redirectToUri(res, originalUri, fullUrl) {
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<meta http-equiv="refresh" content="0;url=${fullUrl}"> <meta http-equiv="refresh" content="0;url=${escapeHtml(fullUrl)}">
<title>Redirecting…</title> <title>Redirecting…</title>
</head> </head>
<body> <body>

View File

@@ -8,12 +8,14 @@ import { serializeStatus } from "../entities/status.js";
import { serializeAccount } from "../entities/account.js"; import { serializeAccount } from "../entities/account.js";
import { parseLimit } from "../helpers/pagination.js"; import { parseLimit } from "../helpers/pagination.js";
import { resolveRemoteAccount } from "../helpers/resolve-account.js"; import { resolveRemoteAccount } from "../helpers/resolve-account.js";
import { tokenRequired } from "../middleware/token-required.js";
import { scopeRequired } from "../middleware/scope-required.js";
const router = express.Router(); // eslint-disable-line new-cap const router = express.Router(); // eslint-disable-line new-cap
// ─── GET /api/v2/search ───────────────────────────────────────────────────── // ─── GET /api/v2/search ─────────────────────────────────────────────────────
router.get("/api/v2/search", async (req, res, next) => { router.get("/api/v2/search", tokenRequired, scopeRequired("read", "read:search"), async (req, res, next) => {
try { try {
const collections = req.app.locals.mastodonCollections; const collections = req.app.locals.mastodonCollections;
const baseUrl = `${req.protocol}://${req.get("host")}`; const baseUrl = `${req.protocol}://${req.get("host")}`;

View File

@@ -22,12 +22,14 @@ import {
bookmarkPost, unbookmarkPost, bookmarkPost, unbookmarkPost,
} from "../helpers/interactions.js"; } from "../helpers/interactions.js";
import { addTimelineItem } from "../../storage/timeline.js"; import { addTimelineItem } from "../../storage/timeline.js";
import { tokenRequired } from "../middleware/token-required.js";
import { scopeRequired } from "../middleware/scope-required.js";
const router = express.Router(); // eslint-disable-line new-cap const router = express.Router(); // eslint-disable-line new-cap
// ─── GET /api/v1/statuses/:id ─────────────────────────────────────────────── // ─── GET /api/v1/statuses/:id ───────────────────────────────────────────────
router.get("/api/v1/statuses/:id", async (req, res, next) => { router.get("/api/v1/statuses/:id", tokenRequired, scopeRequired("read", "read:statuses"), async (req, res, next) => {
try { try {
const { id } = req.params; const { id } = req.params;
const collections = req.app.locals.mastodonCollections; const collections = req.app.locals.mastodonCollections;
@@ -55,7 +57,7 @@ router.get("/api/v1/statuses/:id", async (req, res, next) => {
// ─── GET /api/v1/statuses/:id/context ─────────────────────────────────────── // ─── GET /api/v1/statuses/:id/context ───────────────────────────────────────
router.get("/api/v1/statuses/:id/context", async (req, res, next) => { router.get("/api/v1/statuses/:id/context", tokenRequired, scopeRequired("read", "read:statuses"), async (req, res, next) => {
try { try {
const { id } = req.params; const { id } = req.params;
const collections = req.app.locals.mastodonCollections; const collections = req.app.locals.mastodonCollections;
@@ -134,13 +136,8 @@ router.get("/api/v1/statuses/:id/context", async (req, res, next) => {
// Creates a post via the Micropub pipeline so it goes through the full flow: // Creates a post via the Micropub pipeline so it goes through the full flow:
// Micropub → content file → Eleventy build → syndication → AP federation. // Micropub → content file → Eleventy build → syndication → AP federation.
router.post("/api/v1/statuses", async (req, res, next) => { router.post("/api/v1/statuses", tokenRequired, scopeRequired("write", "write:statuses"), async (req, res, next) => {
try { try {
const token = req.mastodonToken;
if (!token) {
return res.status(401).json({ error: "The access token is invalid" });
}
const { application, publication } = req.app.locals; const { application, publication } = req.app.locals;
const collections = req.app.locals.mastodonCollections; const collections = req.app.locals.mastodonCollections;
const pluginOptions = req.app.locals.mastodonPluginOptions || {}; const pluginOptions = req.app.locals.mastodonPluginOptions || {};
@@ -302,13 +299,8 @@ router.post("/api/v1/statuses", async (req, res, next) => {
// Deletes via Micropub pipeline (removes content file + MongoDB post) and // Deletes via Micropub pipeline (removes content file + MongoDB post) and
// cleans up the ap_timeline entry. // cleans up the ap_timeline entry.
router.delete("/api/v1/statuses/:id", async (req, res, next) => { router.delete("/api/v1/statuses/:id", tokenRequired, scopeRequired("write", "write:statuses"), async (req, res, next) => {
try { try {
const token = req.mastodonToken;
if (!token) {
return res.status(401).json({ error: "The access token is invalid" });
}
const { application, publication } = req.app.locals; const { application, publication } = req.app.locals;
const { id } = req.params; const { id } = req.params;
const collections = req.app.locals.mastodonCollections; const collections = req.app.locals.mastodonCollections;
@@ -368,27 +360,22 @@ router.delete("/api/v1/statuses/:id", async (req, res, next) => {
// ─── GET /api/v1/statuses/:id/favourited_by ───────────────────────────────── // ─── GET /api/v1/statuses/:id/favourited_by ─────────────────────────────────
router.get("/api/v1/statuses/:id/favourited_by", async (req, res) => { router.get("/api/v1/statuses/:id/favourited_by", tokenRequired, scopeRequired("read", "read:statuses"), async (req, res) => {
// Stub — we don't track who favourited remotely // Stub — we don't track who favourited remotely
res.json([]); res.json([]);
}); });
// ─── GET /api/v1/statuses/:id/reblogged_by ────────────────────────────────── // ─── GET /api/v1/statuses/:id/reblogged_by ──────────────────────────────────
router.get("/api/v1/statuses/:id/reblogged_by", async (req, res) => { router.get("/api/v1/statuses/:id/reblogged_by", tokenRequired, scopeRequired("read", "read:statuses"), async (req, res) => {
// Stub — we don't track who boosted remotely // Stub — we don't track who boosted remotely
res.json([]); res.json([]);
}); });
// ─── POST /api/v1/statuses/:id/favourite ──────────────────────────────────── // ─── POST /api/v1/statuses/:id/favourite ────────────────────────────────────
router.post("/api/v1/statuses/:id/favourite", async (req, res, next) => { router.post("/api/v1/statuses/:id/favourite", tokenRequired, scopeRequired("write", "write:favourites"), async (req, res, next) => {
try { try {
const token = req.mastodonToken;
if (!token) {
return res.status(401).json({ error: "The access token is invalid" });
}
const { item, collections, baseUrl } = await resolveStatusForInteraction(req); const { item, collections, baseUrl } = await resolveStatusForInteraction(req);
if (!item) { if (!item) {
return res.status(404).json({ error: "Record not found" }); return res.status(404).json({ error: "Record not found" });
@@ -413,13 +400,8 @@ router.post("/api/v1/statuses/:id/favourite", async (req, res, next) => {
// ─── POST /api/v1/statuses/:id/unfavourite ────────────────────────────────── // ─── POST /api/v1/statuses/:id/unfavourite ──────────────────────────────────
router.post("/api/v1/statuses/:id/unfavourite", async (req, res, next) => { router.post("/api/v1/statuses/:id/unfavourite", tokenRequired, scopeRequired("write", "write:favourites"), async (req, res, next) => {
try { try {
const token = req.mastodonToken;
if (!token) {
return res.status(401).json({ error: "The access token is invalid" });
}
const { item, collections, baseUrl } = await resolveStatusForInteraction(req); const { item, collections, baseUrl } = await resolveStatusForInteraction(req);
if (!item) { if (!item) {
return res.status(404).json({ error: "Record not found" }); return res.status(404).json({ error: "Record not found" });
@@ -443,13 +425,8 @@ router.post("/api/v1/statuses/:id/unfavourite", async (req, res, next) => {
// ─── POST /api/v1/statuses/:id/reblog ─────────────────────────────────────── // ─── POST /api/v1/statuses/:id/reblog ───────────────────────────────────────
router.post("/api/v1/statuses/:id/reblog", async (req, res, next) => { router.post("/api/v1/statuses/:id/reblog", tokenRequired, scopeRequired("write", "write:statuses"), async (req, res, next) => {
try { try {
const token = req.mastodonToken;
if (!token) {
return res.status(401).json({ error: "The access token is invalid" });
}
const { item, collections, baseUrl } = await resolveStatusForInteraction(req); const { item, collections, baseUrl } = await resolveStatusForInteraction(req);
if (!item) { if (!item) {
return res.status(404).json({ error: "Record not found" }); return res.status(404).json({ error: "Record not found" });
@@ -473,13 +450,8 @@ router.post("/api/v1/statuses/:id/reblog", async (req, res, next) => {
// ─── POST /api/v1/statuses/:id/unreblog ───────────────────────────────────── // ─── POST /api/v1/statuses/:id/unreblog ─────────────────────────────────────
router.post("/api/v1/statuses/:id/unreblog", async (req, res, next) => { router.post("/api/v1/statuses/:id/unreblog", tokenRequired, scopeRequired("write", "write:statuses"), async (req, res, next) => {
try { try {
const token = req.mastodonToken;
if (!token) {
return res.status(401).json({ error: "The access token is invalid" });
}
const { item, collections, baseUrl } = await resolveStatusForInteraction(req); const { item, collections, baseUrl } = await resolveStatusForInteraction(req);
if (!item) { if (!item) {
return res.status(404).json({ error: "Record not found" }); return res.status(404).json({ error: "Record not found" });
@@ -503,13 +475,8 @@ router.post("/api/v1/statuses/:id/unreblog", async (req, res, next) => {
// ─── POST /api/v1/statuses/:id/bookmark ───────────────────────────────────── // ─── POST /api/v1/statuses/:id/bookmark ─────────────────────────────────────
router.post("/api/v1/statuses/:id/bookmark", async (req, res, next) => { router.post("/api/v1/statuses/:id/bookmark", tokenRequired, scopeRequired("write", "write:bookmarks"), async (req, res, next) => {
try { try {
const token = req.mastodonToken;
if (!token) {
return res.status(401).json({ error: "The access token is invalid" });
}
const { item, collections, baseUrl } = await resolveStatusForInteraction(req); const { item, collections, baseUrl } = await resolveStatusForInteraction(req);
if (!item) { if (!item) {
return res.status(404).json({ error: "Record not found" }); return res.status(404).json({ error: "Record not found" });
@@ -531,13 +498,8 @@ router.post("/api/v1/statuses/:id/bookmark", async (req, res, next) => {
// ─── POST /api/v1/statuses/:id/unbookmark ─────────────────────────────────── // ─── POST /api/v1/statuses/:id/unbookmark ───────────────────────────────────
router.post("/api/v1/statuses/:id/unbookmark", async (req, res, next) => { router.post("/api/v1/statuses/:id/unbookmark", tokenRequired, scopeRequired("write", "write:bookmarks"), async (req, res, next) => {
try { try {
const token = req.mastodonToken;
if (!token) {
return res.status(401).json({ error: "The access token is invalid" });
}
const { item, collections, baseUrl } = await resolveStatusForInteraction(req); const { item, collections, baseUrl } = await resolveStatusForInteraction(req);
if (!item) { if (!item) {
return res.status(404).json({ error: "Record not found" }); return res.status(404).json({ error: "Record not found" });

View File

@@ -10,18 +10,15 @@ import { serializeStatus } from "../entities/status.js";
import { buildPaginationQuery, parseLimit, setPaginationHeaders } from "../helpers/pagination.js"; import { buildPaginationQuery, parseLimit, setPaginationHeaders } from "../helpers/pagination.js";
import { loadModerationData, applyModerationFilters } from "../../item-processing.js"; import { loadModerationData, applyModerationFilters } from "../../item-processing.js";
import { enrichAccountStats } from "../helpers/enrich-accounts.js"; import { enrichAccountStats } from "../helpers/enrich-accounts.js";
import { tokenRequired } from "../middleware/token-required.js";
import { scopeRequired } from "../middleware/scope-required.js";
const router = express.Router(); // eslint-disable-line new-cap const router = express.Router(); // eslint-disable-line new-cap
// ─── GET /api/v1/timelines/home ───────────────────────────────────────────── // ─── GET /api/v1/timelines/home ─────────────────────────────────────────────
router.get("/api/v1/timelines/home", async (req, res, next) => { router.get("/api/v1/timelines/home", tokenRequired, scopeRequired("read", "read:statuses"), async (req, res, next) => {
try { try {
const token = req.mastodonToken;
if (!token) {
return res.status(401).json({ error: "The access token is invalid" });
}
const collections = req.app.locals.mastodonCollections; const collections = req.app.locals.mastodonCollections;
const baseUrl = `${req.protocol}://${req.get("host")}`; const baseUrl = `${req.protocol}://${req.get("host")}`;
const limit = parseLimit(req.query.limit); const limit = parseLimit(req.query.limit);

View File

@@ -3,6 +3,8 @@
* @module og-unfurl * @module og-unfurl
*/ */
import { lookup } from "node:dns/promises";
import { isIP } from "node:net";
import { unfurl } from "unfurl.js"; import { unfurl } from "unfurl.js";
import { extractObjectData } from "./timeline-store.js"; import { extractObjectData } from "./timeline-store.js";
import { lookupWithSecurity } from "./lookup-helpers.js"; import { lookupWithSecurity } from "./lookup-helpers.js";
@@ -45,45 +47,58 @@ function extractDomain(url) {
} }
/** /**
* Check if a URL points to a private/reserved IP or localhost (SSRF protection) * Check if an IP address is in a private/reserved range.
* @param {string} url - URL to check * @param {string} ip - IPv4 or IPv6 address
* @returns {boolean} True if URL targets a private network * @returns {boolean} True if private/reserved
*/ */
function isPrivateUrl(url) { function isPrivateIP(ip) {
if (isIP(ip) === 4) {
const parts = ip.split(".").map(Number);
const [a, b] = parts;
if (a === 10) return true; // 10.0.0.0/8
if (a === 172 && b >= 16 && b <= 31) return true; // 172.16.0.0/12
if (a === 192 && b === 168) return true; // 192.168.0.0/16
if (a === 169 && b === 254) return true; // 169.254.0.0/16 (link-local)
if (a === 127) return true; // 127.0.0.0/8
if (a === 0) return true; // 0.0.0.0/8
}
if (isIP(ip) === 6) {
const lower = ip.toLowerCase();
if (lower.startsWith("fc") || lower.startsWith("fd")) return true; // ULA
if (lower.startsWith("fe80")) return true; // link-local
if (lower === "::1") return true; // loopback
}
return false;
}
/**
* Check if a URL resolves to a private/reserved IP (SSRF protection).
* Performs DNS resolution to defeat DNS rebinding attacks.
* @param {string} url - URL to check
* @returns {Promise<boolean>} True if URL targets a private network
*/
async function isPrivateResolved(url) {
try { try {
const urlObj = new URL(url); const urlObj = new URL(url);
const hostname = urlObj.hostname.toLowerCase();
// Block non-http(s) schemes // Block non-http(s) schemes
if (urlObj.protocol !== "http:" && urlObj.protocol !== "https:") { if (urlObj.protocol !== "http:" && urlObj.protocol !== "https:") {
return true; return true;
} }
// Block localhost variants const hostname = urlObj.hostname.toLowerCase().replace(/^\[|\]$/g, "");
if (hostname === "localhost" || hostname === "127.0.0.1" || hostname === "::1" || hostname === "[::1]") {
return true;
}
// Block private IPv4 ranges // Block obvious localhost variants
const ipv4Match = hostname.match(/^(\d+)\.(\d+)\.(\d+)\.(\d+)$/); if (hostname === "localhost") return true;
if (ipv4Match) {
const [, a, b] = ipv4Match.map(Number);
if (a === 10) return true; // 10.0.0.0/8
if (a === 172 && b >= 16 && b <= 31) return true; // 172.16.0.0/12
if (a === 192 && b === 168) return true; // 192.168.0.0/16
if (a === 169 && b === 254) return true; // 169.254.0.0/16 (link-local / cloud metadata)
if (a === 127) return true; // 127.0.0.0/8
if (a === 0) return true; // 0.0.0.0/8
}
// Block IPv6 private ranges (basic check) // If hostname is already an IP, check directly (no DNS needed)
if (hostname.startsWith("[fc") || hostname.startsWith("[fd") || hostname.startsWith("[fe80")) { if (isIP(hostname)) return isPrivateIP(hostname);
return true;
}
return false; // DNS resolution — check the resolved IP
const { address } = await lookup(hostname);
return isPrivateIP(address);
} catch { } catch {
return true; // Invalid URL, treat as private return true; // DNS failure or invalid URL treat as private
} }
} }
@@ -115,14 +130,14 @@ function extractLinks(html) {
/** /**
* Check if URL is likely an ActivityPub object or media file * Check if URL is likely an ActivityPub object or media file
* @param {string} url - URL to check * @param {string} url - URL to check
* @returns {boolean} True if URL should be skipped * @returns {Promise<boolean>} True if URL should be skipped
*/ */
function shouldSkipUrl(url) { async function shouldSkipUrl(url) {
try { try {
const urlObj = new URL(url); const urlObj = new URL(url);
// SSRF protection — skip private/internal URLs // SSRF protection — skip private/internal URLs
if (isPrivateUrl(url)) { if (await isPrivateResolved(url)) {
return true; return true;
} }
@@ -158,9 +173,9 @@ export async function fetchLinkPreviews(html) {
const links = extractLinks(html); const links = extractLinks(html);
// Filter links // Filter links — async because shouldSkipUrl performs DNS resolution
const urlsToFetch = links const filterResults = await Promise.all(
.filter((link) => { links.map(async (link) => {
// Skip mention links (class="mention") // Skip mention links (class="mention")
if (link.classes.includes("mention")) return false; if (link.classes.includes("mention")) return false;
@@ -168,10 +183,14 @@ export async function fetchLinkPreviews(html) {
if (link.classes.includes("hashtag")) return false; if (link.classes.includes("hashtag")) return false;
// Skip AP object URLs and media files // Skip AP object URLs and media files
if (shouldSkipUrl(link.url)) return false; if (await shouldSkipUrl(link.url)) return false;
return true; return true;
}) }),
);
const urlsToFetch = links
.filter((_, index) => filterResults[index])
.map((link) => link.url) .map((link) => link.url)
.filter((url, index, self) => self.indexOf(url) === index) // Dedupe .filter((url, index, self) => self.indexOf(url) === index) // Dedupe
.slice(0, MAX_PREVIEWS); // Cap at max .slice(0, MAX_PREVIEWS); // Cap at max

View File

@@ -3,6 +3,8 @@
* @module storage/moderation * @module storage/moderation
*/ */
import { invalidateModerationCache } from "../item-processing.js";
/** /**
* Add a muted URL or keyword * Add a muted URL or keyword
* @param {object} collections - MongoDB collections * @param {object} collections - MongoDB collections
@@ -32,6 +34,7 @@ export async function addMuted(collections, { url, keyword }) {
const filter = url ? { url } : { keyword }; const filter = url ? { url } : { keyword };
await ap_muted.updateOne(filter, { $setOnInsert: entry }, { upsert: true }); await ap_muted.updateOne(filter, { $setOnInsert: entry }, { upsert: true });
invalidateModerationCache();
return await ap_muted.findOne(filter); return await ap_muted.findOne(filter);
} }
@@ -55,7 +58,9 @@ export async function removeMuted(collections, { url, keyword }) {
throw new Error("Either url or keyword must be provided"); throw new Error("Either url or keyword must be provided");
} }
return await ap_muted.deleteOne(filter); const result = await ap_muted.deleteOne(filter);
invalidateModerationCache();
return result;
} }
/** /**
@@ -122,6 +127,7 @@ export async function addBlocked(collections, url) {
// Upsert to avoid duplicates // Upsert to avoid duplicates
await ap_blocked.updateOne({ url }, { $setOnInsert: entry }, { upsert: true }); await ap_blocked.updateOne({ url }, { $setOnInsert: entry }, { upsert: true });
invalidateModerationCache();
return await ap_blocked.findOne({ url }); return await ap_blocked.findOne({ url });
} }
@@ -133,7 +139,9 @@ export async function addBlocked(collections, url) {
*/ */
export async function removeBlocked(collections, url) { export async function removeBlocked(collections, url) {
const { ap_blocked } = collections; const { ap_blocked } = collections;
return await ap_blocked.deleteOne({ url }); const result = await ap_blocked.deleteOne({ url });
invalidateModerationCache();
return result;
} }
/** /**
@@ -204,4 +212,5 @@ export async function setFilterMode(collections, mode) {
if (!ap_profile) return; if (!ap_profile) return;
const valid = mode === "warn" ? "warn" : "hide"; const valid = mode === "warn" ? "warn" : "hide";
await ap_profile.updateOne({}, { $set: { moderationFilterMode: valid } }); await ap_profile.updateOne({}, { $set: { moderationFilterMode: valid } });
invalidateModerationCache();
} }

239
lib/syndicator.js Normal file
View File

@@ -0,0 +1,239 @@
/**
* ActivityPub syndicator — delivers posts to followers via Fedify.
* @module syndicator
*/
import {
jf2ToAS2Activity,
parseMentions,
} from "./jf2-to-as2.js";
import { lookupWithSecurity } from "./lookup-helpers.js";
import { logActivity } from "./activity-log.js";
/**
* Create the ActivityPub syndicator object.
* @param {object} plugin - ActivityPubEndpoint instance
* @returns {object} Syndicator compatible with Indiekit's syndicator API
*/
export function createSyndicator(plugin) {
return {
name: "ActivityPub syndicator",
options: { checked: plugin.options.checked },
get info() {
const hostname = plugin._publicationUrl
? new URL(plugin._publicationUrl).hostname
: "example.com";
return {
checked: plugin.options.checked,
name: `@${plugin.options.actor.handle}@${hostname}`,
uid: plugin._publicationUrl || "https://example.com/",
service: {
name: "ActivityPub (Fediverse)",
photo: "/assets/@rmdes-indiekit-endpoint-activitypub/icon.svg",
url: plugin._publicationUrl || "https://example.com/",
},
};
},
async syndicate(properties) {
if (!plugin._federation) {
return undefined;
}
try {
const actorUrl = plugin._getActorUrl();
const handle = plugin.options.actor.handle;
const ctx = plugin._federation.createContext(
new URL(plugin._publicationUrl),
{ handle, publicationUrl: plugin._publicationUrl },
);
// For replies, resolve the original post author for proper
// addressing (CC) and direct inbox delivery
let replyToActor = null;
if (properties["in-reply-to"]) {
try {
const remoteObject = await lookupWithSecurity(ctx,
new URL(properties["in-reply-to"]),
);
if (remoteObject && typeof remoteObject.getAttributedTo === "function") {
const author = await remoteObject.getAttributedTo();
const authorActor = Array.isArray(author) ? author[0] : author;
if (authorActor?.id) {
replyToActor = {
url: authorActor.id.href,
handle: authorActor.preferredUsername || null,
recipient: authorActor,
};
console.info(
`[ActivityPub] Reply to ${properties["in-reply-to"]} — resolved author: ${replyToActor.url}`,
);
}
}
} catch (error) {
console.warn(
`[ActivityPub] Could not resolve reply-to author for ${properties["in-reply-to"]}: ${error.message}`,
);
}
}
// Resolve @user@domain mentions in content via WebFinger
const contentText = properties.content?.html || properties.content || "";
const mentionHandles = parseMentions(contentText);
const resolvedMentions = [];
const mentionRecipients = [];
for (const { handle } of mentionHandles) {
try {
const mentionedActor = await lookupWithSecurity(ctx,
new URL(`acct:${handle}`),
);
if (mentionedActor?.id) {
resolvedMentions.push({
handle,
actorUrl: mentionedActor.id.href,
profileUrl: mentionedActor.url?.href || null,
});
mentionRecipients.push({
handle,
actorUrl: mentionedActor.id.href,
actor: mentionedActor,
});
console.info(
`[ActivityPub] Resolved mention @${handle}${mentionedActor.id.href}`,
);
}
} catch (error) {
console.warn(
`[ActivityPub] Could not resolve mention @${handle}: ${error.message}`,
);
// Still add with no actorUrl so it gets a fallback link
resolvedMentions.push({ handle, actorUrl: null });
}
}
const activity = jf2ToAS2Activity(
properties,
actorUrl,
plugin._publicationUrl,
{
replyToActorUrl: replyToActor?.url,
replyToActorHandle: replyToActor?.handle,
visibility: plugin.options.defaultVisibility,
mentions: resolvedMentions,
},
);
if (!activity) {
await logActivity(plugin._collections.ap_activities, {
direction: "outbound",
type: "Syndicate",
actorUrl: plugin._publicationUrl,
objectUrl: properties.url,
summary: `Syndication skipped: could not convert post to AS2`,
});
return undefined;
}
// Count followers for logging
const followerCount =
await plugin._collections.ap_followers.countDocuments();
console.info(
`[ActivityPub] Sending ${activity.constructor?.name || "activity"} for ${properties.url} to ${followerCount} followers`,
);
// Send to followers via shared inboxes with collection sync (FEP-8fcf)
await ctx.sendActivity(
{ identifier: handle },
"followers",
activity,
{
preferSharedInbox: true,
syncCollection: true,
orderingKey: properties.url,
},
);
// For replies, also deliver to the original post author's inbox
// so their server can thread the reply under the original post
if (replyToActor?.recipient) {
try {
await ctx.sendActivity(
{ identifier: handle },
replyToActor.recipient,
activity,
{ orderingKey: properties.url },
);
console.info(
`[ActivityPub] Reply delivered to author: ${replyToActor.url}`,
);
} catch (error) {
console.warn(
`[ActivityPub] Failed to deliver reply to ${replyToActor.url}: ${error.message}`,
);
}
}
// Deliver to mentioned actors' inboxes (skip reply-to author, already delivered above)
for (const { handle: mHandle, actorUrl: mUrl, actor: mActor } of mentionRecipients) {
if (replyToActor?.url === mUrl) continue;
try {
await ctx.sendActivity(
{ identifier: handle },
mActor,
activity,
{ orderingKey: properties.url },
);
console.info(
`[ActivityPub] Mention delivered to @${mHandle}: ${mUrl}`,
);
} catch (error) {
console.warn(
`[ActivityPub] Failed to deliver mention to @${mHandle}: ${error.message}`,
);
}
}
// Determine activity type name
const typeName =
activity.constructor?.name || "Create";
const replyNote = replyToActor
? ` (reply to ${replyToActor.url})`
: "";
const mentionNote = mentionRecipients.length > 0
? ` (mentions: ${mentionRecipients.map(m => `@${m.handle}`).join(", ")})`
: "";
await logActivity(plugin._collections.ap_activities, {
direction: "outbound",
type: typeName,
actorUrl: plugin._publicationUrl,
objectUrl: properties.url,
targetUrl: properties["in-reply-to"] || undefined,
summary: `Sent ${typeName} for ${properties.url} to ${followerCount} followers${replyNote}${mentionNote}`,
});
console.info(
`[ActivityPub] Syndication queued: ${typeName} for ${properties.url}${replyNote}`,
);
return properties.url || undefined;
} catch (error) {
console.error("[ActivityPub] Syndication failed:", error.message);
await logActivity(plugin._collections.ap_activities, {
direction: "outbound",
type: "Syndicate",
actorUrl: plugin._publicationUrl,
objectUrl: properties.url,
summary: `Syndication failed: ${error.message}`,
}).catch(() => {});
return undefined;
}
},
delete: async (url) => plugin.delete(url),
update: async (properties) => plugin.update(properties),
};
}

View File

@@ -28,7 +28,7 @@ export function sanitizeContent(html) {
}, },
allowedSchemes: ["http", "https", "mailto"], allowedSchemes: ["http", "https", "mailto"],
allowedSchemesByTag: { allowedSchemesByTag: {
img: ["http", "https", "data"] img: ["http", "https"]
} }
}); });
} }
@@ -46,11 +46,16 @@ export function replaceCustomEmoji(html, emojis) {
if (!emojis?.length || !html) return html; if (!emojis?.length || !html) return html;
let result = html; let result = html;
for (const { shortcode, url } of emojis) { for (const { shortcode, url } of emojis) {
// Validate URL is HTTP(S) only — reject data:, javascript:, etc.
if (!url || (!url.startsWith("https://") && !url.startsWith("http://"))) continue;
// Escape HTML special characters in URL and shortcode to prevent attribute injection
const safeUrl = url.replace(/&/g, "&amp;").replace(/"/g, "&quot;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
const safeShortcode = shortcode.replace(/&/g, "&amp;").replace(/"/g, "&quot;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
const escaped = shortcode.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); const escaped = shortcode.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const pattern = new RegExp(`:${escaped}:`, "g"); const pattern = new RegExp(`:${escaped}:`, "g");
result = result.replace( result = result.replace(
pattern, pattern,
`<img class="ap-custom-emoji" src="${url}" alt=":${shortcode}:" title=":${shortcode}:" draggable="false">`, `<img class="ap-custom-emoji" src="${safeUrl}" alt=":${safeShortcode}:" title=":${safeShortcode}:" draggable="false">`,
); );
} }
return result; return result;
@@ -347,20 +352,11 @@ export async function extractObjectData(object, options = {}) {
// Quote URL — Fedify reads quoteUrl / _misskey_quote / quoteUri // Quote URL — Fedify reads quoteUrl / _misskey_quote / quoteUri
const quoteUrl = object.quoteUrl?.href || ""; const quoteUrl = object.quoteUrl?.href || "";
// Interaction counts — extract from AP Collection objects // Interaction counts — not fetched at ingest time. The three collection
// fetches (getReplies, getLikes, getShares) each trigger an HTTP round-trip
// for counts that are ephemeral and stale moments after fetching. Removed
// per audit M11 to save 3 network calls per inbound activity.
const counts = { replies: null, boosts: null, likes: null }; const counts = { replies: null, boosts: null, likes: null };
try {
const replies = await object.getReplies?.(loaderOpts);
if (replies?.totalItems != null) counts.replies = replies.totalItems;
} catch { /* ignore — collection may not exist */ }
try {
const likes = await object.getLikes?.(loaderOpts);
if (likes?.totalItems != null) counts.likes = likes.totalItems;
} catch { /* ignore */ }
try {
const shares = await object.getShares?.(loaderOpts);
if (shares?.totalItems != null) counts.boosts = shares.totalItems;
} catch { /* ignore */ }
// Replace custom emoji :shortcode: in content with inline <img> tags. // Replace custom emoji :shortcode: in content with inline <img> tags.
// Applied after sanitization — these are trusted emoji from the post's tags. // Applied after sanitization — these are trusted emoji from the post's tags.

123
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{ {
"name": "@rmdes/indiekit-endpoint-activitypub", "name": "@rmdes/indiekit-endpoint-activitypub",
"version": "2.1.2", "version": "3.8.7",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "@rmdes/indiekit-endpoint-activitypub", "name": "@rmdes/indiekit-endpoint-activitypub",
"version": "2.1.2", "version": "3.8.7",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@fedify/debugger": "^2.0.0", "@fedify/debugger": "^2.0.0",
@@ -14,6 +14,7 @@
"@fedify/redis": "^2.0.0", "@fedify/redis": "^2.0.0",
"@js-temporal/polyfill": "^0.5.0", "@js-temporal/polyfill": "^0.5.0",
"express": "^5.0.0", "express": "^5.0.0",
"express-rate-limit": "^7.5.1",
"ioredis": "^5.9.3", "ioredis": "^5.9.3",
"sanitize-html": "^2.13.1", "sanitize-html": "^2.13.1",
"unfurl.js": "^6.4.0" "unfurl.js": "^6.4.0"
@@ -22,6 +23,7 @@
"node": ">=22" "node": ">=22"
}, },
"peerDependencies": { "peerDependencies": {
"@indiekit/endpoint-micropub": "^1.0.0-beta.25",
"@indiekit/error": "^1.0.0-beta.25", "@indiekit/error": "^1.0.0-beta.25",
"@indiekit/frontend": "^1.0.0-beta.25" "@indiekit/frontend": "^1.0.0-beta.25"
} }
@@ -1167,10 +1169,31 @@
"url": "https://opencollective.com/libvips" "url": "https://opencollective.com/libvips"
} }
}, },
"node_modules/@indiekit/endpoint-micropub": {
"version": "1.0.0-beta.27",
"resolved": "https://registry.npmjs.org/@indiekit/endpoint-micropub/-/endpoint-micropub-1.0.0-beta.27.tgz",
"integrity": "sha512-0NAiAYte5u+w3kh2dDAXbzA9b8Hujoiue59OHEen8/w1ZHyOI/Zp1ctlErrakFBqElPx6ZyfDbbrBXpaCudXbQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"@indiekit/error": "^1.0.0-beta.27",
"@indiekit/util": "^1.0.0-beta.25",
"@paulrobertlloyd/mf2tojf2": "^3.0.0",
"debug": "^4.3.2",
"express": "^5.0.0",
"lodash": "^4.17.21",
"markdown-it": "^14.0.0",
"newbase60": "^1.3.1",
"turndown": "^7.1.1"
},
"engines": {
"node": ">=20"
}
},
"node_modules/@indiekit/error": { "node_modules/@indiekit/error": {
"version": "1.0.0-beta.25", "version": "1.0.0-beta.27",
"resolved": "https://registry.npmjs.org/@indiekit/error/-/error-1.0.0-beta.25.tgz", "resolved": "https://registry.npmjs.org/@indiekit/error/-/error-1.0.0-beta.27.tgz",
"integrity": "sha512-ZDM6cyC4qPaosv4Ji1gGObSYpOlHNMqys9v428E7/XvK1qT3uW5S8mAeqGu7ErbWdMZINe0ua0fuZwBlGmSPLg==", "integrity": "sha512-Y0XIM1fptHf3i4cfxcIMqueMtqEJ6rOn2qtiYCmJcreiuG72CwaOjXtTW7CELpW/o4B0aZ9pUTEr8ef2+qvRIQ==",
"license": "MIT", "license": "MIT",
"peer": true, "peer": true,
"engines": { "engines": {
@@ -1249,6 +1272,13 @@
], ],
"license": "MIT" "license": "MIT"
}, },
"node_modules/@mixmark-io/domino": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/@mixmark-io/domino/-/domino-2.2.0.tgz",
"integrity": "sha512-Y28PR25bHXUg88kCV7nivXrP2Nj2RueZ3/l/jdx6J9f8J4nsEGcgX0Qe6lt7Pa+J79+kPiJU3LguR6O/6zrLOw==",
"license": "BSD-2-Clause",
"peer": true
},
"node_modules/@mongodb-js/saslprep": { "node_modules/@mongodb-js/saslprep": {
"version": "1.4.6", "version": "1.4.6",
"resolved": "https://registry.npmjs.org/@mongodb-js/saslprep/-/saslprep-1.4.6.tgz", "resolved": "https://registry.npmjs.org/@mongodb-js/saslprep/-/saslprep-1.4.6.tgz",
@@ -1355,6 +1385,19 @@
"node": ">=14" "node": ">=14"
} }
}, },
"node_modules/@paulrobertlloyd/mf2tojf2": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/@paulrobertlloyd/mf2tojf2/-/mf2tojf2-3.0.0.tgz",
"integrity": "sha512-R94UVfQ1RrJSvVEco7jk3yeACLCtEixLm6sPnBNjEPpvYr9IitOh9xSFWTT5eFSjT9qYEpBz9SYv3N/g7LK3Dg==",
"license": "MIT",
"peer": true,
"dependencies": {
"microformats-parser": "^2.0.0"
},
"engines": {
"node": ">=22"
}
},
"node_modules/@sindresorhus/slugify": { "node_modules/@sindresorhus/slugify": {
"version": "3.0.0", "version": "3.0.0",
"resolved": "https://registry.npmjs.org/@sindresorhus/slugify/-/slugify-3.0.0.tgz", "resolved": "https://registry.npmjs.org/@sindresorhus/slugify/-/slugify-3.0.0.tgz",
@@ -2046,6 +2089,21 @@
"url": "https://opencollective.com/express" "url": "https://opencollective.com/express"
} }
}, },
"node_modules/express-rate-limit": {
"version": "7.5.1",
"resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-7.5.1.tgz",
"integrity": "sha512-7iN8iPMDzOMHPUYllBEsQdWVB6fPDMPqwjBaFrgr4Jgr/+okjvzAy+UHlYYL/Vs0OsOrMkwS6PJDkFlJwoxUnw==",
"license": "MIT",
"engines": {
"node": ">= 16"
},
"funding": {
"url": "https://github.com/sponsors/express-rate-limit"
},
"peerDependencies": {
"express": ">= 4.11"
}
},
"node_modules/finalhandler": { "node_modules/finalhandler": {
"version": "2.1.1", "version": "2.1.1",
"resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz",
@@ -2770,6 +2828,19 @@
"url": "https://github.com/sponsors/sindresorhus" "url": "https://github.com/sponsors/sindresorhus"
} }
}, },
"node_modules/microformats-parser": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/microformats-parser/-/microformats-parser-2.0.4.tgz",
"integrity": "sha512-DA2yt3uz2JjupBGoNvaG9ngBP5vSTI1ky2yhxBai/RnQrlzo+gEzuCdvwIIjj2nh3uVPDybTP5u7uua7pOa6LA==",
"license": "MIT",
"peer": true,
"dependencies": {
"parse5": "^7.1.2"
},
"engines": {
"node": ">=18"
}
},
"node_modules/mime-db": { "node_modules/mime-db": {
"version": "1.54.0", "version": "1.54.0",
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz",
@@ -2943,6 +3014,12 @@
"node": ">= 0.6" "node": ">= 0.6"
} }
}, },
"node_modules/newbase60": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/newbase60/-/newbase60-1.3.1.tgz",
"integrity": "sha512-2bjwvv8ytc4YQXXnV7lSz7yzQv01eYcdhhX/lo3OWkXgRSxfbbQb922s+6uiC4i5HbNlNu8Vtu9mSZ/xKRaTkg==",
"peer": true
},
"node_modules/node-fetch": { "node_modules/node-fetch": {
"version": "2.7.0", "version": "2.7.0",
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz",
@@ -3028,6 +3105,32 @@
"integrity": "sha512-/2qh0lav6CmI15FzA3i/2Bzk2zCgQhGMkvhOhKNcBVQ1ldgpbfiNTVslmooUmWJcADi1f1kIeynbDRVzNlfR6Q==", "integrity": "sha512-/2qh0lav6CmI15FzA3i/2Bzk2zCgQhGMkvhOhKNcBVQ1ldgpbfiNTVslmooUmWJcADi1f1kIeynbDRVzNlfR6Q==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/parse5": {
"version": "7.3.0",
"resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz",
"integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==",
"license": "MIT",
"peer": true,
"dependencies": {
"entities": "^6.0.0"
},
"funding": {
"url": "https://github.com/inikulin/parse5?sponsor=1"
}
},
"node_modules/parse5/node_modules/entities": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz",
"integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==",
"license": "BSD-2-Clause",
"peer": true,
"engines": {
"node": ">=0.12"
},
"funding": {
"url": "https://github.com/fb55/entities?sponsor=1"
}
},
"node_modules/parseurl": { "node_modules/parseurl": {
"version": "1.3.3", "version": "1.3.3",
"resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
@@ -3515,6 +3618,16 @@
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"license": "0BSD" "license": "0BSD"
}, },
"node_modules/turndown": {
"version": "7.2.2",
"resolved": "https://registry.npmjs.org/turndown/-/turndown-7.2.2.tgz",
"integrity": "sha512-1F7db8BiExOKxjSMU2b7if62D/XOyQyZbPKq/nUwopfgnHlqXHqQ0lvfUTeUIr1lZJzOPFn43dODyMSIfvWRKQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"@mixmark-io/domino": "^2.2.0"
}
},
"node_modules/type-is": { "node_modules/type-is": {
"version": "2.0.1", "version": "2.0.1",
"resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz", "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz",

View File

@@ -1,6 +1,6 @@
{ {
"name": "@rmdes/indiekit-endpoint-activitypub", "name": "@rmdes/indiekit-endpoint-activitypub",
"version": "3.8.5", "version": "3.8.7",
"description": "ActivityPub federation endpoint for Indiekit via Fedify. Adds full fediverse support: actor, inbox, outbox, followers, following, syndication, and Mastodon migration.", "description": "ActivityPub federation endpoint for Indiekit via Fedify. Adds full fediverse support: actor, inbox, outbox, followers, following, syndication, and Mastodon migration.",
"keywords": [ "keywords": [
"indiekit", "indiekit",
@@ -42,6 +42,7 @@
"@fedify/redis": "^2.0.0", "@fedify/redis": "^2.0.0",
"@js-temporal/polyfill": "^0.5.0", "@js-temporal/polyfill": "^0.5.0",
"express": "^5.0.0", "express": "^5.0.0",
"express-rate-limit": "^7.5.1",
"ioredis": "^5.9.3", "ioredis": "^5.9.3",
"sanitize-html": "^2.13.1", "sanitize-html": "^2.13.1",
"unfurl.js": "^6.4.0" "unfurl.js": "^6.4.0"

View File

@@ -34,7 +34,7 @@
</a> </a>
{% endif %} {% endif %}
</div> </div>
<p x-show="actionResult" x-text="actionResult" class="ap-federation__result" x-cloak></p> <p x-show="actionResult" x-text="actionResult" class="ap-federation__result" role="status" x-cloak></p>
</section> </section>
{# --- Object Lookup --- #} {# --- Object Lookup --- #}
@@ -49,7 +49,7 @@
<span x-show="lookupLoading" x-cloak>{{ __("activitypub.federationMgmt.lookupLoading") }}</span> <span x-show="lookupLoading" x-cloak>{{ __("activitypub.federationMgmt.lookupLoading") }}</span>
</button> </button>
</form> </form>
<p x-show="lookupError" x-text="lookupError" class="ap-federation__error" x-cloak></p> <p x-show="lookupError" x-text="lookupError" class="ap-federation__error" role="alert" x-cloak></p>
<pre x-show="lookupResult" x-text="lookupResult" class="ap-federation__json-view" x-cloak></pre> <pre x-show="lookupResult" x-text="lookupResult" class="ap-federation__json-view" x-cloak></pre>
</section> </section>

View File

@@ -131,7 +131,7 @@
{{ __("activitypub.moderation.addKeyword") }} {{ __("activitypub.moderation.addKeyword") }}
</button> </button>
</form> </form>
<p x-show="error" x-text="error" class="ap-moderation__error" x-cloak></p> <p x-show="error" x-text="error" class="ap-moderation__error" role="alert" x-cloak></p>
</section> </section>
</div> </div>

View File

@@ -75,29 +75,25 @@
values: [profile.actorType or "Person"] values: [profile.actorType or "Person"]
}) }} }) }}
<fieldset class="fieldset" style="margin-block-end: var(--space-l);"> <fieldset class="fieldset" style="margin-block-end: var(--space-l);" x-data="{ links: [{% if profile.attachments and profile.attachments.length > 0 %}{% for att in profile.attachments %}{ name: {{ att.name | dump | safe }}, value: {{ att.value | dump | safe }} }{% if not loop.last %},{% endif %}{% endfor %}{% endif %}] }">
<legend class="label">{{ __("activitypub.profile.linksLabel") }}</legend> <legend class="label">{{ __("activitypub.profile.linksLabel") }}</legend>
<p class="hint">{{ __("activitypub.profile.linksHint") }}</p> <p class="hint">{{ __("activitypub.profile.linksHint") }}</p>
<div id="profile-links"> <template x-for="(link, index) in links" :key="index">
{% if profile.attachments and profile.attachments.length > 0 %} <div class="profile-link-row" style="display: grid; grid-template-columns: 1fr 2fr auto; gap: var(--space-s); align-items: end; margin-block-end: var(--space-s);">
{% for att in profile.attachments %} <div>
<div class="profile-link-row" style="display: grid; grid-template-columns: 1fr 2fr auto; gap: var(--space-s); align-items: end; margin-block-end: var(--space-s);"> <label class="label">{{ __("activitypub.profile.linkNameLabel") }}</label>
<div> <input class="input" type="text" :name="'link_name[]'" x-model="link.name" placeholder="Website">
<label class="label" for="link_name_{{ loop.index }}">{{ __("activitypub.profile.linkNameLabel") }}</label> </div>
<input class="input" type="text" id="link_name_{{ loop.index }}" name="link_name[]" value="{{ att.name }}" placeholder="Website"> <div>
</div> <label class="label">{{ __("activitypub.profile.linkValueLabel") }}</label>
<div> <input class="input" type="url" :name="'link_value[]'" x-model="link.value" placeholder="https://example.com">
<label class="label" for="link_value_{{ loop.index }}">{{ __("activitypub.profile.linkValueLabel") }}</label> </div>
<input class="input" type="url" id="link_value_{{ loop.index }}" name="link_value[]" value="{{ att.value }}" placeholder="https://example.com"> <button type="button" class="button button--small profile-link-remove" style="margin-block-end: 4px;" @click="links.splice(index, 1)">{{ __("activitypub.profile.removeLink") }}</button>
</div> </div>
<button type="button" class="button button--small profile-link-remove" style="margin-block-end: 4px;">{{ __("activitypub.profile.removeLink") }}</button> </template>
</div>
{% endfor %}
{% endif %}
</div>
<button type="button" class="button button--small" id="add-link-btn">{{ __("activitypub.profile.addLink") }}</button> <button type="button" class="button button--small" @click="links.push({ name: '', value: '' })">{{ __("activitypub.profile.addLink") }}</button>
</fieldset> </fieldset>
{{ checkboxes({ {{ checkboxes({
@@ -127,60 +123,4 @@
{{ button({ text: __("activitypub.profile.save") }) }} {{ button({ text: __("activitypub.profile.save") }) }}
</form> </form>
<script>
(function() {
document.getElementById('profile-links').addEventListener('click', function(e) {
var btn = e.target.closest('.profile-link-remove');
if (btn) btn.closest('.profile-link-row').remove();
});
var linkCount = {{ (profile.attachments.length if profile.attachments) or 0 }};
document.getElementById('add-link-btn').addEventListener('click', function() {
linkCount++;
var container = document.getElementById('profile-links');
var row = document.createElement('div');
row.className = 'profile-link-row';
row.style.cssText = 'display: grid; grid-template-columns: 1fr 2fr auto; gap: var(--space-s); align-items: end; margin-block-end: var(--space-s);';
var nameDiv = document.createElement('div');
var nameLabel = document.createElement('label');
nameLabel.className = 'label';
nameLabel.setAttribute('for', 'link_name_' + linkCount);
nameLabel.textContent = 'Label';
var nameInput = document.createElement('input');
nameInput.className = 'input';
nameInput.type = 'text';
nameInput.id = 'link_name_' + linkCount;
nameInput.name = 'link_name[]';
nameInput.placeholder = 'Website';
nameDiv.appendChild(nameLabel);
nameDiv.appendChild(nameInput);
var valueDiv = document.createElement('div');
var valueLabel = document.createElement('label');
valueLabel.className = 'label';
valueLabel.setAttribute('for', 'link_value_' + linkCount);
valueLabel.textContent = 'URL';
var valueInput = document.createElement('input');
valueInput.className = 'input';
valueInput.type = 'url';
valueInput.id = 'link_value_' + linkCount;
valueInput.name = 'link_value[]';
valueInput.placeholder = 'https://example.com';
valueDiv.appendChild(valueLabel);
valueDiv.appendChild(valueInput);
var removeBtn = document.createElement('button');
removeBtn.type = 'button';
removeBtn.className = 'button button--small profile-link-remove';
removeBtn.style.cssText = 'margin-block-end: 4px;';
removeBtn.textContent = 'Remove';
row.appendChild(nameDiv);
row.appendChild(valueDiv);
row.appendChild(removeBtn);
container.appendChild(row);
});
})();
</script>
{% endblock %} {% endblock %}

View File

@@ -98,6 +98,7 @@
data-tab="{{ tab }}" data-tab="{{ tab }}"
data-mount-path="{{ mountPath }}" data-mount-path="{{ mountPath }}"
x-show="count > 0" x-show="count > 0"
role="status"
x-cloak> x-cloak>
<button class="ap-new-posts-banner__btn" @click="loadNew()"> <button class="ap-new-posts-banner__btn" @click="loadNew()">
<span x-text="count + ' new post' + (count !== 1 ? 's' : '')"></span> — Load <span x-text="count + ' new post' + (count !== 1 ? 's' : '')"></span> — Load
@@ -152,7 +153,7 @@
<button class="ap-load-more__btn" @click="loadMore()" :disabled="loading" x-show="!done && !loading"> <button class="ap-load-more__btn" @click="loadMore()" :disabled="loading" x-show="!done && !loading">
{{ __("activitypub.reader.pagination.loadMore") }} {{ __("activitypub.reader.pagination.loadMore") }}
</button> </button>
<div class="ap-skeleton-group" x-show="loading" x-cloak> <div class="ap-skeleton-group" x-show="loading" aria-live="polite" x-cloak>
{% include "partials/ap-skeleton-card.njk" %} {% include "partials/ap-skeleton-card.njk" %}
{% include "partials/ap-skeleton-card.njk" %} {% include "partials/ap-skeleton-card.njk" %}
{% include "partials/ap-skeleton-card.njk" %} {% include "partials/ap-skeleton-card.njk" %}

View File

@@ -3,6 +3,8 @@
{% block content %} {% block content %}
{# Infinite scroll component — must load before Alpine to register via alpine:init #} {# Infinite scroll component — must load before Alpine to register via alpine:init #}
<script defer src="/assets/@rmdes-indiekit-endpoint-activitypub/reader-infinite-scroll.js"></script> <script defer src="/assets/@rmdes-indiekit-endpoint-activitypub/reader-infinite-scroll.js"></script>
{# Card interaction component — apCardInteraction Alpine component #}
<script defer src="/assets/@rmdes-indiekit-endpoint-activitypub/reader-interactions.js"></script>
{# Autocomplete components for explore + popular accounts #} {# Autocomplete components for explore + popular accounts #}
<script defer src="/assets/@rmdes-indiekit-endpoint-activitypub/reader-autocomplete.js"></script> <script defer src="/assets/@rmdes-indiekit-endpoint-activitypub/reader-autocomplete.js"></script>
{# Tab components — apExploreTabs #} {# Tab components — apExploreTabs #}

View File

@@ -26,14 +26,14 @@
{# Boost header if this is a boosted post #} {# Boost header if this is a boosted post #}
{% if item.type == "boost" and item.boostedBy %} {% if item.type == "boost" and item.boostedBy %}
<div class="ap-card__boost"> <div class="ap-card__boost">
🔁 {% if item.boostedBy.url %}<a href="{{ mountPath }}/admin/reader/profile?url={{ item.boostedBy.url | urlencode }}">{% if item.boostedBy.nameHtml %}{{ item.boostedBy.nameHtml | safe }}{% else %}{{ item.boostedBy.name or "Someone" }}{% endif %}</a>{% else %}{% if item.boostedBy.nameHtml %}{{ item.boostedBy.nameHtml | safe }}{% else %}{{ item.boostedBy.name or "Someone" }}{% endif %}{% endif %} {{ __("activitypub.reader.boosted") }} <span aria-hidden="true">🔁</span><span class="visually-hidden">{{ __("activitypub.reader.boosted") }}</span> {% if item.boostedBy.url %}<a href="{{ mountPath }}/admin/reader/profile?url={{ item.boostedBy.url | urlencode }}">{% if item.boostedBy.nameHtml %}{{ item.boostedBy.nameHtml | safe }}{% else %}{{ item.boostedBy.name or "Someone" }}{% endif %}</a>{% else %}{% if item.boostedBy.nameHtml %}{{ item.boostedBy.nameHtml | safe }}{% else %}{{ item.boostedBy.name or "Someone" }}{% endif %}{% endif %} {{ __("activitypub.reader.boosted") }}
</div> </div>
{% endif %} {% endif %}
{# Reply context if this is a reply #} {# Reply context if this is a reply #}
{% if item.inReplyTo %} {% if item.inReplyTo %}
<div class="ap-card__reply-to"> <div class="ap-card__reply-to">
{{ __("activitypub.reader.replyingTo") }} <a href="{{ mountPath }}/admin/reader/post?url={{ item.inReplyTo | urlencode }}">{{ item.inReplyTo }}</a> <span aria-hidden="true">↩</span> {{ __("activitypub.reader.replyingTo") }} <a href="{{ mountPath }}/admin/reader/post?url={{ item.inReplyTo | urlencode }}">{{ item.inReplyTo }}</a>
</div> </div>
{% endif %} {% endif %}
@@ -63,10 +63,10 @@
<time datetime="{{ item.published }}" class="ap-card__timestamp" x-data x-relative-time> <time datetime="{{ item.published }}" class="ap-card__timestamp" x-data x-relative-time>
{{ item.published | date("PPp") }} {{ item.published | date("PPp") }}
</time> </time>
{% if item.updated %}<span class="ap-card__edited" title="{{ item.updated | date('PPp') }}">✏️</span>{% endif %} {% if item.updated %}<span class="ap-card__edited" title="{{ item.updated | date('PPp') }}" aria-label="Edited"><span aria-hidden="true">✏️</span></span>{% endif %}
</a> </a>
{% if item.visibility and item.visibility != "public" %} {% if item.visibility and item.visibility != "public" %}
<span class="ap-card__visibility ap-card__visibility--{{ item.visibility }}" title="{% if item.visibility == 'unlisted' %}{{ __('activitypub.reader.compose.visibilityUnlisted') }}{% elif item.visibility == 'private' %}{{ __('activitypub.reader.compose.visibilityFollowers') }}{% elif item.visibility == 'direct' %}DM{% endif %}">{% if item.visibility == "unlisted" %}🔓{% elif item.visibility == "private" %}🔒{% elif item.visibility == "direct" %}✉️{% endif %}</span> <span class="ap-card__visibility ap-card__visibility--{{ item.visibility }}" title="{% if item.visibility == 'unlisted' %}{{ __('activitypub.reader.compose.visibilityUnlisted') }}{% elif item.visibility == 'private' %}{{ __('activitypub.reader.compose.visibilityFollowers') }}{% elif item.visibility == 'direct' %}DM{% endif %}" aria-label="{% if item.visibility == 'unlisted' %}{{ __('activitypub.reader.compose.visibilityUnlisted') }}{% elif item.visibility == 'private' %}{{ __('activitypub.reader.compose.visibilityFollowers') }}{% elif item.visibility == 'direct' %}DM{% endif %}"><span aria-hidden="true">{% if item.visibility == "unlisted" %}🔓{% elif item.visibility == "private" %}🔒{% elif item.visibility == "direct" %}✉️{% endif %}</span></span>
{% endif %} {% endif %}
{% endif %} {% endif %}
</header> </header>
@@ -89,48 +89,11 @@
<span x-show="shown" x-cloak>{{ __("activitypub.reader.hideContent") }}</span> <span x-show="shown" x-cloak>{{ __("activitypub.reader.hideContent") }}</span>
</button> </button>
<div x-show="shown" x-cloak> <div x-show="shown" x-cloak>
{% if item.content and item.content.html %} {% include "partials/ap-item-content.njk" %}
<div class="ap-card__content">
{{ item.content.html | safe }}
</div>
{% endif %}
{# Quoted post embed #}
{% include "partials/ap-quote-embed.njk" %}
{# Link previews #}
{% include "partials/ap-link-preview.njk" %}
{# Media hidden behind CW #}
{% include "partials/ap-item-media.njk" %}
{# Poll options #}
{% if item.type == "question" or (item.pollOptions and item.pollOptions.length > 0) %}
{% include "partials/ap-poll-options.njk" %}
{% endif %}
</div> </div>
</div> </div>
{% else %} {% else %}
{# Regular content (no CW) #} {% include "partials/ap-item-content.njk" %}
{% if item.content and item.content.html %}
<div class="ap-card__content">
{{ item.content.html | safe }}
</div>
{% endif %}
{# Quoted post embed #}
{% include "partials/ap-quote-embed.njk" %}
{# Link previews #}
{% include "partials/ap-link-preview.njk" %}
{# Media visible directly #}
{% include "partials/ap-item-media.njk" %}
{# Poll options #}
{% if item.type == "question" or (item.pollOptions and item.pollOptions.length > 0) %}
{% include "partials/ap-poll-options.njk" %}
{% endif %}
{% endif %} {% endif %}
{# Mentions and hashtags #} {# Mentions and hashtags #}
@@ -171,77 +134,11 @@
data-item-url="{{ itemUrl }}" data-item-url="{{ itemUrl }}"
data-csrf-token="{{ csrfToken }}" data-csrf-token="{{ csrfToken }}"
data-mount-path="{{ mountPath }}" data-mount-path="{{ mountPath }}"
x-data="{ data-liked="{{ 'true' if isLiked else 'false' }}"
liked: {{ 'true' if isLiked else 'false' }}, data-boosted="{{ 'true' if isBoosted else 'false' }}"
boosted: {{ 'true' if isBoosted else 'false' }}, data-like-count="{{ likeCount if likeCount != null else '' }}"
saved: false, data-boost-count="{{ boostCount if boostCount != null else '' }}"
loading: false, x-data="apCardInteraction()">
error: '',
boostCount: {{ boostCount if boostCount != null else 'null' }},
likeCount: {{ likeCount if likeCount != null else 'null' }},
async saveLater() {
if (this.saved) return;
const el = this.$root;
const itemUrl = el.dataset.itemUrl;
try {
const res = await fetch('/readlater/save', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
url: itemUrl,
title: el.closest('article')?.querySelector('p')?.textContent?.substring(0, 80) || itemUrl,
source: 'activitypub'
}),
credentials: 'same-origin'
});
if (res.ok) this.saved = true;
else this.error = 'Failed to save';
} catch (e) {
this.error = e.message;
}
if (this.error) setTimeout(() => this.error = '', 3000);
},
async interact(action) {
if (this.loading) return;
this.loading = true;
this.error = '';
const el = this.$root;
const itemUid = el.dataset.itemUid;
const csrfToken = el.dataset.csrfToken;
const basePath = el.dataset.mountPath;
const prev = { liked: this.liked, boosted: this.boosted, boostCount: this.boostCount, likeCount: this.likeCount };
if (action === 'like') { this.liked = true; if (this.likeCount !== null) this.likeCount++; }
else if (action === 'unlike') { this.liked = false; if (this.likeCount !== null && this.likeCount > 0) this.likeCount--; }
else if (action === 'boost') { this.boosted = true; if (this.boostCount !== null) this.boostCount++; }
else if (action === 'unboost') { this.boosted = false; if (this.boostCount !== null && this.boostCount > 0) this.boostCount--; }
try {
const res = await fetch(basePath + '/admin/reader/' + action, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': csrfToken
},
body: JSON.stringify({ url: itemUid })
});
const data = await res.json();
if (!data.success) {
this.liked = prev.liked;
this.boosted = prev.boosted;
this.boostCount = prev.boostCount;
this.likeCount = prev.likeCount;
this.error = data.error || 'Failed';
}
} catch (e) {
this.liked = prev.liked;
this.boosted = prev.boosted;
this.boostCount = prev.boostCount;
this.likeCount = prev.likeCount;
this.error = e.message;
}
this.loading = false;
if (this.error) setTimeout(() => this.error = '', 3000);
}
}">
<a href="{{ mountPath }}/admin/reader/compose?replyTo={{ (itemUrl or itemUid) | urlencode }}" <a href="{{ mountPath }}/admin/reader/compose?replyTo={{ (itemUrl or itemUid) | urlencode }}"
class="ap-card__action ap-card__action--reply" class="ap-card__action ap-card__action--reply"
title="{{ __('activitypub.reader.actions.reply') }}"> title="{{ __('activitypub.reader.actions.reply') }}">
@@ -252,7 +149,7 @@
:title="boosted ? '{{ __('activitypub.reader.actions.unboost') }}' : '{{ __('activitypub.reader.actions.boost') }}'" :title="boosted ? '{{ __('activitypub.reader.actions.unboost') }}' : '{{ __('activitypub.reader.actions.boost') }}'"
:disabled="loading" :disabled="loading"
@click="interact(boosted ? 'unboost' : 'boost')"> @click="interact(boosted ? 'unboost' : 'boost')">
🔁 <span x-text="boosted ? '{{ __('activitypub.reader.actions.boosted') }}' : '{{ __('activitypub.reader.actions.boost') }}'"></span><template x-if="boostCount !== null"><span class="ap-card__count" x-text="boostCount"></span></template> <span aria-hidden="true">🔁</span> <span x-text="boosted ? '{{ __('activitypub.reader.actions.boosted') }}' : '{{ __('activitypub.reader.actions.boost') }}'"></span><template x-if="boostCount !== null"><span class="ap-card__count" x-text="boostCount"></span></template>
</button> </button>
<button class="ap-card__action ap-card__action--like" <button class="ap-card__action ap-card__action--like"
:class="{ 'ap-card__action--active': liked }" :class="{ 'ap-card__action--active': liked }"
@@ -263,7 +160,7 @@
<span x-text="liked ? '{{ __('activitypub.reader.actions.liked') }}' : '{{ __('activitypub.reader.actions.like') }}'"></span><template x-if="likeCount !== null"><span class="ap-card__count" x-text="likeCount"></span></template> <span x-text="liked ? '{{ __('activitypub.reader.actions.liked') }}' : '{{ __('activitypub.reader.actions.like') }}'"></span><template x-if="likeCount !== null"><span class="ap-card__count" x-text="likeCount"></span></template>
</button> </button>
<a href="{{ itemUrl }}" class="ap-card__action ap-card__action--link" target="_blank" rel="noopener"> <a href="{{ itemUrl }}" class="ap-card__action ap-card__action--link" target="_blank" rel="noopener">
🔗 {{ __("activitypub.reader.actions.viewOriginal") }} <span aria-hidden="true">🔗</span> {{ __("activitypub.reader.actions.viewOriginal") }}
</a> </a>
{% if application.readlaterEndpoint %} {% if application.readlaterEndpoint %}
<button class="ap-card__action ap-card__action--save" <button class="ap-card__action ap-card__action--save"
@@ -275,7 +172,7 @@
<span x-text="saved ? 'Saved' : 'Save'"></span> <span x-text="saved ? 'Saved' : 'Save'"></span>
</button> </button>
{% endif %} {% endif %}
<div x-show="error" x-text="error" class="ap-card__action-error" x-transition></div> <div x-show="error" x-text="error" class="ap-card__action-error" role="alert" x-transition></div>
</footer> </footer>
{# Close moderation content warning wrapper #} {# Close moderation content warning wrapper #}
{% if item._moderated %} {% if item._moderated %}

View File

@@ -0,0 +1,20 @@
{# Shared content rendering — included in both CW and non-CW paths #}
{% if item.content and item.content.html %}
<div class="ap-card__content">
{{ item.content.html | safe }}
</div>
{% endif %}
{# Quoted post embed #}
{% include "partials/ap-quote-embed.njk" %}
{# Link previews #}
{% include "partials/ap-link-preview.njk" %}
{# Media attachments #}
{% include "partials/ap-item-media.njk" %}
{# Poll options #}
{% if item.type == "question" or (item.pollOptions and item.pollOptions.length > 0) %}
{% include "partials/ap-poll-options.njk" %}
{% endif %}

View File

@@ -14,7 +14,7 @@
<img src="{{ item.actorPhoto }}" alt="{{ item.actorName }}" class="ap-notification__avatar" loading="lazy" crossorigin="anonymous"> <img src="{{ item.actorPhoto }}" alt="{{ item.actorName }}" class="ap-notification__avatar" loading="lazy" crossorigin="anonymous">
{% endif %} {% endif %}
<span class="ap-notification__avatar ap-notification__avatar--default" aria-hidden="true">{{ item.actorName[0] | upper if item.actorName else "?" }}</span> <span class="ap-notification__avatar ap-notification__avatar--default" aria-hidden="true">{{ item.actorName[0] | upper if item.actorName else "?" }}</span>
<span class="ap-notification__type-badge"> <span class="ap-notification__type-badge" aria-hidden="true">
{% if item.type == "like" %}❤{% elif item.type == "boost" %}🔁{% elif item.type == "follow" or item.type == "follow_request" %}👤{% elif item.type == "reply" %}💬{% elif item.type == "mention" %}@{% elif item.type == "dm" %}✉{% elif item.type == "report" %}⚑{% endif %} {% if item.type == "like" %}❤{% elif item.type == "boost" %}🔁{% elif item.type == "follow" or item.type == "follow_request" %}👤{% elif item.type == "reply" %}💬{% elif item.type == "mention" %}@{% elif item.type == "dm" %}✉{% elif item.type == "report" %}⚑{% endif %}
</span> </span>
</div> </div>