mirror of
https://github.com/svemagie/indiekit-endpoint-activitypub.git
synced 2026-04-02 15:44:58 +02:00
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:
2
.gitignore
vendored
2
.gitignore
vendored
@@ -1 +1,3 @@
|
|||||||
node_modules
|
node_modules
|
||||||
|
.playwright-cli/
|
||||||
|
.playwright-mcp/
|
||||||
|
|||||||
31
CLAUDE.md
31
CLAUDE.md
@@ -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
144
assets/css/base.css
Normal 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
377
assets/css/card.css
Normal 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
169
assets/css/compose.css
Normal 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
94
assets/css/dark-mode.css
Normal 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
530
assets/css/explore.css
Normal 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
436
assets/css/features.css
Normal 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
242
assets/css/federation.css
Normal 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
236
assets/css/interactions.css
Normal 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
315
assets/css/media.css
Normal 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
158
assets/css/messages.css
Normal 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
119
assets/css/moderation.css
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
/* ==========================================================================
|
||||||
|
Moderation
|
||||||
|
========================================================================== */
|
||||||
|
|
||||||
|
.ap-moderation__section {
|
||||||
|
margin-bottom: var(--space-l);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ap-moderation__section h2 {
|
||||||
|
font-size: var(--font-size-l);
|
||||||
|
margin-bottom: var(--space-s);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ap-moderation__list {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ap-moderation__entry {
|
||||||
|
align-items: center;
|
||||||
|
border-bottom: var(--border-width-thin) solid var(--color-outline);
|
||||||
|
display: flex;
|
||||||
|
gap: var(--space-s);
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: var(--space-s) 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ap-moderation__entry a {
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ap-moderation__remove {
|
||||||
|
background: transparent;
|
||||||
|
border: var(--border-width-thin) solid var(--color-outline);
|
||||||
|
border-radius: var(--border-radius-small);
|
||||||
|
color: var(--color-on-offset);
|
||||||
|
cursor: pointer;
|
||||||
|
flex-shrink: 0;
|
||||||
|
font-size: var(--font-size-s);
|
||||||
|
padding: var(--space-xs) var(--space-s);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ap-moderation__remove:hover {
|
||||||
|
border-color: var(--color-error);
|
||||||
|
color: var(--color-error);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ap-moderation__add-form {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--space-s);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ap-moderation__input {
|
||||||
|
background: var(--color-background);
|
||||||
|
border: var(--border-width-thick) solid var(--color-outline);
|
||||||
|
border-radius: var(--border-radius-small);
|
||||||
|
color: var(--color-on-background);
|
||||||
|
flex: 1;
|
||||||
|
font-size: var(--font-size-m);
|
||||||
|
padding: var(--space-xs) var(--space-s);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ap-moderation__add-btn {
|
||||||
|
background: var(--color-offset);
|
||||||
|
border: var(--border-width-thin) solid var(--color-outline);
|
||||||
|
border-radius: var(--border-radius-small);
|
||||||
|
color: var(--color-on-background);
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: var(--font-size-m);
|
||||||
|
padding: var(--space-xs) var(--space-m);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ap-moderation__add-btn:hover {
|
||||||
|
background: var(--color-offset-variant);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ap-moderation__add-btn:disabled {
|
||||||
|
cursor: not-allowed;
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ap-moderation__error {
|
||||||
|
color: var(--color-error);
|
||||||
|
font-size: var(--font-size-s);
|
||||||
|
margin-top: var(--space-xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ap-moderation__empty {
|
||||||
|
color: var(--color-on-offset);
|
||||||
|
font-size: var(--font-size-s);
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ap-moderation__hint {
|
||||||
|
color: var(--color-on-offset);
|
||||||
|
font-size: var(--font-size-s);
|
||||||
|
margin-bottom: var(--space-s);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ap-moderation__filter-toggle {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--space-m);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ap-moderation__radio {
|
||||||
|
align-items: center;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
gap: var(--space-xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ap-moderation__radio input {
|
||||||
|
accent-color: var(--color-primary);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
191
assets/css/notifications.css
Normal file
191
assets/css/notifications.css
Normal 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
308
assets/css/profile.css
Normal 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
33
assets/css/responsive.css
Normal 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
74
assets/css/skeleton.css
vendored
Normal 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);
|
||||||
|
}
|
||||||
115
assets/reader-interactions.js
Normal file
115
assets/reader-interactions.js
Normal 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);
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
});
|
||||||
3459
assets/reader.css
3459
assets/reader.css
File diff suppressed because it is too large
Load Diff
728
index.js
728
index.js
@@ -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
98
lib/batch-broadcast.js
Normal 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(() => {});
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
@@ -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
70
lib/federation-actions.js
Normal 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;
|
||||||
|
}
|
||||||
@@ -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
251
lib/init-indexes.js
Normal 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.
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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, "&")
|
|
||||||
.replace(/"/g, """)
|
|
||||||
.replace(/</g, "<")
|
|
||||||
.replace(/>/g, ">");
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 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();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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(() => {});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
@@ -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" });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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, "&")
|
||||||
|
.replace(/</g, "<")
|
||||||
|
.replace(/>/g, ">")
|
||||||
|
.replace(/"/g, """)
|
||||||
|
.replace(/'/g, "'");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hash a client secret for secure storage.
|
||||||
|
* @param {string} secret - Plaintext secret
|
||||||
|
* @returns {string} SHA-256 hex hash
|
||||||
|
*/
|
||||||
|
function hashSecret(secret) {
|
||||||
|
return crypto.createHash("sha256").update(secret).digest("hex");
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Parse redirect_uris from request — accepts space-separated string or array.
|
* 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>
|
||||||
|
|||||||
@@ -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")}`;
|
||||||
|
|||||||
@@ -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" });
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
239
lib/syndicator.js
Normal 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),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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, "&").replace(/"/g, """).replace(/</g, "<").replace(/>/g, ">");
|
||||||
|
const safeShortcode = shortcode.replace(/&/g, "&").replace(/"/g, """).replace(/</g, "<").replace(/>/g, ">");
|
||||||
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
123
package-lock.json
generated
@@ -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",
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 %}
|
||||||
|
|||||||
@@ -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" %}
|
||||||
|
|||||||
@@ -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 #}
|
||||||
|
|||||||
@@ -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 %}
|
||||||
|
|||||||
20
views/partials/ap-item-content.njk
Normal file
20
views/partials/ap-item-content.njk
Normal 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 %}
|
||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user