mirror of
https://github.com/svemagie/indiekit-endpoint-activitypub.git
synced 2026-04-02 15:44:58 +02:00
feat: ActivityPub reader — timeline, notifications, compose, moderation
Add a dedicated fediverse reader view with: - Timeline view showing posts from followed accounts with threading, content warnings, boosts, and media display - Compose form with dual-path posting (quick AP reply + Micropub blog post) - Native AP interactions (like, boost, reply, follow/unfollow) - Notifications view for likes, boosts, follows, mentions, replies - Moderation tools (mute/block actors, keyword filters) - Remote actor profile pages with follow state - Automatic timeline cleanup with configurable retention - CSRF protection, XSS prevention, input validation throughout Removes Microsub bridge dependency — AP content now lives in its own MongoDB collections (ap_timeline, ap_notifications, ap_interactions, ap_muted, ap_blocked). Bumps version to 1.1.0.
This commit is contained in:
884
assets/reader.css
Normal file
884
assets/reader.css
Normal file
@@ -0,0 +1,884 @@
|
|||||||
|
/**
|
||||||
|
* ActivityPub Reader Styles
|
||||||
|
* Card-based layout inspired by Phanpy/Elk
|
||||||
|
* Uses Indiekit CSS custom properties
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* ==========================================================================
|
||||||
|
Tab Navigation
|
||||||
|
========================================================================== */
|
||||||
|
|
||||||
|
.ap-tabs {
|
||||||
|
border-bottom: 1px solid var(--color-offset);
|
||||||
|
display: flex;
|
||||||
|
gap: var(--space-xs);
|
||||||
|
margin-bottom: var(--space-m);
|
||||||
|
overflow-x: auto;
|
||||||
|
-webkit-overflow-scrolling: touch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ap-tab {
|
||||||
|
border-bottom: 2px solid transparent;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
font-size: var(--font-size-body);
|
||||||
|
padding: var(--space-s) var(--space-m);
|
||||||
|
text-decoration: none;
|
||||||
|
transition:
|
||||||
|
color 0.2s ease,
|
||||||
|
border-color 0.2s ease;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ap-tab:hover {
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ap-tab--active {
|
||||||
|
border-bottom-color: var(--color-primary);
|
||||||
|
color: var(--color-primary);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ==========================================================================
|
||||||
|
Timeline Layout
|
||||||
|
========================================================================== */
|
||||||
|
|
||||||
|
.ap-timeline {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-m);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ==========================================================================
|
||||||
|
Item Card
|
||||||
|
========================================================================== */
|
||||||
|
|
||||||
|
.ap-card {
|
||||||
|
background: var(--color-background);
|
||||||
|
border: 1px solid var(--color-offset);
|
||||||
|
border-radius: var(--border-radius);
|
||||||
|
overflow: hidden;
|
||||||
|
padding: var(--space-m);
|
||||||
|
transition:
|
||||||
|
box-shadow 0.2s ease,
|
||||||
|
border-color 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ap-card:hover {
|
||||||
|
border-color: var(--color-offset-active);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Boost header */
|
||||||
|
.ap-card__boost {
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
font-size: var(--font-size-small);
|
||||||
|
margin-bottom: var(--space-s);
|
||||||
|
padding-bottom: var(--space-xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ap-card__boost a {
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
font-weight: 600;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ap-card__boost a:hover {
|
||||||
|
color: var(--color-text);
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Reply context */
|
||||||
|
.ap-card__reply-to {
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
font-size: var(--font-size-small);
|
||||||
|
margin-bottom: var(--space-s);
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ap-card__reply-to a {
|
||||||
|
color: var(--color-primary);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ap-card__reply-to a:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Author header */
|
||||||
|
.ap-card__author {
|
||||||
|
align-items: center;
|
||||||
|
display: flex;
|
||||||
|
gap: var(--space-s);
|
||||||
|
margin-bottom: var(--space-s);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ap-card__avatar {
|
||||||
|
border: 1px solid var(--color-offset);
|
||||||
|
border-radius: 50%;
|
||||||
|
flex-shrink: 0;
|
||||||
|
height: 40px;
|
||||||
|
object-fit: cover;
|
||||||
|
width: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ap-card__author-info {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ap-card__author-name {
|
||||||
|
font-weight: 600;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ap-card__author-name a {
|
||||||
|
color: inherit;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ap-card__author-name a:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ap-card__author-handle {
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
font-size: var(--font-size-small);
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ap-card__timestamp {
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
flex-shrink: 0;
|
||||||
|
font-size: var(--font-size-small);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Post title (articles) */
|
||||||
|
.ap-card__title {
|
||||||
|
font-size: var(--font-size-heading-4);
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 1.3;
|
||||||
|
margin-bottom: var(--space-s);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ap-card__title a {
|
||||||
|
color: inherit;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ap-card__title a:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Content */
|
||||||
|
.ap-card__content {
|
||||||
|
color: var(--color-text);
|
||||||
|
line-height: 1.6;
|
||||||
|
margin-bottom: var(--space-s);
|
||||||
|
overflow-wrap: break-word;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ap-card__content a {
|
||||||
|
color: var(--color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ap-card__content p {
|
||||||
|
margin-bottom: var(--space-xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ap-card__content p:last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ap-card__content blockquote {
|
||||||
|
border-left: 3px solid var(--color-offset);
|
||||||
|
margin: var(--space-s) 0;
|
||||||
|
padding-left: var(--space-m);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ap-card__content pre {
|
||||||
|
background: var(--color-offset);
|
||||||
|
border-radius: var(--border-radius);
|
||||||
|
overflow-x: auto;
|
||||||
|
padding: var(--space-s);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ap-card__content code {
|
||||||
|
background: var(--color-offset);
|
||||||
|
border-radius: 3px;
|
||||||
|
font-size: 0.9em;
|
||||||
|
padding: 1px 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ap-card__content pre code {
|
||||||
|
background: none;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ap-card__content img {
|
||||||
|
border-radius: var(--border-radius);
|
||||||
|
height: auto;
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Content warning */
|
||||||
|
.ap-card__cw {
|
||||||
|
margin-bottom: var(--space-s);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ap-card__cw-toggle {
|
||||||
|
background: var(--color-offset);
|
||||||
|
border: 1px solid var(--color-offset-active);
|
||||||
|
border-radius: var(--border-radius);
|
||||||
|
color: var(--color-text);
|
||||||
|
cursor: pointer;
|
||||||
|
display: block;
|
||||||
|
font-size: var(--font-size-small);
|
||||||
|
padding: var(--space-s) var(--space-m);
|
||||||
|
text-align: left;
|
||||||
|
transition: background 0.2s ease;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ap-card__cw-toggle:hover {
|
||||||
|
background: var(--color-offset-active);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Photo gallery */
|
||||||
|
.ap-card__gallery {
|
||||||
|
border-radius: var(--border-radius);
|
||||||
|
display: grid;
|
||||||
|
gap: 2px;
|
||||||
|
margin-bottom: var(--space-s);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ap-card__gallery-link {
|
||||||
|
display: block;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ap-card__gallery img {
|
||||||
|
background: var(--color-offset);
|
||||||
|
display: block;
|
||||||
|
height: 200px;
|
||||||
|
object-fit: cover;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ap-card__gallery-link--more::after {
|
||||||
|
background: rgba(0, 0, 0, 0.5);
|
||||||
|
bottom: 0;
|
||||||
|
content: "";
|
||||||
|
left: 0;
|
||||||
|
position: absolute;
|
||||||
|
right: 0;
|
||||||
|
top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ap-card__gallery-more {
|
||||||
|
color: #fff;
|
||||||
|
font-size: 1.5em;
|
||||||
|
font-weight: 600;
|
||||||
|
left: 50%;
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 1 photo */
|
||||||
|
.ap-card__gallery--1 {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ap-card__gallery--1 img {
|
||||||
|
height: auto;
|
||||||
|
max-height: 400px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 2 photos - side by side */
|
||||||
|
.ap-card__gallery--2 {
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 3 photos - one large, two small */
|
||||||
|
.ap-card__gallery--3 {
|
||||||
|
grid-template-columns: 2fr 1fr;
|
||||||
|
grid-template-rows: 1fr 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ap-card__gallery--3 img:first-child {
|
||||||
|
grid-row: 1 / 3;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 4+ photos - 2x2 grid */
|
||||||
|
.ap-card__gallery--4 {
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
grid-template-rows: 1fr 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Video embed */
|
||||||
|
.ap-card__video {
|
||||||
|
margin-bottom: var(--space-s);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ap-card__video video {
|
||||||
|
border-radius: var(--border-radius);
|
||||||
|
max-height: 400px;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Audio player */
|
||||||
|
.ap-card__audio {
|
||||||
|
margin-bottom: var(--space-s);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ap-card__audio audio {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Tags */
|
||||||
|
.ap-card__tags {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: var(--space-xs);
|
||||||
|
margin-bottom: var(--space-s);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ap-card__tag {
|
||||||
|
background: var(--color-offset);
|
||||||
|
border-radius: var(--border-radius);
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
font-size: var(--font-size-small);
|
||||||
|
padding: 2px var(--space-xs);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ap-card__tag:hover {
|
||||||
|
background: var(--color-offset-active);
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Interaction buttons */
|
||||||
|
.ap-card__actions {
|
||||||
|
border-top: 1px solid var(--color-offset);
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: var(--space-s);
|
||||||
|
padding-top: var(--space-s);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ap-card__action {
|
||||||
|
align-items: center;
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid var(--color-offset);
|
||||||
|
border-radius: var(--border-radius);
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
cursor: pointer;
|
||||||
|
display: inline-flex;
|
||||||
|
font-size: var(--font-size-small);
|
||||||
|
gap: var(--space-xs);
|
||||||
|
padding: var(--space-xs) var(--space-s);
|
||||||
|
text-decoration: none;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ap-card__action:hover {
|
||||||
|
background: var(--color-offset);
|
||||||
|
border-color: var(--color-offset-active);
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Active interaction states */
|
||||||
|
.ap-card__action--like.ap-card__action--active {
|
||||||
|
background: rgba(225, 29, 72, 0.1);
|
||||||
|
border-color: #e11d48;
|
||||||
|
color: #e11d48;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ap-card__action--boost.ap-card__action--active {
|
||||||
|
background: rgba(22, 163, 74, 0.1);
|
||||||
|
border-color: #16a34a;
|
||||||
|
color: #16a34a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ap-card__action:disabled {
|
||||||
|
cursor: wait;
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Error message */
|
||||||
|
.ap-card__action-error {
|
||||||
|
color: #e11d48;
|
||||||
|
font-size: var(--font-size-small);
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ==========================================================================
|
||||||
|
Pagination
|
||||||
|
========================================================================== */
|
||||||
|
|
||||||
|
.ap-pagination {
|
||||||
|
border-top: 1px solid var(--color-offset);
|
||||||
|
display: flex;
|
||||||
|
gap: var(--space-m);
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-top: var(--space-m);
|
||||||
|
padding-top: var(--space-m);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ap-pagination a {
|
||||||
|
color: var(--color-primary);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ap-pagination a:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ==========================================================================
|
||||||
|
Compose Form
|
||||||
|
========================================================================== */
|
||||||
|
|
||||||
|
.ap-compose__context {
|
||||||
|
background: var(--color-offset);
|
||||||
|
border-left: 3px solid var(--color-primary);
|
||||||
|
border-radius: var(--border-radius);
|
||||||
|
margin-bottom: var(--space-m);
|
||||||
|
padding: var(--space-m);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ap-compose__context-label {
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
font-size: var(--font-size-small);
|
||||||
|
margin-bottom: var(--space-xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ap-compose__context-author a {
|
||||||
|
font-weight: 600;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ap-compose__context-text {
|
||||||
|
border: 0;
|
||||||
|
font-size: var(--font-size-small);
|
||||||
|
line-height: 1.5;
|
||||||
|
margin: var(--space-xs) 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ap-compose__context-link {
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
font-size: var(--font-size-small);
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ap-compose__form {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-m);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ap-compose__mode {
|
||||||
|
border: 1px solid var(--color-offset);
|
||||||
|
border-radius: var(--border-radius);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-s);
|
||||||
|
padding: var(--space-m);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ap-compose__mode legend {
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ap-compose__mode-option {
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: var(--space-xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ap-compose__mode-hint {
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
display: block;
|
||||||
|
font-size: var(--font-size-small);
|
||||||
|
margin-left: 1.5em;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ap-compose__editor {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ap-compose__textarea {
|
||||||
|
border: 1px solid var(--color-offset-active);
|
||||||
|
border-radius: var(--border-radius);
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: var(--font-size-body);
|
||||||
|
line-height: 1.6;
|
||||||
|
padding: var(--space-s);
|
||||||
|
resize: vertical;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ap-compose__textarea:focus {
|
||||||
|
border-color: var(--color-primary);
|
||||||
|
outline: 2px solid var(--color-primary);
|
||||||
|
outline-offset: -2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ap-compose__counter {
|
||||||
|
font-size: var(--font-size-small);
|
||||||
|
padding-top: var(--space-xs);
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ap-compose__counter--warn {
|
||||||
|
color: #d97706;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ap-compose__counter--over {
|
||||||
|
color: #e11d48;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ap-compose__syndication {
|
||||||
|
border: 1px solid var(--color-offset);
|
||||||
|
border-radius: var(--border-radius);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-xs);
|
||||||
|
padding: var(--space-m);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ap-compose__syndication legend {
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ap-compose__syndication-target {
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
gap: var(--space-xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ap-compose__actions {
|
||||||
|
align-items: center;
|
||||||
|
display: flex;
|
||||||
|
gap: var(--space-m);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ap-compose__submit {
|
||||||
|
background: var(--color-primary);
|
||||||
|
border: 0;
|
||||||
|
border-radius: var(--border-radius);
|
||||||
|
color: #fff;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: var(--font-size-body);
|
||||||
|
font-weight: 600;
|
||||||
|
padding: var(--space-s) var(--space-l);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ap-compose__submit:hover {
|
||||||
|
opacity: 0.9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ap-compose__cancel {
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ap-compose__cancel:hover {
|
||||||
|
color: var(--color-text);
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ==========================================================================
|
||||||
|
Notifications
|
||||||
|
========================================================================== */
|
||||||
|
|
||||||
|
.ap-notification {
|
||||||
|
align-items: flex-start;
|
||||||
|
background: var(--color-background);
|
||||||
|
border: 1px solid var(--color-offset);
|
||||||
|
border-radius: var(--border-radius);
|
||||||
|
display: flex;
|
||||||
|
gap: var(--space-s);
|
||||||
|
padding: var(--space-m);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ap-notification--unread {
|
||||||
|
border-color: rgba(255, 204, 0, 0.5);
|
||||||
|
box-shadow: 0 0 8px 0 rgba(255, 204, 0, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ap-notification__icon {
|
||||||
|
flex-shrink: 0;
|
||||||
|
font-size: 1.5em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ap-notification__body {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ap-notification__actor {
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ap-notification__action {
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ap-notification__target {
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
display: block;
|
||||||
|
font-size: var(--font-size-small);
|
||||||
|
margin-top: var(--space-xs);
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ap-notification__excerpt {
|
||||||
|
background: var(--color-offset);
|
||||||
|
border-radius: var(--border-radius);
|
||||||
|
font-size: var(--font-size-small);
|
||||||
|
margin-top: var(--space-xs);
|
||||||
|
padding: var(--space-xs) var(--space-s);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ap-notification__time {
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
flex-shrink: 0;
|
||||||
|
font-size: var(--font-size-small);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ==========================================================================
|
||||||
|
Remote Profile
|
||||||
|
========================================================================== */
|
||||||
|
|
||||||
|
.ap-profile__header {
|
||||||
|
border-radius: var(--border-radius);
|
||||||
|
height: 200px;
|
||||||
|
margin-bottom: var(--space-m);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ap-profile__header-img {
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ap-profile__info {
|
||||||
|
margin-bottom: var(--space-l);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ap-profile__avatar-wrap {
|
||||||
|
margin-bottom: var(--space-s);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ap-profile__avatar {
|
||||||
|
border: 3px solid var(--color-background);
|
||||||
|
border-radius: 50%;
|
||||||
|
height: 80px;
|
||||||
|
object-fit: cover;
|
||||||
|
width: 80px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ap-profile__avatar--placeholder {
|
||||||
|
align-items: center;
|
||||||
|
background: var(--color-offset);
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
display: flex;
|
||||||
|
font-size: 2em;
|
||||||
|
font-weight: 600;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ap-profile__name {
|
||||||
|
font-size: var(--font-size-heading-3);
|
||||||
|
margin-bottom: var(--space-xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ap-profile__handle {
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
margin-bottom: var(--space-s);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ap-profile__bio {
|
||||||
|
line-height: 1.6;
|
||||||
|
margin-bottom: var(--space-s);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ap-profile__bio a {
|
||||||
|
color: var(--color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ap-profile__actions {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: var(--space-s);
|
||||||
|
margin-top: var(--space-m);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ap-profile__action {
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid var(--color-offset-active);
|
||||||
|
border-radius: var(--border-radius);
|
||||||
|
color: var(--color-text);
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: var(--font-size-small);
|
||||||
|
padding: var(--space-xs) var(--space-m);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ap-profile__action:hover {
|
||||||
|
background: var(--color-offset);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ap-profile__action--follow.ap-profile__action--active {
|
||||||
|
background: var(--color-primary);
|
||||||
|
border-color: var(--color-primary);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ap-profile__action--danger:hover {
|
||||||
|
border-color: #e11d48;
|
||||||
|
color: #e11d48;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ap-profile__posts {
|
||||||
|
margin-top: var(--space-l);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ap-profile__posts h3 {
|
||||||
|
border-bottom: 1px solid var(--color-offset);
|
||||||
|
font-size: var(--font-size-heading-4);
|
||||||
|
margin-bottom: var(--space-m);
|
||||||
|
padding-bottom: var(--space-s);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ==========================================================================
|
||||||
|
Moderation
|
||||||
|
========================================================================== */
|
||||||
|
|
||||||
|
.ap-moderation__section {
|
||||||
|
margin-bottom: var(--space-l);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ap-moderation__section h2 {
|
||||||
|
font-size: var(--font-size-heading-4);
|
||||||
|
margin-bottom: var(--space-s);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ap-moderation__list {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ap-moderation__entry {
|
||||||
|
align-items: center;
|
||||||
|
border-bottom: 1px solid var(--color-offset);
|
||||||
|
display: flex;
|
||||||
|
gap: var(--space-s);
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: var(--space-s) 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ap-moderation__entry a {
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ap-moderation__remove {
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid var(--color-offset-active);
|
||||||
|
border-radius: var(--border-radius);
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
cursor: pointer;
|
||||||
|
flex-shrink: 0;
|
||||||
|
font-size: var(--font-size-small);
|
||||||
|
padding: var(--space-xs) var(--space-s);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ap-moderation__remove:hover {
|
||||||
|
border-color: #e11d48;
|
||||||
|
color: #e11d48;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ap-moderation__add-form {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--space-s);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ap-moderation__input {
|
||||||
|
border: 1px solid var(--color-offset-active);
|
||||||
|
border-radius: var(--border-radius);
|
||||||
|
flex: 1;
|
||||||
|
font-size: var(--font-size-body);
|
||||||
|
padding: var(--space-xs) var(--space-s);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ap-moderation__add-btn {
|
||||||
|
background: var(--color-offset);
|
||||||
|
border: 1px solid var(--color-offset-active);
|
||||||
|
border-radius: var(--border-radius);
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: var(--font-size-body);
|
||||||
|
padding: var(--space-xs) var(--space-m);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ap-moderation__add-btn:hover {
|
||||||
|
background: var(--color-offset-active);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ==========================================================================
|
||||||
|
Responsive
|
||||||
|
========================================================================== */
|
||||||
|
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.ap-tabs {
|
||||||
|
gap: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ap-tab {
|
||||||
|
padding: var(--space-xs) var(--space-s);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ap-card__gallery--3 {
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
grid-template-rows: auto auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ap-card__gallery--3 img:first-child {
|
||||||
|
grid-column: 1 / 3;
|
||||||
|
grid-row: 1;
|
||||||
|
height: 200px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ap-card__actions {
|
||||||
|
gap: var(--space-xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ap-card__action {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
padding: var(--space-xs);
|
||||||
|
}
|
||||||
|
}
|
||||||
882
docs/plans/2026-02-21-activitypub-reader.md
Normal file
882
docs/plans/2026-02-21-activitypub-reader.md
Normal file
@@ -0,0 +1,882 @@
|
|||||||
|
# ActivityPub Reader Implementation Plan
|
||||||
|
|
||||||
|
Created: 2026-02-21
|
||||||
|
Status: VERIFIED
|
||||||
|
Approved: Yes
|
||||||
|
Iterations: 0
|
||||||
|
Worktree: No
|
||||||
|
|
||||||
|
> **Status Lifecycle:** PENDING → COMPLETE → VERIFIED
|
||||||
|
> **Iterations:** Tracks implement→verify cycles (incremented by verify phase)
|
||||||
|
>
|
||||||
|
> - PENDING: Initial state, awaiting implementation
|
||||||
|
> - COMPLETE: All tasks implemented
|
||||||
|
> - VERIFIED: All checks passed
|
||||||
|
>
|
||||||
|
> **Approval Gate:** Implementation CANNOT proceed until `Approved: Yes`
|
||||||
|
> **Worktree:** Set at plan creation (from dispatcher). `Yes` uses git worktree isolation; `No` works directly on current branch
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
**Goal:** Build a dedicated ActivityPub reader within the `@rmdes/indiekit-endpoint-activitypub` plugin, providing a timeline view of followed accounts' posts, a notifications stream, native AP interactions (like, boost, reply, follow/unfollow), and Micropub-based content creation — then remove the Microsub bridge dependency.
|
||||||
|
|
||||||
|
**Architecture:** The reader adds new MongoDB collections (`ap_timeline`, `ap_notifications`, `ap_muted`, `ap_blocked`) alongside new controllers, views, and a CSS stylesheet. Inbox listeners are refactored to store items natively instead of bridging to Microsub. Alpine.js provides client-side reactivity for interactions. Content creation uses two paths: direct Fedify `ctx.sendActivity()` for quick likes/boosts, and Micropub POST for replies that become blog posts (user chooses per-reply).
|
||||||
|
|
||||||
|
**Tech Stack:** Node.js/Express, MongoDB, Nunjucks templates, Alpine.js, Fedify SDK (`ctx.sendActivity()`, `ctx.lookupObject()`), Indiekit frontend components, CSS custom properties.
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
### In Scope
|
||||||
|
|
||||||
|
- Timeline view showing posts from followed accounts with threading, content warnings, boosts, and rich media (images, video, audio, polls)
|
||||||
|
- Tab-based filtering (All, Notes, Articles, Replies, Boosts, Media)
|
||||||
|
- Notifications stream (likes, boosts, follows, mentions, replies received)
|
||||||
|
- Native AP interactions: like, boost, reply (with choice of direct AP or Micropub), follow/unfollow
|
||||||
|
- Mute/unmute (accounts and keywords), block/unblock
|
||||||
|
- Profile view for remote actors (view posts, follow/unfollow, mute, block)
|
||||||
|
- Compose form that submits via Micropub endpoint (for blog-worthy replies)
|
||||||
|
- Custom CSS stylesheet with card-based layout inspired by Phanpy/Elk
|
||||||
|
- Content warning spoiler toggle (Alpine.js)
|
||||||
|
- Image gallery grid for multi-image posts
|
||||||
|
- Video/audio embed rendering
|
||||||
|
- Removal of Microsub bridge (`storeTimelineItem`, `getApChannelId`, lazy `microsub_items`/`microsub_channels` accessors)
|
||||||
|
|
||||||
|
### Out of Scope
|
||||||
|
|
||||||
|
- Mastodon REST API compatibility (no mobile client support — would be a separate project)
|
||||||
|
- Lists (organizing follows into named groups) — deferred to future plan
|
||||||
|
- Local/Federated timeline distinction (single timeline of followed accounts only)
|
||||||
|
- Full-text search within timeline items
|
||||||
|
- Polls (rendering existing polls is in scope; creating polls is not)
|
||||||
|
- Direct messages / conversations
|
||||||
|
- Push notifications (browser notifications)
|
||||||
|
- Infinite scroll (standard pagination is used)
|
||||||
|
- Video/audio upload in compose form
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
- Plugin is at v1.0.29+ with all federation hardening features complete
|
||||||
|
- Fedify SDK available via `this._federation` on the plugin instance
|
||||||
|
- MongoDB collections infrastructure in `index.js`
|
||||||
|
- Indiekit frontend components available (`@indiekit/frontend`)
|
||||||
|
- Alpine.js: **NOT loaded by Indiekit core**. The reader layout must explicitly load Alpine.js via a `<script>` CDN tag (e.g., `<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3/dist/cdn.min.js"></script>`). The existing AP dashboard views use `x-data` directives — they work because the Cloudron deployment's CSP allows `cdn.jsdelivr.net` (see `nginx.conf`). The reader layout template must include Alpine.js in its `<head>` block.
|
||||||
|
- `sanitize-html` package (add to `package.json` dependencies — used by Microsub plugin already, needed here for XSS prevention on remote content)
|
||||||
|
|
||||||
|
## Context for Implementer
|
||||||
|
|
||||||
|
> This section is critical for cross-session continuity. Write it for an implementer who has never seen the codebase.
|
||||||
|
|
||||||
|
- **Patterns to follow:**
|
||||||
|
- Route registration: See `index.js:143-169` — admin routes go in `get routes()` method, registered at `/admin/activitypub/*`
|
||||||
|
- Controller pattern: Each controller exports async functions taking `(request, response)`. See `lib/controllers/dashboard.js` as example
|
||||||
|
- View pattern: Views are `activitypub-*.njk` files in `views/`. They extend `document.njk` and use Indiekit frontend component macros (`card`, `button`, `badge`, `pagination`, etc.)
|
||||||
|
- Collection registration: See `index.js:614-621` — register via `Indiekit.addCollection("name")` calls in `init()`, then store references via `this._collections.name = indiekitCollections.get("name")`
|
||||||
|
- i18n: All user-visible strings go in `locales/en.json` under the `activitypub` namespace, referenced via `__("activitypub.reader.xxx")`
|
||||||
|
- Asset serving: Place CSS/JS in `assets/` directory. Indiekit core serves at `/assets/@rmdes-indiekit-endpoint-activitypub/`. Reference from views with `<link>` tag.
|
||||||
|
|
||||||
|
- **Conventions:**
|
||||||
|
- ESM modules throughout (`import`/`export`)
|
||||||
|
- ISO 8601 strings for dates in MongoDB (except `published` in timeline items which uses `Date` for sorting queries)
|
||||||
|
- Nunjucks templates use `{% from "xxx.njk" import component %}` for Indiekit frontend components
|
||||||
|
- Alpine.js `x-data`, `x-show`, `x-on:click` for client-side interactivity (loaded explicitly in reader layout, NOT by Indiekit core)
|
||||||
|
- CSRF protection: Indiekit core has no CSRF middleware. POST endpoints that trigger ActivityPub activities must validate a CSRF token. Use a simple pattern: generate a token per-session and embed as a hidden field in forms / include in `fetch()` headers. Validate on the server side before processing.
|
||||||
|
|
||||||
|
- **Key files:**
|
||||||
|
- `index.js` — Plugin entry point, routes, collections, syndicator, follow/unfollow methods
|
||||||
|
- `lib/inbox-listeners.js` — All inbox activity handlers (Follow, Like, Announce, Create, Delete, etc.)
|
||||||
|
- `lib/federation-setup.js` — Fedify federation object configuration (dispatchers, queue, etc.)
|
||||||
|
- `locales/en.json` — English translations
|
||||||
|
- `views/activitypub-dashboard.njk` — Dashboard view (reference for card-grid patterns)
|
||||||
|
- `views/activitypub-following.njk` — Following view (reference for list+pagination)
|
||||||
|
|
||||||
|
- **Gotchas:**
|
||||||
|
- Fedify returns `Temporal.Instant` for dates, not JS `Date`. Convert with `new Date(Number(obj.published.epochMilliseconds))`
|
||||||
|
- Fedify object properties are often async getters — `await actorObj.icon` not `actorObj.icon`
|
||||||
|
- `ctx.sendActivity()` first argument is `{ identifier: handle }` where `handle` comes from plugin options
|
||||||
|
- The plugin stores `this._federation` and creates context via `this._federation.createContext(new URL(this._publicationUrl), { handle, publicationUrl })`
|
||||||
|
- Remote actor lookup uses `ctx.lookupObject("@handle@instance")` or `ctx.lookupObject("https://url")`
|
||||||
|
- The AP plugin's asset directory is `assets/` at the package root, served at `/assets/@rmdes-indiekit-endpoint-activitypub/`
|
||||||
|
|
||||||
|
- **Domain context:**
|
||||||
|
- ActivityPub activities: `Like` (favorite), `Announce` (boost/repost), `Create` (new post), `Follow`/`Undo(Follow)`, `Accept`, `Reject`, `Delete`, `Update`, `Block`, `Move`
|
||||||
|
- Content warnings use the `summary` field on AP objects (Mastodon convention)
|
||||||
|
- Boosts are `Announce` activities wrapping the original post — the reader must render the original post with boost attribution
|
||||||
|
- Replies use `inReplyTo` linking to the parent post URL
|
||||||
|
- Sensitive content uses the `sensitive` boolean on AP objects
|
||||||
|
|
||||||
|
## Runtime Environment
|
||||||
|
|
||||||
|
- **Start command:** `cloudron exec --app rmendes.net` or locally `npm start` in the Cloudron container
|
||||||
|
- **Port:** Indiekit on 8080 (behind nginx on 3000)
|
||||||
|
- **Health check:** `curl https://rmendes.net/.well-known/webfinger?resource=acct:rick@rmendes.net`
|
||||||
|
- **Deploy:** Build via `cloudron build --no-cache && cloudron update --app rmendes.net --no-backup`
|
||||||
|
|
||||||
|
## Feature Inventory — Microsub Bridge Being Replaced
|
||||||
|
|
||||||
|
### Files Being Modified (Bridge Removal)
|
||||||
|
|
||||||
|
| Old Code | Functions | Mapped to Task |
|
||||||
|
|----------|-----------|----------------|
|
||||||
|
| `lib/inbox-listeners.js` — function `storeTimelineItem()` (~line 468) | Timeline item storage from AP activities | Task 2 (store natively), Task 12 (remove bridge) |
|
||||||
|
| `lib/inbox-listeners.js` — function `getApChannelId()` (~line 413) | Auto-creates Microsub "Fediverse" channel | Task 12 (remove) |
|
||||||
|
| `index.js` — lazy accessors in `init()` (~line 638) | `microsub_items`, `microsub_channels` collection refs | Task 12 (remove) |
|
||||||
|
| `lib/inbox-listeners.js` — Create handler (~line 262, calls `storeTimelineItem` at ~line 310) | Stores incoming posts via bridge | Task 2 (redirect to native storage) |
|
||||||
|
|
||||||
|
### Feature Mapping Verification
|
||||||
|
|
||||||
|
- [x] `storeTimelineItem()` → Task 2 (native `ap_timeline` storage)
|
||||||
|
- [x] `getApChannelId()` → Task 12 (removed; no longer needed)
|
||||||
|
- [x] Lazy Microsub collection accessors → Task 12 (removed)
|
||||||
|
- [x] Inbox Create handler → Task 2 (rewired to native storage)
|
||||||
|
- [x] Like/Announce inbox storage → Task 3 (notification storage)
|
||||||
|
|
||||||
|
## Progress Tracking
|
||||||
|
|
||||||
|
**MANDATORY: Update this checklist as tasks complete. Change `[ ]` to `[x]`.**
|
||||||
|
|
||||||
|
- [x] Task 1: MongoDB collections and data models
|
||||||
|
- [x] Task 2: Inbox listener refactor — native timeline storage (includes Delete/Update handling)
|
||||||
|
- [x] Task 3: Inbox listener refactor — notification storage
|
||||||
|
- [x] Task 4: Timeline controller and view
|
||||||
|
- [x] Task 5: Reader CSS stylesheet
|
||||||
|
- [x] Task 6: Notifications controller and view
|
||||||
|
- [x] Task 7a: Interaction API — Like and Boost endpoints (with CSRF)
|
||||||
|
- [x] Task 7b: Interaction UI — Like and Boost buttons (Alpine.js)
|
||||||
|
- [x] Task 8: Compose form — Micropub reply path
|
||||||
|
- [x] Task 9: Content warning toggles and rich media rendering
|
||||||
|
- [x] Task 10: Mute, block, and tab filtering
|
||||||
|
- [x] Task 11: Remote profile view
|
||||||
|
- [x] Task 12: Remove Microsub bridge
|
||||||
|
- [x] Task 13: Timeline retention cleanup
|
||||||
|
|
||||||
|
**Total Tasks:** 14 | **Completed:** 14 | **Remaining:** 0
|
||||||
|
|
||||||
|
## Implementation Tasks
|
||||||
|
|
||||||
|
### Task 1: MongoDB Collections and Data Models
|
||||||
|
|
||||||
|
**Objective:** Register new MongoDB collections (`ap_timeline`, `ap_notifications`, `ap_muted`, `ap_blocked`, `ap_interactions`) and create indexes for efficient querying.
|
||||||
|
|
||||||
|
**Dependencies:** None
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
|
||||||
|
- Modify: `index.js` — Register collections via `Indiekit.addCollection()` in `init()`, store references in `this._collections`, create indexes
|
||||||
|
- Create: `lib/storage/timeline.js` — Timeline CRUD functions
|
||||||
|
- Create: `lib/storage/notifications.js` — Notification CRUD functions
|
||||||
|
- Create: `lib/storage/moderation.js` — Mute/block CRUD functions
|
||||||
|
|
||||||
|
**Key Decisions / Notes:**
|
||||||
|
|
||||||
|
- `ap_timeline` schema:
|
||||||
|
```js
|
||||||
|
{
|
||||||
|
uid: "https://remote.example/posts/123", // canonical AP object URL (dedup key)
|
||||||
|
type: "note" | "article" | "boost", // boost = Announce wrapper
|
||||||
|
url: "https://remote.example/posts/123",
|
||||||
|
name: "Post Title" | null, // Articles only
|
||||||
|
content: { text: "...", html: "..." },
|
||||||
|
summary: "Content warning text" | null, // CW / spoiler
|
||||||
|
sensitive: false, // Mastodon sensitive flag
|
||||||
|
published: Date, // Date object for sort queries
|
||||||
|
author: { name, url, photo, handle }, // handle = "@user@instance"
|
||||||
|
category: ["tag1", "tag2"],
|
||||||
|
photo: ["url1", "url2"],
|
||||||
|
video: ["url1"],
|
||||||
|
audio: ["url1"],
|
||||||
|
inReplyTo: "https://parent-post-url" | null,
|
||||||
|
boostedBy: { name, url, photo, handle } | null, // For Announce activities
|
||||||
|
boostedAt: Date | null, // When the boost happened
|
||||||
|
originalUrl: "https://original-post-url" | null, // For boosts: the wrapped object URL
|
||||||
|
readBy: [],
|
||||||
|
createdAt: "ISO string"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
- `ap_notifications` schema:
|
||||||
|
```js
|
||||||
|
{
|
||||||
|
uid: "activity-id", // dedup key
|
||||||
|
type: "like" | "boost" | "follow" | "mention" | "reply",
|
||||||
|
actorUrl: "https://remote.example/@user",
|
||||||
|
actorName: "Display Name",
|
||||||
|
actorPhoto: "https://...",
|
||||||
|
actorHandle: "@user@instance",
|
||||||
|
targetUrl: "https://my-post-url" | null, // The post they liked/boosted/replied to
|
||||||
|
targetName: "My Post Title" | null,
|
||||||
|
content: { text: "...", html: "..." } | null, // For mentions/replies
|
||||||
|
published: Date,
|
||||||
|
read: false,
|
||||||
|
createdAt: "ISO string"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
- `ap_muted`: `{ url: "actor-url", keyword: null, mutedAt: "ISO" }` — url OR keyword, not both
|
||||||
|
- `ap_blocked`: `{ url: "actor-url", blockedAt: "ISO" }`
|
||||||
|
- `ap_interactions`: `{ type: "like"|"boost", objectUrl: "https://...", activityId: "urn:uuid:...", createdAt: "ISO" }` — tracks outgoing interactions for undo support and UI state
|
||||||
|
- Indexes:
|
||||||
|
- `ap_timeline`: `{ uid: 1 }` unique, `{ published: -1 }` for timeline sort, `{ "author.url": 1 }` for profile view, `{ type: 1, published: -1 }` for tab filtering
|
||||||
|
- `ap_notifications`: `{ uid: 1 }` unique, `{ published: -1 }` for sort, `{ read: 1 }` for unread count
|
||||||
|
- `ap_muted`: `{ url: 1 }` unique (sparse), `{ keyword: 1 }` unique (sparse)
|
||||||
|
- `ap_blocked`: `{ url: 1 }` unique
|
||||||
|
- `ap_interactions`: `{ objectUrl: 1, type: 1 }` compound unique (one like/boost per object), `{ type: 1 }` for listing
|
||||||
|
- Storage functions follow the pattern in Microsub's `lib/storage/items.js` — export pure functions that take `(collections, ...)` parameters
|
||||||
|
- `addTimelineItem(collections, item)` uses atomic upsert: `updateOne({ uid }, { $setOnInsert: item }, { upsert: true })`
|
||||||
|
- `getTimelineItems(collections, { before, after, limit, type, authorUrl })` returns cursor-paginated results
|
||||||
|
- `addNotification(collections, notification)` uses atomic upsert
|
||||||
|
- `getNotifications(collections, { before, limit })` returns paginated, newest-first
|
||||||
|
- `getUnreadNotificationCount(collections)` returns count of `{ read: false }`
|
||||||
|
|
||||||
|
**Definition of Done:**
|
||||||
|
|
||||||
|
- [ ] All five collections registered via `Indiekit.addCollection()` in `init()` (ap_timeline, ap_notifications, ap_muted, ap_blocked, ap_interactions)
|
||||||
|
- [ ] Indexes created in `init()` method
|
||||||
|
- [ ] `addTimelineItem` stores item and deduplicates by uid
|
||||||
|
- [ ] `getTimelineItems` returns paginated results with before/after cursors
|
||||||
|
- [ ] `addNotification` stores notification and deduplicates
|
||||||
|
- [ ] `getNotifications` returns paginated newest-first
|
||||||
|
- [ ] `getUnreadNotificationCount` returns correct count
|
||||||
|
- [ ] Mute/block CRUD operations work (add, remove, list, check)
|
||||||
|
- [ ] All storage functions have unit tests
|
||||||
|
|
||||||
|
**Verify:**
|
||||||
|
|
||||||
|
- `cd /home/rick/code/indiekit-dev/indiekit-endpoint-activitypub && node -e "import('./lib/storage/timeline.js').then(m => console.log(Object.keys(m)))"` — exports exist
|
||||||
|
- `cd /home/rick/code/indiekit-dev/indiekit-endpoint-activitypub && node -e "import('./lib/storage/notifications.js').then(m => console.log(Object.keys(m)))"` — exports exist
|
||||||
|
- `cd /home/rick/code/indiekit-dev/indiekit-endpoint-activitypub && node -e "import('./lib/storage/moderation.js').then(m => console.log(Object.keys(m)))"` — exports exist
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 2: Inbox Listener Refactor — Native Timeline Storage
|
||||||
|
|
||||||
|
**Objective:** Modify the inbox Create handler to store posts in `ap_timeline` instead of bridging to Microsub. Also handle Announce (boost) activities by storing the wrapped object with boost attribution.
|
||||||
|
|
||||||
|
**Dependencies:** Task 1
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
|
||||||
|
- Modify: `lib/inbox-listeners.js` — Refactor Create handler (~line 262) and Announce handler (~line 233) to store in `ap_timeline`, plus Delete/Update handlers for timeline cleanup
|
||||||
|
- Modify: `package.json` — Add `sanitize-html` to dependencies
|
||||||
|
- Create: `lib/timeline-store.js` — Helper that extracts data from Fedify objects and calls storage functions
|
||||||
|
|
||||||
|
**Key Decisions / Notes:**
|
||||||
|
|
||||||
|
- The existing Create handler at `inbox-listeners.js` (function `registerInboxListeners`, Create section ~line 262) currently calls `storeTimelineItem()`. Replace that call with the new native storage
|
||||||
|
- **CRITICAL — Announce handler bifurcation required:** The current Announce handler (line ~237) has an early return that ONLY processes boosts of our own content: `if (!objectId || (pubUrl && !objectId.startsWith(pubUrl))) return;`. This filter MUST be modified to create two code paths:
|
||||||
|
1. **Boost of our content** (objectId starts with pubUrl) → store as notification (Task 3)
|
||||||
|
2. **Boost from a followed account** (announcing actor is in our followers/following) → store in `ap_timeline` with `type: "boost"`
|
||||||
|
3. **Both conditions true** (a followed account boosts our post) → store BOTH notification AND timeline item
|
||||||
|
- For timeline boosts: fetch the wrapped object via `await announce.getObject()` (the current handler only reads `announce.objectId` URL, NOT the full object), extract its data, then store with `type: "boost"` and `boostedBy` populated from the announcing actor
|
||||||
|
- To check if the announcing actor is followed: query `ap_followers` or `ap_following` collection for the actor URL
|
||||||
|
- Keep the same Fedify object→data extraction logic from `storeTimelineItem` (content, photos, videos, tags, etc.) but move it to a reusable `extractObjectData(object, actorObj)` function in `lib/timeline-store.js`
|
||||||
|
- **CRITICAL: HTML sanitization** — Remote content HTML MUST be sanitized before storage using `sanitize-html` (same library used in Microsub's `lib/webmention/verifier.js`). Allow safe tags: `a`, `p`, `br`, `em`, `strong`, `blockquote`, `ul`, `ol`, `li`, `code`, `pre`, `span`, `h1`-`h6`, `img`. Allow `href` on `a`, `src`/`alt` on `img`, `class` on `span` (for Mastodon custom emoji). Strip all other HTML including `<script>`, `<style>`, event handlers. This prevents XSS when rendering content with Nunjucks `| safe` filter
|
||||||
|
- Check muted/blocked before storing — skip items from muted URLs or containing muted keywords
|
||||||
|
- The existing `storeTimelineItem()` and `getApChannelId()` functions remain for now (cleaned up in Task 12)
|
||||||
|
- For replies (`inReplyTo`), store the parent URL so the frontend can render threading context
|
||||||
|
- **Delete activity handling:** Modify the existing Delete handler (`inbox-listeners.js` ~line 318) to also remove items from `ap_timeline` (currently only deletes from `ap_activities`). When a remote user deletes a post, remove the corresponding `ap_timeline` entry by uid.
|
||||||
|
- **Update activity handling:** Modify the existing Update handler (`inbox-listeners.js` ~line 345) to also update `ap_timeline` items. Currently it only refreshes follower/actor profile data. When a remote user edits a post (Update activity), re-extract the content and update the timeline item. This prevents showing stale content for edited posts.
|
||||||
|
|
||||||
|
**Definition of Done:**
|
||||||
|
|
||||||
|
- [ ] Create activities from followed accounts stored in `ap_timeline` with all fields populated
|
||||||
|
- [ ] Announce (boost) activities stored with `type: "boost"`, `boostedBy`, and the original post content
|
||||||
|
- [ ] Muted actors' posts are skipped during storage
|
||||||
|
- [ ] Blocked actors' posts are skipped during storage
|
||||||
|
- [ ] Posts containing muted keywords are skipped
|
||||||
|
- [ ] Duplicate posts (same uid) are not created
|
||||||
|
- [ ] Remote HTML content sanitized before storage (no `<script>`, `<style>`, event handlers)
|
||||||
|
- [ ] Delete activities remove corresponding items from `ap_timeline`
|
||||||
|
- [ ] Update activities refresh content of existing `ap_timeline` items
|
||||||
|
- [ ] Tests verify Create → timeline storage flow
|
||||||
|
- [ ] Tests verify Announce → timeline storage with boost attribution
|
||||||
|
- [ ] Tests verify Delete → timeline item removal
|
||||||
|
- [ ] Tests verify Update → timeline item content refresh
|
||||||
|
|
||||||
|
**Verify:**
|
||||||
|
|
||||||
|
- Integration test: Send a mock Create activity, verify it appears in `ap_timeline` collection
|
||||||
|
- Integration test: Send a mock Announce activity, verify boost attribution stored correctly
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 3: Inbox Listener Refactor — Notification Storage
|
||||||
|
|
||||||
|
**Objective:** Store incoming Like, Announce (of our posts), Follow, and mention/reply activities as notifications in `ap_notifications`.
|
||||||
|
|
||||||
|
**Dependencies:** Task 1
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
|
||||||
|
- Modify: `lib/inbox-listeners.js` — Add notification storage calls in Like handler (`activity instanceof Like`), Announce handler (`activity instanceof Announce`), Follow handler (`activity instanceof Follow`), Create handler for mentions/replies
|
||||||
|
|
||||||
|
**Key Decisions / Notes:**
|
||||||
|
|
||||||
|
- **Like handler** (in `registerInboxListeners`, search for `activity instanceof Like`): already logs to `ap_activities` and filters to only likes of our own posts. This filter is correct for notifications. Add a call to `addNotification()` with `type: "like"`, including the actor info and the liked post URL
|
||||||
|
- **Announce handler** (search for `activity instanceof Announce`): the dual-path logic from Task 2 handles timeline storage. For notifications, when someone boosts OUR post (objectId starts with pubUrl), store as notification `type: "boost"`
|
||||||
|
- Follow handler: store as notification `type: "follow"` when someone new follows us
|
||||||
|
- Create handler: if the post is a reply TO one of our posts (check `inReplyTo` against our publication URL), store as `type: "reply"`; if it mentions us (check tags for Mention with our actor URL), store as `type: "mention"`
|
||||||
|
- Notification dedup by activity ID or constructed uid (e.g., `like:${actorUrl}:${objectUrl}`)
|
||||||
|
- Extract actor info (name, photo, handle) from Fedify actor object — use same `extractActorInfo()` helper
|
||||||
|
|
||||||
|
**Definition of Done:**
|
||||||
|
|
||||||
|
- [ ] Likes of our posts create notification with type "like"
|
||||||
|
- [ ] Boosts of our posts create notification with type "boost"
|
||||||
|
- [ ] New follows create notification with type "follow"
|
||||||
|
- [ ] Replies to our posts create notification with type "reply"
|
||||||
|
- [ ] Mentions of our actor create notification with type "mention"
|
||||||
|
- [ ] Notifications are deduplicated by uid
|
||||||
|
- [ ] All notification types include correct actor info and target post info
|
||||||
|
- [ ] Tests verify each notification type is stored correctly
|
||||||
|
|
||||||
|
**Verify:**
|
||||||
|
|
||||||
|
- Unit tests for notification storage from each activity type
|
||||||
|
- Verify on live site: receive a like → check `ap_notifications` collection via MongoDB query
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 4: Timeline Controller and View
|
||||||
|
|
||||||
|
**Objective:** Create the reader timeline page at `/admin/activitypub/reader` showing posts from followed accounts with pagination, and a reader navigation sidebar.
|
||||||
|
|
||||||
|
**Dependencies:** Task 1, Task 2
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
|
||||||
|
- Create: `lib/controllers/reader.js` — Timeline controller
|
||||||
|
- Create: `views/layouts/reader.njk` — Reader layout (extends `document.njk`, adds Alpine.js CDN `<script>` tag and reader stylesheet `<link>`)
|
||||||
|
- Create: `views/activitypub-reader.njk` — Timeline view (extends `layouts/reader.njk`)
|
||||||
|
- Create: `views/partials/ap-item-card.njk` — Timeline item card partial
|
||||||
|
- Modify: `index.js` — Add reader routes and navigation item
|
||||||
|
- Modify: `locales/en.json` — Add reader i18n strings
|
||||||
|
|
||||||
|
**Key Decisions / Notes:**
|
||||||
|
|
||||||
|
- Route: `GET /admin/activitypub/reader` → timeline (default tab: "All")
|
||||||
|
- Route: `GET /admin/activitypub/reader?tab=notes|articles|replies|boosts|media` → filtered tab
|
||||||
|
- Route: `GET /admin/activitypub/reader?before=cursor` → pagination
|
||||||
|
- Navigation: Add "Reader" as first navigation item (before Dashboard) with an unread notification count badge
|
||||||
|
- Timeline controller calls `getTimelineItems()` with optional `type` filter based on tab
|
||||||
|
- Item card renders: author (avatar + name + handle), content (HTML), photos (grid), video (embed), audio (player), categories/tags, published date, interaction buttons (like, boost, reply, profile link)
|
||||||
|
- Card layout inspired by Phanpy/Elk: clean white cards with subtle shadows, rounded corners, generous spacing
|
||||||
|
- Use cursor-based pagination (same pattern as Microsub: `before`/`after` query params)
|
||||||
|
- Mark items as read when the timeline page loads (or use a "mark all read" button)
|
||||||
|
- The partial `ap-item-card.njk` renders a single timeline item — reused in both timeline and profile views
|
||||||
|
- For boosts: show "🔁 {booster} boosted" header above the original post card
|
||||||
|
- For replies: show "↩ Replying to {parentAuthorUrl}" link above content
|
||||||
|
- **HTML rendering:** Use `{{ item.content.html | safe }}` in templates — this is safe because content was sanitized at storage time (Task 2). Do NOT use `| safe` on any unsanitized user input
|
||||||
|
- **Navigation architecture:** Indiekit's `get navigationItems()` returns flat top-level items in the sidebar. The AP plugin currently returns one item ("ActivityPub" → `/activitypub`). Change this to return "Reader" as the primary navigation item (→ `/activitypub/reader`), and add sub-navigation within the reader views (Dashboard, Reader, Notifications, Following, Settings/Moderation) using a local `<nav>` in the view template — NOT via `get navigationItems()` (which only handles top-level sidebar items)
|
||||||
|
|
||||||
|
**Definition of Done:**
|
||||||
|
|
||||||
|
- [ ] `/admin/activitypub/reader` renders timeline with posts from followed accounts
|
||||||
|
- [ ] Item cards show author info, content, media, tags, date, and interaction buttons
|
||||||
|
- [ ] Tab filtering works for notes, articles, replies, boosts, media
|
||||||
|
- [ ] Pagination works with cursor-based before/after
|
||||||
|
- [ ] Boost attribution renders correctly (boosted by header)
|
||||||
|
- [ ] Reply context renders (replying to link)
|
||||||
|
- [ ] Navigation item appears in sidebar with Reader label
|
||||||
|
- [ ] Empty state shown when timeline is empty
|
||||||
|
|
||||||
|
**Verify:**
|
||||||
|
|
||||||
|
- `curl -s https://rmendes.net/admin/activitypub/reader -H "Cookie: ..." | grep -c "ap-item-card"` — returns item count
|
||||||
|
- Visual check via `playwright-cli open https://rmendes.net/admin/activitypub/reader`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 5: Reader CSS Stylesheet
|
||||||
|
|
||||||
|
**Objective:** Create a custom CSS stylesheet for the AP reader with card-based layout, image grids, and responsive design.
|
||||||
|
|
||||||
|
**Dependencies:** Task 4
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
|
||||||
|
- Create: `assets/reader.css` — Reader stylesheet
|
||||||
|
- Modify: `views/activitypub-reader.njk` — Link stylesheet
|
||||||
|
|
||||||
|
**Key Decisions / Notes:**
|
||||||
|
|
||||||
|
- Follow the pattern from Microsub: `<link rel="stylesheet" href="/assets/@rmdes-indiekit-endpoint-activitypub/reader.css">`
|
||||||
|
- Use Indiekit CSS custom properties: `--space-s`, `--space-m`, `--space-l`, `--color-offset`, `--border-radius`, `--color-text`, `--color-background`, etc.
|
||||||
|
- Card styles: `.ap-card` — white background, border, rounded corners, padding, margin-bottom
|
||||||
|
- Author header: `.ap-card__author` — flexbox row with avatar (40px circle), name (bold), handle (@user@instance, muted), timestamp (right-aligned, relative)
|
||||||
|
- Content: `.ap-card__content` — prose-like styling, max-width for readability
|
||||||
|
- Image grid: `.ap-card__gallery` — CSS Grid, 2-column for 2 images, 2x2 for 3-4 images, rounded corners, gap
|
||||||
|
- Video embed: `.ap-card__video` — responsive 16:9 container
|
||||||
|
- Audio player: `.ap-card__audio` — full-width native audio element
|
||||||
|
- Content warning: `.ap-card__cw` — blurred/collapsed content behind a "Show more" button
|
||||||
|
- Boost header: `.ap-card__boost` — small text with repost icon, muted color
|
||||||
|
- Reply context: `.ap-card__reply-to` — small text with reply icon, linked to parent
|
||||||
|
- Interaction buttons: `.ap-card__actions` — flexbox row, icon buttons with count labels
|
||||||
|
- Tab bar: `.ap-tabs` — horizontal tabs, active tab highlighted
|
||||||
|
- Notifications: `.ap-notification` — compact card with icon, actor, action description, post excerpt
|
||||||
|
- Responsive: Stack to single column on mobile, full-width cards
|
||||||
|
- Dark mode: Use Indiekit's `prefers-color-scheme` media query with its CSS custom properties
|
||||||
|
|
||||||
|
**Definition of Done:**
|
||||||
|
|
||||||
|
- [ ] Cards render with clean, readable layout
|
||||||
|
- [ ] Image gallery works for 1-4 images with proper grid
|
||||||
|
- [ ] Content warnings show blurred/collapsed state
|
||||||
|
- [ ] Interaction buttons aligned horizontally below content
|
||||||
|
- [ ] Tab bar renders with active state
|
||||||
|
- [ ] Responsive on mobile viewport
|
||||||
|
- [ ] Uses Indiekit CSS custom properties (not hardcoded colors)
|
||||||
|
|
||||||
|
**Verify:**
|
||||||
|
|
||||||
|
- `playwright-cli open https://rmendes.net/admin/activitypub/reader` → screenshot → visual check
|
||||||
|
- `playwright-cli resize 375 812` → mobile check
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 6: Notifications Controller and View
|
||||||
|
|
||||||
|
**Objective:** Create the notifications page at `/admin/activitypub/reader/notifications` showing likes, boosts, follows, mentions, and replies received.
|
||||||
|
|
||||||
|
**Dependencies:** Task 3, Task 5
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
|
||||||
|
- Modify: `lib/controllers/reader.js` — Add notifications controller function
|
||||||
|
- Create: `views/activitypub-notifications.njk` — Notifications view (extends `layouts/reader.njk`)
|
||||||
|
- Create: `views/partials/ap-notification-card.njk` — Notification card partial
|
||||||
|
- Modify: `index.js` — Add notification route
|
||||||
|
- Modify: `locales/en.json` — Add notification i18n strings
|
||||||
|
|
||||||
|
**Key Decisions / Notes:**
|
||||||
|
|
||||||
|
- Route: `GET /admin/activitypub/reader/notifications`
|
||||||
|
- Notification card is more compact than timeline card: icon + actor name + action text + post excerpt + timestamp
|
||||||
|
- Group similar notifications? No — keep it chronological for simplicity
|
||||||
|
- Mark notifications as read when the page loads (set `read: true` on all displayed)
|
||||||
|
- Unread count shown as badge on "Reader" navigation item (combine timeline and notification counts)
|
||||||
|
- Notification type → display:
|
||||||
|
- `like`: "❤ {actor} liked your post {title}" with link to the post
|
||||||
|
- `boost`: "🔁 {actor} boosted your post {title}"
|
||||||
|
- `follow`: "👤 {actor} followed you" with link to their profile
|
||||||
|
- `reply`: "💬 {actor} replied to your post {title}" with content preview
|
||||||
|
- `mention`: "@ {actor} mentioned you" with content preview
|
||||||
|
- Pagination: same cursor-based pattern as timeline
|
||||||
|
|
||||||
|
**Definition of Done:**
|
||||||
|
|
||||||
|
- [ ] `/admin/activitypub/reader/notifications` renders notification stream
|
||||||
|
- [ ] Each notification type displays correctly with icon, actor, action, and target
|
||||||
|
- [ ] Notifications marked as read when page loads
|
||||||
|
- [ ] Unread count appears on Reader navigation badge
|
||||||
|
- [ ] Pagination works for notifications
|
||||||
|
- [ ] Empty state shown when no notifications
|
||||||
|
|
||||||
|
**Verify:**
|
||||||
|
|
||||||
|
- `curl -s https://rmendes.net/admin/activitypub/reader/notifications -H "Cookie: ..."` — renders HTML
|
||||||
|
- Check unread badge updates after viewing notifications
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 7a: Interaction API — Like and Boost Endpoints
|
||||||
|
|
||||||
|
**Objective:** Create the server-side API endpoints for Like, Unlike, Boost, and Unboost that send ActivityPub activities via Fedify.
|
||||||
|
|
||||||
|
**Dependencies:** Task 1, Task 4
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
|
||||||
|
- Create: `lib/controllers/interactions.js` — Handle like/boost/unlike/unboost POST requests (receives plugin instance via injection)
|
||||||
|
- Create: `lib/csrf.js` — Simple CSRF token generation and validation middleware
|
||||||
|
- Modify: `index.js` — Add interaction routes, inject plugin instance into controller (same pattern as `refollowPauseController(mp, this)` at `index.js:165-166`)
|
||||||
|
- Modify: `locales/en.json` — Add interaction i18n strings
|
||||||
|
|
||||||
|
**Key Decisions / Notes:**
|
||||||
|
|
||||||
|
- **CRITICAL — Federation context injection:** Regular controllers only have access to `request.app.locals.application` — they do NOT have `this._federation` or `this._collections`. The interaction controller needs federation context to call `ctx.sendActivity()`. Follow the refollow controller pattern: in `index.js`, pass the plugin instance when registering routes: `interactionController(mp, this)`. The controller factory returns route handlers with access to `pluginInstance._federation` and `pluginInstance._collections`. This same pattern is needed for ALL controllers that send ActivityPub activities (interactions, compose, moderation/block).
|
||||||
|
- **CSRF protection:** Generate a per-session CSRF token (store in `request.session.csrfToken`). Embed as hidden field in forms and as `X-CSRF-Token` header in `fetch()` requests. Validate on all POST endpoints before processing. Create `lib/csrf.js` with `generateToken(session)` and `validateToken(request)` functions.
|
||||||
|
- Routes:
|
||||||
|
- `POST /admin/activitypub/reader/like` — body: `{ url: "post-url", _csrf: "token" }` → sends Like activity
|
||||||
|
- `POST /admin/activitypub/reader/unlike` — body: `{ url: "post-url", _csrf: "token" }` → sends Undo(Like)
|
||||||
|
- `POST /admin/activitypub/reader/boost` — body: `{ url: "post-url", _csrf: "token" }` → sends Announce activity
|
||||||
|
- `POST /admin/activitypub/reader/unboost` — body: `{ url: "post-url", _csrf: "token" }` → sends Undo(Announce)
|
||||||
|
- Implementation pattern (like):
|
||||||
|
1. Validate CSRF token
|
||||||
|
2. Look up the post author via the post URL using `ctx.lookupObject(url)`
|
||||||
|
3. Construct a `Like` activity with the post as object
|
||||||
|
4. Send via `ctx.sendActivity({ identifier: handle }, recipient, likeActivity)`
|
||||||
|
5. Store the interaction in `ap_interactions` collection
|
||||||
|
6. Return JSON response `{ success: true, type: "like", objectUrl: "..." }`
|
||||||
|
- For Announce (boost): construct `Announce` activity wrapping the original post, send to followers via shared inbox
|
||||||
|
- Track interactions in `ap_interactions` collection `{ type: "like"|"boost", objectUrl: "...", activityId: "urn:uuid:...", createdAt: "ISO" }` — allows undo by looking up the activity ID
|
||||||
|
- Error handling: return JSON `{ success: false, error: "message" }` with appropriate HTTP status
|
||||||
|
|
||||||
|
**Definition of Done:**
|
||||||
|
|
||||||
|
- [ ] Like endpoint sends Like activity to remote actor's inbox
|
||||||
|
- [ ] Unlike endpoint sends Undo(Like) activity
|
||||||
|
- [ ] Boost endpoint sends Announce activity to followers
|
||||||
|
- [ ] Unboost endpoint sends Undo(Announce) activity
|
||||||
|
- [ ] CSRF token validated on all POST endpoints
|
||||||
|
- [ ] Interaction tracking persisted in `ap_interactions`
|
||||||
|
- [ ] JSON response returned for all endpoints
|
||||||
|
- [ ] Tests verify activity construction and sending
|
||||||
|
|
||||||
|
**Verify:**
|
||||||
|
|
||||||
|
- Like a post via `curl -X POST .../reader/like -d '{"url":"...","_csrf":"..."}'` → check JSON response
|
||||||
|
- Verify `ap_interactions` collection has the record
|
||||||
|
- Check remote instance shows the like (manual)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 7b: Interaction UI — Like and Boost Buttons
|
||||||
|
|
||||||
|
**Objective:** Add Alpine.js-powered like/boost buttons to timeline cards with optimistic updates and error handling.
|
||||||
|
|
||||||
|
**Dependencies:** Task 7a
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
|
||||||
|
- Modify: `views/partials/ap-item-card.njk` — Add like/boost buttons with Alpine.js reactivity
|
||||||
|
- Modify: `lib/controllers/reader.js` — Query `ap_interactions` on timeline load to populate liked/boosted state, pass CSRF token to template
|
||||||
|
- Modify: `assets/reader.css` — Add interaction button styles (if not already in Task 5)
|
||||||
|
|
||||||
|
**Key Decisions / Notes:**
|
||||||
|
|
||||||
|
- Use Alpine.js `x-data` on each card to track `liked` and `boosted` state — initialized from server data
|
||||||
|
- Timeline controller queries `ap_interactions` for all displayed item URLs, builds a Set of liked/boosted URLs, passes to template
|
||||||
|
- Button click makes `fetch()` POST with CSRF token in `X-CSRF-Token` header, toggles visual state immediately (optimistic update)
|
||||||
|
- Error handling: if the API returns `{ success: false }`, revert the visual state and show a brief error message
|
||||||
|
- Button styling: heart icon for like (filled when liked), repost icon for boost (highlighted when boosted)
|
||||||
|
|
||||||
|
**Definition of Done:**
|
||||||
|
|
||||||
|
- [ ] Like/boost buttons appear on every timeline card
|
||||||
|
- [ ] Button state reflects server state on page load (already-liked/boosted show active)
|
||||||
|
- [ ] Clicking like sends POST and toggles button visually
|
||||||
|
- [ ] Clicking boost sends POST and toggles button visually
|
||||||
|
- [ ] Failed interactions revert button state and show error
|
||||||
|
- [ ] CSRF token included in all fetch() requests
|
||||||
|
|
||||||
|
**Verify:**
|
||||||
|
|
||||||
|
- `playwright-cli open .../reader` → find a post → click like → verify button state changes
|
||||||
|
- Reload page → verify liked state persists
|
||||||
|
- Unlike → verify button reverts
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 8: Compose Form — Micropub Reply Path
|
||||||
|
|
||||||
|
**Objective:** Add a compose form for replying to posts, with the option to submit via Micropub (creating a blog post) or via direct AP reply.
|
||||||
|
|
||||||
|
**Dependencies:** Task 4, Task 7a
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
|
||||||
|
- Modify: `lib/controllers/reader.js` — Add compose and submitCompose functions
|
||||||
|
- Create: `views/activitypub-compose.njk` — Compose form view
|
||||||
|
- Modify: `views/partials/ap-item-card.njk` — Add reply button linking to compose
|
||||||
|
- Modify: `index.js` — Add compose routes
|
||||||
|
- Modify: `locales/en.json` — Add compose i18n strings
|
||||||
|
|
||||||
|
**Key Decisions / Notes:**
|
||||||
|
|
||||||
|
- Routes:
|
||||||
|
- `GET /admin/activitypub/reader/compose?replyTo=url` — Show compose form
|
||||||
|
- `POST /admin/activitypub/reader/compose` — Submit reply
|
||||||
|
- Compose form has two submit paths (radio toggle):
|
||||||
|
1. **"Post as blog reply" (Micropub)** — Submits to Micropub endpoint as `in-reply-to` + `content`, creating a permanent blog post that gets syndicated to AP via the existing syndicator pipeline
|
||||||
|
2. **"Quick reply" (Direct AP)** — Constructs a Create(Note) activity with `inReplyTo` and sends directly via `ctx.sendActivity()` to the author's inbox + followers. No blog post created.
|
||||||
|
- The form pattern borrows from Microsub compose (`views/compose.njk`): textarea, hidden in-reply-to field, syndication target checkboxes (for Micropub path)
|
||||||
|
- For the quick reply path: the Note is ephemeral (not stored as a blog post) but IS stored in the timeline as the user's own post
|
||||||
|
- Fetch syndication targets from Micropub config endpoint (same pattern as Microsub compose at `reader.js:403-407`)
|
||||||
|
- **Micropub endpoint discovery:** Access via `request.app.locals.application.micropubEndpoint` (same as Microsub). Auth token from `request.session.access_token`. Build absolute URL from relative endpoint path using `application.url` as base.
|
||||||
|
- Character counter for quick reply mode (AP convention: 500 chars)
|
||||||
|
- Reply context: show the parent post above the compose form (fetch via stored timeline item or `ctx.lookupObject()`)
|
||||||
|
- **Federation context injection:** The compose controller needs plugin instance for the direct AP reply path (same `ctx.sendActivity()` pattern as Task 7a). Register via same injection pattern.
|
||||||
|
- **CSRF protection:** Both form submit paths must validate CSRF token (reuse `lib/csrf.js` from Task 7a)
|
||||||
|
|
||||||
|
**Definition of Done:**
|
||||||
|
|
||||||
|
- [ ] Compose form renders with reply context (parent post preview)
|
||||||
|
- [ ] "Post as blog reply" submits via Micropub and redirects back to reader
|
||||||
|
- [ ] "Quick reply" sends Create(Note) directly via Fedify
|
||||||
|
- [ ] Quick reply includes proper `inReplyTo` reference
|
||||||
|
- [ ] Quick reply is delivered to the original author's inbox
|
||||||
|
- [ ] Syndication targets appear for Micropub path
|
||||||
|
- [ ] Character counter works in quick reply mode
|
||||||
|
- [ ] Error handling for both paths
|
||||||
|
|
||||||
|
**Verify:**
|
||||||
|
|
||||||
|
- Post a Micropub reply → verify blog post created and syndicated
|
||||||
|
- Post a quick reply → verify it appears on the remote instance as a reply
|
||||||
|
- Check `in-reply-to` is correctly set in both cases
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 9: Content Warning Toggles and Rich Media Rendering
|
||||||
|
|
||||||
|
**Objective:** Implement content warning spoiler toggle (click to reveal), image gallery grid, and video/audio embeds in timeline cards.
|
||||||
|
|
||||||
|
**Dependencies:** Task 4, Task 5
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
|
||||||
|
- Modify: `views/partials/ap-item-card.njk` — Add CW toggle, gallery grid, video/audio
|
||||||
|
- Modify: `assets/reader.css` — Add styles for CW, gallery, video, audio
|
||||||
|
|
||||||
|
**Key Decisions / Notes:**
|
||||||
|
|
||||||
|
- **Content warnings:** Posts with `summary` field (Mastodon CW) render as:
|
||||||
|
- Visible: CW text (the summary)
|
||||||
|
- Hidden (behind button): The actual content + media
|
||||||
|
- Alpine.js `x-data="{ revealed: false }"` + `x-show="revealed"` + `@click="revealed = !revealed"`
|
||||||
|
- Button text toggles: "Show more" / "Show less"
|
||||||
|
- `sensitive: true` without summary: "Sensitive content" as default CW text
|
||||||
|
- **Image gallery:**
|
||||||
|
- 1 image: Full width, max-height with object-fit: cover
|
||||||
|
- 2 images: Side-by-side (50/50 grid)
|
||||||
|
- 3 images: First image full width, second and third side-by-side below
|
||||||
|
- 4+ images: 2x2 grid, "+N more" overlay on 4th image if >4
|
||||||
|
- All images rounded corners, gap between
|
||||||
|
- Click to expand? Lightbox is out of scope — just link to full image
|
||||||
|
- **Video:** `<video>` tag with controls, poster if available, responsive wrapper
|
||||||
|
- **Audio:** `<audio>` tag with controls, full width
|
||||||
|
- **Polls:** Render poll options as a list with vote counts if available (read-only display)
|
||||||
|
|
||||||
|
**Definition of Done:**
|
||||||
|
|
||||||
|
- [ ] Content warnings display summary text with "Show more" button
|
||||||
|
- [ ] Clicking "Show more" reveals hidden content and media
|
||||||
|
- [ ] Clicking "Show less" re-hides content
|
||||||
|
- [ ] Image gallery renders correctly for 1, 2, 3, and 4+ images
|
||||||
|
- [ ] Videos render with native player controls
|
||||||
|
- [ ] Audio renders with native player controls
|
||||||
|
- [ ] Sensitive posts without summary show "Sensitive content" label
|
||||||
|
|
||||||
|
**Verify:**
|
||||||
|
|
||||||
|
- `playwright-cli open https://rmendes.net/admin/activitypub/reader`
|
||||||
|
- Find a post with CW → click "Show more" → content reveals
|
||||||
|
- Find a post with multiple images → verify grid layout
|
||||||
|
- `playwright-cli snapshot` → verify structure
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 10: Mute, Block, and Tab Filtering
|
||||||
|
|
||||||
|
**Objective:** Add mute/block functionality for actors and keywords, and implement tab-based timeline filtering.
|
||||||
|
|
||||||
|
**Dependencies:** Task 1, Task 4
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
|
||||||
|
- Create: `lib/controllers/moderation.js` — Mute/block controller
|
||||||
|
- Modify: `lib/controllers/reader.js` — Add tab filtering logic, mute/block from profile
|
||||||
|
- Create: `views/activitypub-moderation.njk` — Moderation settings page (list muted/blocked)
|
||||||
|
- Modify: `views/partials/ap-item-card.njk` — Add mute/block in item card dropdown menu
|
||||||
|
- Modify: `index.js` — Add moderation routes
|
||||||
|
- Modify: `locales/en.json` — Add moderation i18n strings
|
||||||
|
|
||||||
|
**Key Decisions / Notes:**
|
||||||
|
|
||||||
|
- Routes:
|
||||||
|
- `POST /admin/activitypub/reader/mute` — body: `{ url: "actor-url" }` or `{ keyword: "text" }`
|
||||||
|
- `POST /admin/activitypub/reader/unmute` — body: `{ url: "actor-url" }` or `{ keyword: "text" }`
|
||||||
|
- `POST /admin/activitypub/reader/block` — body: `{ url: "actor-url" }` → also sends Block activity
|
||||||
|
- `POST /admin/activitypub/reader/unblock` — body: `{ url: "actor-url" }` → sends Undo(Block)
|
||||||
|
- `GET /admin/activitypub/reader/moderation` — View muted/blocked lists
|
||||||
|
- Mute: hide from timeline but don't notify the remote actor. Filter at query time: exclude items where `author.url` is in muted list or content matches muted keyword
|
||||||
|
- Block: send `Block` activity to remote actor via `ctx.sendActivity()` AND hide from timeline. On block: also remove existing timeline items from that actor. **Federation context injection** needed for Block/Undo(Block) — same plugin instance pattern as Task 7a.
|
||||||
|
- **CSRF protection:** All POST endpoints (mute/unmute/block/unblock) must validate CSRF token (reuse `lib/csrf.js` from Task 7a)
|
||||||
|
- Tab filtering implementation: `getTimelineItems()` accepts a `type` parameter. Map tabs:
|
||||||
|
- All → no filter
|
||||||
|
- Notes → `type: "note"`
|
||||||
|
- Articles → `type: "article"`
|
||||||
|
- Replies → items where `inReplyTo` is not null
|
||||||
|
- Boosts → `type: "boost"`
|
||||||
|
- Media → items where `photo`, `video`, or `audio` arrays are non-empty
|
||||||
|
- Each tab shows a count badge? No — too expensive on every page load. Just tab labels.
|
||||||
|
- Card dropdown (three dots menu): "Mute @user", "Block @user", "Mute keyword..."
|
||||||
|
|
||||||
|
**Definition of Done:**
|
||||||
|
|
||||||
|
- [ ] Muting an actor hides their posts from timeline
|
||||||
|
- [ ] Muting a keyword hides matching posts from timeline
|
||||||
|
- [ ] Blocking an actor sends Block activity and removes their posts
|
||||||
|
- [ ] Unblocking sends Undo(Block)
|
||||||
|
- [ ] Moderation settings page lists all muted actors, keywords, and blocked actors
|
||||||
|
- [ ] Can unmute/unblock from the settings page
|
||||||
|
- [ ] Tab filtering returns correct subset of timeline items
|
||||||
|
- [ ] Card dropdown has mute/block actions
|
||||||
|
|
||||||
|
**Verify:**
|
||||||
|
|
||||||
|
- Mute an actor → verify their posts disappear from timeline
|
||||||
|
- Block an actor → verify Block activity sent + posts removed
|
||||||
|
- Switch between tabs → verify correct filtering
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 11: Remote Profile View
|
||||||
|
|
||||||
|
**Objective:** Create a profile page for viewing remote actors, showing their info and recent posts, with follow/unfollow, mute, and block buttons.
|
||||||
|
|
||||||
|
**Dependencies:** Task 4, Task 7b, Task 10
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
|
||||||
|
- Modify: `lib/controllers/reader.js` — Add profile controller function
|
||||||
|
- Create: `views/activitypub-remote-profile.njk` — Remote actor profile view (**NOT** `activitypub-profile.njk` — that file already exists for the user's own profile editor)
|
||||||
|
- Modify: `assets/reader.css` — Add profile view styles
|
||||||
|
- Modify: `index.js` — Add profile route
|
||||||
|
- Modify: `locales/en.json` — Add profile i18n strings
|
||||||
|
|
||||||
|
**Key Decisions / Notes:**
|
||||||
|
|
||||||
|
- Route: `GET /admin/activitypub/reader/profile?url=actor-url` or `GET /admin/activitypub/reader/profile?handle=@user@instance`
|
||||||
|
- Fetch actor info via `ctx.lookupObject(url)` — returns Fedify Actor with name, summary, icon, image, followerCount, followingCount, etc.
|
||||||
|
- Show: avatar, header image, display name, handle, bio, follower/following counts, profile links
|
||||||
|
- Show recent posts from that actor in the timeline (filter `ap_timeline` by `author.url`)
|
||||||
|
- If the actor is not followed, posts won't be in the local timeline — show a message "Follow to see their posts" or attempt to fetch their outbox via `ctx.traverseCollection(outbox)` (limited, slow)
|
||||||
|
- Decision: For now, only show locally-stored posts (from following). If not following, show profile info only with a "Follow to see their posts in your timeline" CTA
|
||||||
|
- Action buttons: Follow/Unfollow (reuse existing `followActor`/`unfollowActor` methods from `index.js`), Mute, Block
|
||||||
|
- Link to external profile: "View on {instance}" link to the actor's URL
|
||||||
|
|
||||||
|
**Definition of Done:**
|
||||||
|
|
||||||
|
- [ ] Profile page renders remote actor info (avatar, name, handle, bio)
|
||||||
|
- [ ] Profile shows header image if available
|
||||||
|
- [ ] Profile shows follower/following counts
|
||||||
|
- [ ] Posts from that actor shown below profile (if following)
|
||||||
|
- [ ] Follow/unfollow button works
|
||||||
|
- [ ] Mute/block buttons work from profile
|
||||||
|
- [ ] "View on {instance}" external link present
|
||||||
|
- [ ] Graceful handling when actor lookup fails
|
||||||
|
|
||||||
|
**Verify:**
|
||||||
|
|
||||||
|
- Navigate to a followed actor's profile → verify info and posts display
|
||||||
|
- Follow/unfollow from profile → verify state changes
|
||||||
|
- Navigate to an unknown handle → verify graceful error
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 12: Remove Microsub Bridge
|
||||||
|
|
||||||
|
**Objective:** Remove all Microsub bridge code from the AP plugin — `storeTimelineItem()`, `getApChannelId()`, and the lazy `microsub_items`/`microsub_channels` collection accessors.
|
||||||
|
|
||||||
|
**Dependencies:** Task 2, Task 3, Task 4, Task 6 (all reader functionality must be working first)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
|
||||||
|
- Modify: `lib/inbox-listeners.js` — Remove `storeTimelineItem()` function (lines 455-576), remove `getApChannelId()` function (lines 400-453), remove any remaining calls to these functions
|
||||||
|
- Modify: `index.js` — Remove lazy `microsub_items` and `microsub_channels` getter/accessors (lines 638-643), remove any `microsub` references from collection handling
|
||||||
|
- Modify: `lib/inbox-listeners.js` — Remove the `storeTimelineItem()` call in the Create handler (should already be replaced in Task 2, but verify)
|
||||||
|
|
||||||
|
**Key Decisions / Notes:**
|
||||||
|
|
||||||
|
- This is a cleanup task — all replacement functionality should already be working via Tasks 2-6
|
||||||
|
- The Microsub plugin itself remains fully functional — it still manages its own RSS/Atom feeds, channels, and items. We're only removing the AP plugin's code that bridges INTO Microsub collections
|
||||||
|
- After removal, the `microsub_items` collection may still contain old AP items (with `source.type: "activitypub"`) — these can be left in place or cleaned up manually by the user
|
||||||
|
- Verify that the Microsub plugin's "Fediverse" channel still works for non-AP content (it's created by `getApChannelId` which we're removing). If no non-AP content uses it, the channel becomes orphaned — that's fine.
|
||||||
|
- Test that the AP plugin starts cleanly without any Microsub collections referenced
|
||||||
|
- Bump version in `package.json` for this change since it removes a dependency
|
||||||
|
|
||||||
|
**Definition of Done:**
|
||||||
|
|
||||||
|
- [ ] `storeTimelineItem()` function removed from `inbox-listeners.js`
|
||||||
|
- [ ] `getApChannelId()` function removed from `inbox-listeners.js`
|
||||||
|
- [ ] No references to `microsub_items` or `microsub_channels` in any AP plugin file
|
||||||
|
- [ ] No `import` or `require` of Microsub-related modules
|
||||||
|
- [ ] Plugin starts without errors when Microsub plugin is not loaded
|
||||||
|
- [ ] Plugin starts without errors when Microsub plugin IS loaded (no conflict)
|
||||||
|
- [ ] Existing AP timeline/notification functionality unaffected
|
||||||
|
- [ ] Version bumped in `package.json`
|
||||||
|
|
||||||
|
**Verify:**
|
||||||
|
|
||||||
|
- `grep -r "microsub" /home/rick/code/indiekit-dev/indiekit-endpoint-activitypub/lib/ /home/rick/code/indiekit-dev/indiekit-endpoint-activitypub/index.js` — returns zero matches
|
||||||
|
- `node -e "import('./index.js')"` — plugin loads without errors
|
||||||
|
- Deploy to Cloudron → verify reader works, verify Microsub reader still works independently
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 13: Timeline Retention Cleanup
|
||||||
|
|
||||||
|
**Objective:** Implement automatic cleanup of old timeline items to prevent unbounded collection growth.
|
||||||
|
|
||||||
|
**Dependencies:** Task 1, Task 2
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
|
||||||
|
- Create: `lib/timeline-cleanup.js` — Retention cleanup function
|
||||||
|
- Modify: `index.js` — Schedule periodic cleanup (e.g., on server startup and via a setInterval)
|
||||||
|
|
||||||
|
**Key Decisions / Notes:**
|
||||||
|
|
||||||
|
- Keep the last 1000 timeline items (configurable via plugin options: `timelineRetention: 1000`)
|
||||||
|
- Cleanup runs on plugin `init()` and then every 24 hours via `setInterval`
|
||||||
|
- Implementation: `ap_timeline.deleteMany({ published: { $lt: oldestKeepDate } })` — find the published date of the 1000th newest item, delete everything older
|
||||||
|
- Alternative: count-based: `ap_timeline.find().sort({ published: -1 }).skip(1000).forEach(doc => delete)`
|
||||||
|
- Decision: Use count-based approach — simpler, handles edge cases where many items share the same date
|
||||||
|
- Also clean up corresponding `ap_interactions` entries for deleted timeline items (remove stale like/boost tracking)
|
||||||
|
- Log cleanup results: "Timeline cleanup: removed N items older than {date}"
|
||||||
|
|
||||||
|
**Definition of Done:**
|
||||||
|
|
||||||
|
- [ ] Cleanup function removes items beyond retention limit
|
||||||
|
- [ ] Cleanup runs on startup and periodically
|
||||||
|
- [ ] Retention limit is configurable via plugin options
|
||||||
|
- [ ] Stale `ap_interactions` entries cleaned up alongside timeline items
|
||||||
|
- [ ] Cleanup logged for diagnostics
|
||||||
|
- [ ] Tests verify retention limit is enforced
|
||||||
|
|
||||||
|
**Verify:**
|
||||||
|
|
||||||
|
- Insert 1050 test items → run cleanup → verify only 1000 remain
|
||||||
|
- Verify `ap_interactions` for removed items are also deleted
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Testing Strategy
|
||||||
|
|
||||||
|
- **Unit tests:** Storage functions (timeline CRUD, notification CRUD, moderation CRUD), data extraction helpers (`extractObjectData`, `extractActorInfo`), tab filtering logic
|
||||||
|
- **Integration tests:** Bash-based tests in `/home/rick/code/indiekit-dev/activitypub-tests/` — add new tests for reader endpoints (authenticated GET requests), interaction endpoints (POST like/boost), notification counts
|
||||||
|
- **Manual verification:**
|
||||||
|
- Use `playwright-cli` to verify reader UI renders correctly
|
||||||
|
- Send real AP interactions from a test Mastodon account to verify inbox→timeline→notification flow
|
||||||
|
- Compose replies via both paths (Micropub and direct AP) and verify they appear on remote instances
|
||||||
|
|
||||||
|
## Risks and Mitigations
|
||||||
|
|
||||||
|
| Risk | Likelihood | Impact | Mitigation |
|
||||||
|
|------|-----------|--------|------------|
|
||||||
|
| `ctx.lookupObject()` slow for remote actors (profile view) | High | Medium | Cache actor info in `ap_timeline` author fields; only call lookupObject once per profile visit, not per card |
|
||||||
|
| `ctx.sendActivity()` for likes/boosts may fail silently | Medium | Medium | Store interaction attempt in `ap_interactions` with status field; show error state in UI if delivery fails |
|
||||||
|
| Content warnings/sensitive flag not consistently set by remote servers | Medium | Low | Treat `summary` presence as CW signal (Mastodon convention); fall back to "Sensitive content" for `sensitive: true` without summary |
|
||||||
|
| Image gallery CSS breaks with very large images | Low | Low | Use `object-fit: cover` with max-height constraints; all images in grid cells |
|
||||||
|
| Removing Microsub bridge while user still has AP items in Microsub channel | Medium | Low | Leave existing items in `microsub_items` untouched; they'll still be readable through the Microsub reader. Only new AP items go to `ap_timeline` |
|
||||||
|
| Alpine.js optimistic updates for like/boost may desync with server state | Medium | Low | On page reload, always read server state from timeline items; track interactions in `ap_interactions` collection |
|
||||||
|
| CSRF attacks on POST endpoints could trigger unwanted AP activities | Medium | High | All POST endpoints validate per-session CSRF token via `lib/csrf.js`; token embedded in forms and `fetch()` headers |
|
||||||
|
| Timeline collection grows unbounded | High | Medium | Task 13 implements automatic retention cleanup (keep last 1000 items, configurable) |
|
||||||
|
| Announce wraps a deleted/inaccessible object | Medium | Low | If `activity.getObject()` returns null or fails, skip storing the boost and log a warning. Don't crash the inbox handler. |
|
||||||
|
| Remote actor lookup fails during profile view | Medium | Low | Show error message "Could not load profile — the server may be temporarily unavailable" with retry link. Don't crash the page. |
|
||||||
|
|
||||||
|
## Open Questions
|
||||||
|
|
||||||
|
- Should there be a "Refresh timeline" button/action, or does it automatically show new items on page reload? → Decision: Automatic on reload for MVP; real-time updates (SSE/polling) deferred
|
||||||
|
- Should the AP reader be the default landing page when navigating to `/admin/activitypub/`? → Decision: Yes, redirect `/admin/activitypub/` to `/admin/activitypub/reader` as the primary view. Dashboard remains accessible via sub-navigation within the reader layout. The top-level sidebar `get navigationItems()` returns "Reader" linking to `/activitypub/reader`.
|
||||||
|
- What's the maximum number of timeline items to store before cleanup? → Decision: Keep last 1000 items; auto-delete older items on a weekly basis
|
||||||
|
|
||||||
|
### Deferred Ideas
|
||||||
|
|
||||||
|
- Real-time timeline updates via Server-Sent Events (SSE) or periodic polling
|
||||||
|
- Lists feature (organizing follows into named groups with separate timelines)
|
||||||
|
- Thread view (expanding full conversation thread from a reply)
|
||||||
|
- Mastodon REST API compatibility layer for mobile clients
|
||||||
|
- Push notifications for new mentions/replies
|
||||||
|
- Image lightbox for gallery view
|
||||||
|
- Infinite scroll instead of pagination
|
||||||
|
- Timeline item search
|
||||||
124
index.js
124
index.js
@@ -9,6 +9,28 @@ import {
|
|||||||
jf2ToAS2Activity,
|
jf2ToAS2Activity,
|
||||||
} from "./lib/jf2-to-as2.js";
|
} from "./lib/jf2-to-as2.js";
|
||||||
import { dashboardController } from "./lib/controllers/dashboard.js";
|
import { dashboardController } from "./lib/controllers/dashboard.js";
|
||||||
|
import {
|
||||||
|
readerController,
|
||||||
|
notificationsController,
|
||||||
|
composeController,
|
||||||
|
submitComposeController,
|
||||||
|
remoteProfileController,
|
||||||
|
followController,
|
||||||
|
unfollowController,
|
||||||
|
} from "./lib/controllers/reader.js";
|
||||||
|
import {
|
||||||
|
likeController,
|
||||||
|
unlikeController,
|
||||||
|
boostController,
|
||||||
|
unboostController,
|
||||||
|
} from "./lib/controllers/interactions.js";
|
||||||
|
import {
|
||||||
|
muteController,
|
||||||
|
unmuteController,
|
||||||
|
blockController,
|
||||||
|
unblockController,
|
||||||
|
moderationController,
|
||||||
|
} from "./lib/controllers/moderation.js";
|
||||||
import { followersController } from "./lib/controllers/followers.js";
|
import { followersController } from "./lib/controllers/followers.js";
|
||||||
import { followingController } from "./lib/controllers/following.js";
|
import { followingController } from "./lib/controllers/following.js";
|
||||||
import { activitiesController } from "./lib/controllers/activities.js";
|
import { activitiesController } from "./lib/controllers/activities.js";
|
||||||
@@ -38,6 +60,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 { scheduleCleanup } from "./lib/timeline-cleanup.js";
|
||||||
|
|
||||||
const defaults = {
|
const defaults = {
|
||||||
mountPath: "/activitypub",
|
mountPath: "/activitypub",
|
||||||
@@ -54,6 +77,7 @@ const defaults = {
|
|||||||
redisUrl: "",
|
redisUrl: "",
|
||||||
parallelWorkers: 5,
|
parallelWorkers: 5,
|
||||||
actorType: "Person",
|
actorType: "Person",
|
||||||
|
timelineRetention: 1000,
|
||||||
};
|
};
|
||||||
|
|
||||||
export default class ActivityPubEndpoint {
|
export default class ActivityPubEndpoint {
|
||||||
@@ -72,8 +96,8 @@ export default class ActivityPubEndpoint {
|
|||||||
|
|
||||||
get navigationItems() {
|
get navigationItems() {
|
||||||
return {
|
return {
|
||||||
href: this.options.mountPath,
|
href: `${this.options.mountPath}/admin/reader`,
|
||||||
text: "activitypub.title",
|
text: "activitypub.reader.title",
|
||||||
requiresDatabase: true,
|
requiresDatabase: true,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -145,6 +169,22 @@ export default class ActivityPubEndpoint {
|
|||||||
const mp = this.options.mountPath;
|
const mp = this.options.mountPath;
|
||||||
|
|
||||||
router.get("/", dashboardController(mp));
|
router.get("/", dashboardController(mp));
|
||||||
|
router.get("/admin/reader", readerController(mp));
|
||||||
|
router.get("/admin/reader/notifications", notificationsController(mp));
|
||||||
|
router.get("/admin/reader/compose", composeController(mp, this));
|
||||||
|
router.post("/admin/reader/compose", submitComposeController(mp, this));
|
||||||
|
router.post("/admin/reader/like", likeController(mp, this));
|
||||||
|
router.post("/admin/reader/unlike", unlikeController(mp, this));
|
||||||
|
router.post("/admin/reader/boost", boostController(mp, this));
|
||||||
|
router.post("/admin/reader/unboost", unboostController(mp, this));
|
||||||
|
router.get("/admin/reader/profile", remoteProfileController(mp, this));
|
||||||
|
router.post("/admin/reader/follow", followController(mp, this));
|
||||||
|
router.post("/admin/reader/unfollow", unfollowController(mp, this));
|
||||||
|
router.get("/admin/reader/moderation", moderationController(mp));
|
||||||
|
router.post("/admin/reader/mute", muteController(mp, this));
|
||||||
|
router.post("/admin/reader/unmute", unmuteController(mp, this));
|
||||||
|
router.post("/admin/reader/block", blockController(mp, this));
|
||||||
|
router.post("/admin/reader/unblock", unblockController(mp, this));
|
||||||
router.get("/admin/followers", followersController(mp));
|
router.get("/admin/followers", followersController(mp));
|
||||||
router.get("/admin/following", followingController(mp));
|
router.get("/admin/following", followingController(mp));
|
||||||
router.get("/admin/activities", activitiesController(mp));
|
router.get("/admin/activities", activitiesController(mp));
|
||||||
@@ -483,7 +523,7 @@ export default class ActivityPubEndpoint {
|
|||||||
inbox,
|
inbox,
|
||||||
sharedInbox,
|
sharedInbox,
|
||||||
followedAt: new Date().toISOString(),
|
followedAt: new Date().toISOString(),
|
||||||
source: "microsub-reader",
|
source: "reader",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{ upsert: true },
|
{ upsert: true },
|
||||||
@@ -619,6 +659,12 @@ export default class ActivityPubEndpoint {
|
|||||||
Indiekit.addCollection("ap_profile");
|
Indiekit.addCollection("ap_profile");
|
||||||
Indiekit.addCollection("ap_featured");
|
Indiekit.addCollection("ap_featured");
|
||||||
Indiekit.addCollection("ap_featured_tags");
|
Indiekit.addCollection("ap_featured_tags");
|
||||||
|
// Reader collections
|
||||||
|
Indiekit.addCollection("ap_timeline");
|
||||||
|
Indiekit.addCollection("ap_notifications");
|
||||||
|
Indiekit.addCollection("ap_muted");
|
||||||
|
Indiekit.addCollection("ap_blocked");
|
||||||
|
Indiekit.addCollection("ap_interactions");
|
||||||
|
|
||||||
// Store collection references (posts resolved lazily)
|
// Store collection references (posts resolved lazily)
|
||||||
const indiekitCollections = Indiekit.collections;
|
const indiekitCollections = Indiekit.collections;
|
||||||
@@ -631,16 +677,15 @@ export default class ActivityPubEndpoint {
|
|||||||
ap_profile: indiekitCollections.get("ap_profile"),
|
ap_profile: indiekitCollections.get("ap_profile"),
|
||||||
ap_featured: indiekitCollections.get("ap_featured"),
|
ap_featured: indiekitCollections.get("ap_featured"),
|
||||||
ap_featured_tags: indiekitCollections.get("ap_featured_tags"),
|
ap_featured_tags: indiekitCollections.get("ap_featured_tags"),
|
||||||
|
// Reader collections
|
||||||
|
ap_timeline: indiekitCollections.get("ap_timeline"),
|
||||||
|
ap_notifications: indiekitCollections.get("ap_notifications"),
|
||||||
|
ap_muted: indiekitCollections.get("ap_muted"),
|
||||||
|
ap_blocked: indiekitCollections.get("ap_blocked"),
|
||||||
|
ap_interactions: indiekitCollections.get("ap_interactions"),
|
||||||
get posts() {
|
get posts() {
|
||||||
return indiekitCollections.get("posts");
|
return indiekitCollections.get("posts");
|
||||||
},
|
},
|
||||||
// Lazy access to Microsub collections (may not exist if plugin not loaded)
|
|
||||||
get microsub_items() {
|
|
||||||
return indiekitCollections.get("microsub_items");
|
|
||||||
},
|
|
||||||
get microsub_channels() {
|
|
||||||
return indiekitCollections.get("microsub_channels");
|
|
||||||
},
|
|
||||||
_publicationUrl: this._publicationUrl,
|
_publicationUrl: this._publicationUrl,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -675,6 +720,60 @@ export default class ActivityPubEndpoint {
|
|||||||
{ background: true },
|
{ background: true },
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Reader indexes (timeline, notifications, moderation, interactions)
|
||||||
|
this._collections.ap_timeline.createIndex(
|
||||||
|
{ uid: 1 },
|
||||||
|
{ unique: true, background: true },
|
||||||
|
);
|
||||||
|
this._collections.ap_timeline.createIndex(
|
||||||
|
{ published: -1 },
|
||||||
|
{ background: true },
|
||||||
|
);
|
||||||
|
this._collections.ap_timeline.createIndex(
|
||||||
|
{ "author.url": 1 },
|
||||||
|
{ background: true },
|
||||||
|
);
|
||||||
|
this._collections.ap_timeline.createIndex(
|
||||||
|
{ type: 1, published: -1 },
|
||||||
|
{ background: true },
|
||||||
|
);
|
||||||
|
|
||||||
|
this._collections.ap_notifications.createIndex(
|
||||||
|
{ uid: 1 },
|
||||||
|
{ unique: true, background: true },
|
||||||
|
);
|
||||||
|
this._collections.ap_notifications.createIndex(
|
||||||
|
{ published: -1 },
|
||||||
|
{ background: true },
|
||||||
|
);
|
||||||
|
this._collections.ap_notifications.createIndex(
|
||||||
|
{ read: 1 },
|
||||||
|
{ background: true },
|
||||||
|
);
|
||||||
|
|
||||||
|
this._collections.ap_muted.createIndex(
|
||||||
|
{ url: 1 },
|
||||||
|
{ unique: true, sparse: true, background: true },
|
||||||
|
);
|
||||||
|
this._collections.ap_muted.createIndex(
|
||||||
|
{ keyword: 1 },
|
||||||
|
{ unique: true, sparse: true, background: true },
|
||||||
|
);
|
||||||
|
|
||||||
|
this._collections.ap_blocked.createIndex(
|
||||||
|
{ url: 1 },
|
||||||
|
{ unique: true, background: true },
|
||||||
|
);
|
||||||
|
|
||||||
|
this._collections.ap_interactions.createIndex(
|
||||||
|
{ objectUrl: 1, type: 1 },
|
||||||
|
{ unique: true, background: true },
|
||||||
|
);
|
||||||
|
this._collections.ap_interactions.createIndex(
|
||||||
|
{ type: 1 },
|
||||||
|
{ background: true },
|
||||||
|
);
|
||||||
|
|
||||||
// Seed actor profile from config on first run
|
// Seed actor profile from config on first run
|
||||||
this._seedProfile().catch((error) => {
|
this._seedProfile().catch((error) => {
|
||||||
console.warn("[ActivityPub] Profile seed failed:", error.message);
|
console.warn("[ActivityPub] Profile seed failed:", error.message);
|
||||||
@@ -720,6 +819,11 @@ export default class ActivityPubEndpoint {
|
|||||||
console.error("[ActivityPub] Batch refollow start failed:", error.message);
|
console.error("[ActivityPub] Batch refollow start failed:", error.message);
|
||||||
});
|
});
|
||||||
}, 10_000);
|
}, 10_000);
|
||||||
|
|
||||||
|
// Schedule timeline retention cleanup (runs on startup + every 24h)
|
||||||
|
if (this.options.timelineRetention > 0) {
|
||||||
|
scheduleCleanup(this._collections, this.options.timelineRetention);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
323
lib/controllers/compose.js
Normal file
323
lib/controllers/compose.js
Normal file
@@ -0,0 +1,323 @@
|
|||||||
|
/**
|
||||||
|
* Compose controllers — reply form via Micropub or direct AP.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Temporal } from "@js-temporal/polyfill";
|
||||||
|
import { getTimelineItem } from "../storage/timeline.js";
|
||||||
|
import { getToken, validateToken } from "../csrf.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch syndication targets from the Micropub config endpoint.
|
||||||
|
* @param {object} application - Indiekit application locals
|
||||||
|
* @param {string} token - Session access token
|
||||||
|
* @returns {Promise<Array>}
|
||||||
|
*/
|
||||||
|
async function getSyndicationTargets(application, token) {
|
||||||
|
try {
|
||||||
|
const micropubEndpoint = application.micropubEndpoint;
|
||||||
|
|
||||||
|
if (!micropubEndpoint) return [];
|
||||||
|
|
||||||
|
const micropubUrl = micropubEndpoint.startsWith("http")
|
||||||
|
? micropubEndpoint
|
||||||
|
: new URL(micropubEndpoint, application.url).href;
|
||||||
|
|
||||||
|
const configUrl = `${micropubUrl}?q=config`;
|
||||||
|
const configResponse = await fetch(configUrl, {
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
Accept: "application/json",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (configResponse.ok) {
|
||||||
|
const config = await configResponse.json();
|
||||||
|
return config["syndicate-to"] || [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [];
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /admin/reader/compose — Show compose form.
|
||||||
|
* @param {string} mountPath - Plugin mount path
|
||||||
|
* @param {object} plugin - ActivityPub plugin instance
|
||||||
|
*/
|
||||||
|
export function composeController(mountPath, plugin) {
|
||||||
|
return async (request, response, next) => {
|
||||||
|
try {
|
||||||
|
const { application } = request.app.locals;
|
||||||
|
const replyTo = request.query.replyTo || "";
|
||||||
|
|
||||||
|
// Fetch reply context (the post being replied to)
|
||||||
|
let replyContext = null;
|
||||||
|
|
||||||
|
if (replyTo) {
|
||||||
|
const collections = {
|
||||||
|
ap_timeline: application?.collections?.get("ap_timeline"),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Try to find the post in our timeline first
|
||||||
|
replyContext = await getTimelineItem(collections, replyTo);
|
||||||
|
|
||||||
|
// If not in timeline, try to look up remotely
|
||||||
|
if (!replyContext && plugin._federation) {
|
||||||
|
try {
|
||||||
|
const handle = plugin.options.actor.handle;
|
||||||
|
const ctx = plugin._federation.createContext(
|
||||||
|
new URL(plugin._publicationUrl),
|
||||||
|
{ handle, publicationUrl: plugin._publicationUrl },
|
||||||
|
);
|
||||||
|
const remoteObject = await ctx.lookupObject(new URL(replyTo));
|
||||||
|
|
||||||
|
if (remoteObject) {
|
||||||
|
let authorName = "";
|
||||||
|
let authorUrl = "";
|
||||||
|
|
||||||
|
if (typeof remoteObject.getAttributedTo === "function") {
|
||||||
|
const author = await remoteObject.getAttributedTo();
|
||||||
|
const actor = Array.isArray(author) ? author[0] : author;
|
||||||
|
|
||||||
|
if (actor) {
|
||||||
|
authorName =
|
||||||
|
actor.name?.toString() ||
|
||||||
|
actor.preferredUsername?.toString() ||
|
||||||
|
"";
|
||||||
|
authorUrl = actor.id?.href || "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
replyContext = {
|
||||||
|
url: replyTo,
|
||||||
|
name: remoteObject.name?.toString() || "",
|
||||||
|
content: {
|
||||||
|
text:
|
||||||
|
remoteObject.content?.toString()?.slice(0, 300) || "",
|
||||||
|
},
|
||||||
|
author: { name: authorName, url: authorUrl },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Could not resolve — form still works without context
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch syndication targets for Micropub path
|
||||||
|
const token = request.session?.access_token;
|
||||||
|
const syndicationTargets = token
|
||||||
|
? await getSyndicationTargets(application, token)
|
||||||
|
: [];
|
||||||
|
|
||||||
|
const csrfToken = getToken(request.session);
|
||||||
|
|
||||||
|
response.render("activitypub-compose", {
|
||||||
|
title: response.locals.__("activitypub.compose.title"),
|
||||||
|
replyTo,
|
||||||
|
replyContext,
|
||||||
|
syndicationTargets,
|
||||||
|
csrfToken,
|
||||||
|
mountPath,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /admin/reader/compose — Submit reply via Micropub or direct AP.
|
||||||
|
* @param {string} mountPath - Plugin mount path
|
||||||
|
* @param {object} plugin - ActivityPub plugin instance
|
||||||
|
*/
|
||||||
|
export function submitComposeController(mountPath, plugin) {
|
||||||
|
return async (request, response, next) => {
|
||||||
|
try {
|
||||||
|
if (!validateToken(request)) {
|
||||||
|
return response.status(403).render("error", {
|
||||||
|
title: "Error",
|
||||||
|
content: "Invalid CSRF token",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const { application } = request.app.locals;
|
||||||
|
const { content, mode } = request.body;
|
||||||
|
const inReplyTo = request.body["in-reply-to"];
|
||||||
|
const syndicateTo = request.body["mp-syndicate-to"];
|
||||||
|
|
||||||
|
if (!content || !content.trim()) {
|
||||||
|
return response.status(400).render("error", {
|
||||||
|
title: "Error",
|
||||||
|
content: response.locals.__("activitypub.compose.errorEmpty"),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Quick reply — direct AP
|
||||||
|
if (mode === "quick") {
|
||||||
|
if (!plugin._federation) {
|
||||||
|
return response.status(503).render("error", {
|
||||||
|
title: "Error",
|
||||||
|
content: "Federation not initialized",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const { Create, Note } = await import("@fedify/fedify");
|
||||||
|
const handle = plugin.options.actor.handle;
|
||||||
|
const ctx = plugin._federation.createContext(
|
||||||
|
new URL(plugin._publicationUrl),
|
||||||
|
{ handle, publicationUrl: plugin._publicationUrl },
|
||||||
|
);
|
||||||
|
|
||||||
|
const noteId = `urn:uuid:${crypto.randomUUID()}`;
|
||||||
|
const actorUri = ctx.getActorUri(handle);
|
||||||
|
|
||||||
|
const note = new Note({
|
||||||
|
id: new URL(noteId),
|
||||||
|
attribution: actorUri,
|
||||||
|
content: content.trim(),
|
||||||
|
inReplyTo: inReplyTo ? new URL(inReplyTo) : undefined,
|
||||||
|
published: Temporal.Now.instant(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const create = new Create({
|
||||||
|
id: new URL(`${noteId}#activity`),
|
||||||
|
actor: actorUri,
|
||||||
|
object: note,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Send to followers
|
||||||
|
await ctx.sendActivity({ identifier: handle }, "followers", create, {
|
||||||
|
preferSharedInbox: true,
|
||||||
|
syncCollection: true,
|
||||||
|
orderingKey: noteId,
|
||||||
|
});
|
||||||
|
|
||||||
|
// If replying, also send to the original author
|
||||||
|
if (inReplyTo) {
|
||||||
|
try {
|
||||||
|
const remoteObject = await ctx.lookupObject(new URL(inReplyTo));
|
||||||
|
|
||||||
|
if (
|
||||||
|
remoteObject &&
|
||||||
|
typeof remoteObject.getAttributedTo === "function"
|
||||||
|
) {
|
||||||
|
const author = await remoteObject.getAttributedTo();
|
||||||
|
const recipient = Array.isArray(author)
|
||||||
|
? author[0]
|
||||||
|
: author;
|
||||||
|
|
||||||
|
if (recipient) {
|
||||||
|
await ctx.sendActivity(
|
||||||
|
{ identifier: handle },
|
||||||
|
recipient,
|
||||||
|
create,
|
||||||
|
{ orderingKey: noteId },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Non-critical — followers still got it
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.info(
|
||||||
|
`[ActivityPub] Sent quick reply${inReplyTo ? ` to ${inReplyTo}` : ""}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
return response.redirect(`${mountPath}/admin/reader`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Micropub path — post as blog reply
|
||||||
|
const micropubEndpoint = application.micropubEndpoint;
|
||||||
|
|
||||||
|
if (!micropubEndpoint) {
|
||||||
|
return response.status(500).render("error", {
|
||||||
|
title: "Error",
|
||||||
|
content: "Micropub endpoint not configured",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const micropubUrl = micropubEndpoint.startsWith("http")
|
||||||
|
? micropubEndpoint
|
||||||
|
: new URL(micropubEndpoint, application.url).href;
|
||||||
|
|
||||||
|
const token = request.session?.access_token;
|
||||||
|
|
||||||
|
if (!token) {
|
||||||
|
return response.redirect(
|
||||||
|
"/session/login?redirect=" + request.originalUrl,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const micropubData = new URLSearchParams();
|
||||||
|
micropubData.append("h", "entry");
|
||||||
|
micropubData.append("content", content.trim());
|
||||||
|
|
||||||
|
if (inReplyTo) {
|
||||||
|
micropubData.append("in-reply-to", inReplyTo);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (syndicateTo) {
|
||||||
|
const targets = Array.isArray(syndicateTo)
|
||||||
|
? syndicateTo
|
||||||
|
: [syndicateTo];
|
||||||
|
|
||||||
|
for (const target of targets) {
|
||||||
|
micropubData.append("mp-syndicate-to", target);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const micropubResponse = await fetch(micropubUrl, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
"Content-Type": "application/x-www-form-urlencoded",
|
||||||
|
Accept: "application/json",
|
||||||
|
},
|
||||||
|
body: micropubData.toString(),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (
|
||||||
|
micropubResponse.ok ||
|
||||||
|
micropubResponse.status === 201 ||
|
||||||
|
micropubResponse.status === 202
|
||||||
|
) {
|
||||||
|
const location = micropubResponse.headers.get("Location");
|
||||||
|
console.info(
|
||||||
|
`[ActivityPub] Created blog reply via Micropub: ${location || "success"}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
return response.redirect(`${mountPath}/admin/reader`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const errorBody = await micropubResponse.text();
|
||||||
|
let errorMessage = `Micropub error: ${micropubResponse.statusText}`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const errorJson = JSON.parse(errorBody);
|
||||||
|
|
||||||
|
if (errorJson.error_description) {
|
||||||
|
errorMessage = String(errorJson.error_description);
|
||||||
|
} else if (errorJson.error) {
|
||||||
|
errorMessage = String(errorJson.error);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Not JSON
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.status(micropubResponse.status).render("error", {
|
||||||
|
title: "Error",
|
||||||
|
content: errorMessage,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[ActivityPub] Compose submit failed:", error.message);
|
||||||
|
return response.status(500).render("error", {
|
||||||
|
title: "Error",
|
||||||
|
content: "Failed to create post. Please try again later.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
208
lib/controllers/interactions-boost.js
Normal file
208
lib/controllers/interactions-boost.js
Normal file
@@ -0,0 +1,208 @@
|
|||||||
|
/**
|
||||||
|
* Boost/Unboost interaction controllers.
|
||||||
|
* Sends Announce and Undo(Announce) activities via Fedify.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { validateToken } from "../csrf.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /admin/reader/boost — send an Announce activity to followers.
|
||||||
|
*/
|
||||||
|
export function boostController(mountPath, plugin) {
|
||||||
|
return async (request, response, next) => {
|
||||||
|
try {
|
||||||
|
if (!validateToken(request)) {
|
||||||
|
return response.status(403).json({
|
||||||
|
success: false,
|
||||||
|
error: "Invalid CSRF token",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const { url } = request.body;
|
||||||
|
|
||||||
|
if (!url) {
|
||||||
|
return response.status(400).json({
|
||||||
|
success: false,
|
||||||
|
error: "Missing post URL",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!plugin._federation) {
|
||||||
|
return response.status(503).json({
|
||||||
|
success: false,
|
||||||
|
error: "Federation not initialized",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const { Announce } = await import("@fedify/fedify");
|
||||||
|
const handle = plugin.options.actor.handle;
|
||||||
|
const ctx = plugin._federation.createContext(
|
||||||
|
new URL(plugin._publicationUrl),
|
||||||
|
{ handle, publicationUrl: plugin._publicationUrl },
|
||||||
|
);
|
||||||
|
|
||||||
|
const activityId = `urn:uuid:${crypto.randomUUID()}`;
|
||||||
|
|
||||||
|
// Construct Announce activity
|
||||||
|
const announce = new Announce({
|
||||||
|
id: new URL(activityId),
|
||||||
|
actor: ctx.getActorUri(handle),
|
||||||
|
object: new URL(url),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Send to followers via shared inbox
|
||||||
|
await ctx.sendActivity({ identifier: handle }, "followers", announce, {
|
||||||
|
preferSharedInbox: true,
|
||||||
|
syncCollection: true,
|
||||||
|
orderingKey: url,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Also send to the original post author
|
||||||
|
try {
|
||||||
|
const remoteObject = await ctx.lookupObject(new URL(url));
|
||||||
|
|
||||||
|
if (
|
||||||
|
remoteObject &&
|
||||||
|
typeof remoteObject.getAttributedTo === "function"
|
||||||
|
) {
|
||||||
|
const author = await remoteObject.getAttributedTo();
|
||||||
|
const recipient = Array.isArray(author) ? author[0] : author;
|
||||||
|
|
||||||
|
if (recipient) {
|
||||||
|
await ctx.sendActivity(
|
||||||
|
{ identifier: handle },
|
||||||
|
recipient,
|
||||||
|
announce,
|
||||||
|
{ orderingKey: url },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Non-critical — followers still received the boost
|
||||||
|
}
|
||||||
|
|
||||||
|
// Track the interaction
|
||||||
|
const { application } = request.app.locals;
|
||||||
|
const interactions = application?.collections?.get("ap_interactions");
|
||||||
|
|
||||||
|
if (interactions) {
|
||||||
|
await interactions.updateOne(
|
||||||
|
{ objectUrl: url, type: "boost" },
|
||||||
|
{
|
||||||
|
$set: {
|
||||||
|
objectUrl: url,
|
||||||
|
type: "boost",
|
||||||
|
activityId,
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ upsert: true },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.info(`[ActivityPub] Sent Announce (boost) for ${url}`);
|
||||||
|
|
||||||
|
return response.json({
|
||||||
|
success: true,
|
||||||
|
type: "boost",
|
||||||
|
objectUrl: url,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[ActivityPub] Boost failed:", error.message);
|
||||||
|
return response.status(500).json({
|
||||||
|
success: false,
|
||||||
|
error: "Boost failed. Please try again later.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /admin/reader/unboost — send an Undo(Announce) to followers.
|
||||||
|
*/
|
||||||
|
export function unboostController(mountPath, plugin) {
|
||||||
|
return async (request, response, next) => {
|
||||||
|
try {
|
||||||
|
if (!validateToken(request)) {
|
||||||
|
return response.status(403).json({
|
||||||
|
success: false,
|
||||||
|
error: "Invalid CSRF token",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const { url } = request.body;
|
||||||
|
|
||||||
|
if (!url) {
|
||||||
|
return response.status(400).json({
|
||||||
|
success: false,
|
||||||
|
error: "Missing post URL",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!plugin._federation) {
|
||||||
|
return response.status(503).json({
|
||||||
|
success: false,
|
||||||
|
error: "Federation not initialized",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const { application } = request.app.locals;
|
||||||
|
const interactions = application?.collections?.get("ap_interactions");
|
||||||
|
|
||||||
|
const existing = interactions
|
||||||
|
? await interactions.findOne({ objectUrl: url, type: "boost" })
|
||||||
|
: null;
|
||||||
|
|
||||||
|
if (!existing) {
|
||||||
|
return response.status(404).json({
|
||||||
|
success: false,
|
||||||
|
error: "No boost found for this post",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const { Announce, Undo } = await import("@fedify/fedify");
|
||||||
|
const handle = plugin.options.actor.handle;
|
||||||
|
const ctx = plugin._federation.createContext(
|
||||||
|
new URL(plugin._publicationUrl),
|
||||||
|
{ handle, publicationUrl: plugin._publicationUrl },
|
||||||
|
);
|
||||||
|
|
||||||
|
// Construct Undo(Announce)
|
||||||
|
const announce = new Announce({
|
||||||
|
id: existing.activityId ? new URL(existing.activityId) : undefined,
|
||||||
|
actor: ctx.getActorUri(handle),
|
||||||
|
object: new URL(url),
|
||||||
|
});
|
||||||
|
|
||||||
|
const undo = new Undo({
|
||||||
|
actor: ctx.getActorUri(handle),
|
||||||
|
object: announce,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Send to followers
|
||||||
|
await ctx.sendActivity({ identifier: handle }, "followers", undo, {
|
||||||
|
preferSharedInbox: true,
|
||||||
|
orderingKey: url,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Remove the interaction record
|
||||||
|
if (interactions) {
|
||||||
|
await interactions.deleteOne({ objectUrl: url, type: "boost" });
|
||||||
|
}
|
||||||
|
|
||||||
|
console.info(`[ActivityPub] Sent Undo(Announce) for ${url}`);
|
||||||
|
|
||||||
|
return response.json({
|
||||||
|
success: true,
|
||||||
|
type: "unboost",
|
||||||
|
objectUrl: url,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[ActivityPub] Unboost failed:", error.message);
|
||||||
|
return response.status(500).json({
|
||||||
|
success: false,
|
||||||
|
error: "Unboost failed. Please try again later.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
231
lib/controllers/interactions-like.js
Normal file
231
lib/controllers/interactions-like.js
Normal file
@@ -0,0 +1,231 @@
|
|||||||
|
/**
|
||||||
|
* Like/Unlike interaction controllers.
|
||||||
|
* Sends Like and Undo(Like) activities via Fedify.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { validateToken } from "../csrf.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /admin/reader/like — send a Like activity to the post author.
|
||||||
|
* @param {string} mountPath - Plugin mount path
|
||||||
|
* @param {object} plugin - ActivityPub plugin instance (for federation access)
|
||||||
|
*/
|
||||||
|
export function likeController(mountPath, plugin) {
|
||||||
|
return async (request, response, next) => {
|
||||||
|
try {
|
||||||
|
if (!validateToken(request)) {
|
||||||
|
return response.status(403).json({
|
||||||
|
success: false,
|
||||||
|
error: "Invalid CSRF token",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const { url } = request.body;
|
||||||
|
|
||||||
|
if (!url) {
|
||||||
|
return response.status(400).json({
|
||||||
|
success: false,
|
||||||
|
error: "Missing post URL",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!plugin._federation) {
|
||||||
|
return response.status(503).json({
|
||||||
|
success: false,
|
||||||
|
error: "Federation not initialized",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const { Like } = await import("@fedify/fedify");
|
||||||
|
const handle = plugin.options.actor.handle;
|
||||||
|
const ctx = plugin._federation.createContext(
|
||||||
|
new URL(plugin._publicationUrl),
|
||||||
|
{ handle, publicationUrl: plugin._publicationUrl },
|
||||||
|
);
|
||||||
|
|
||||||
|
// Look up the remote post to find its author
|
||||||
|
const remoteObject = await ctx.lookupObject(new URL(url));
|
||||||
|
|
||||||
|
if (!remoteObject) {
|
||||||
|
return response.status(404).json({
|
||||||
|
success: false,
|
||||||
|
error: "Could not resolve remote post",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the post author for delivery
|
||||||
|
let recipient = null;
|
||||||
|
|
||||||
|
if (typeof remoteObject.getAttributedTo === "function") {
|
||||||
|
const author = await remoteObject.getAttributedTo();
|
||||||
|
recipient = Array.isArray(author) ? author[0] : author;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!recipient) {
|
||||||
|
return response.status(404).json({
|
||||||
|
success: false,
|
||||||
|
error: "Could not resolve post author",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate a unique activity ID
|
||||||
|
const activityId = `urn:uuid:${crypto.randomUUID()}`;
|
||||||
|
|
||||||
|
// Construct and send Like activity
|
||||||
|
const like = new Like({
|
||||||
|
id: new URL(activityId),
|
||||||
|
actor: ctx.getActorUri(handle),
|
||||||
|
object: new URL(url),
|
||||||
|
});
|
||||||
|
|
||||||
|
await ctx.sendActivity({ identifier: handle }, recipient, like, {
|
||||||
|
orderingKey: url,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Track the interaction for undo
|
||||||
|
const { application } = request.app.locals;
|
||||||
|
const interactions = application?.collections?.get("ap_interactions");
|
||||||
|
|
||||||
|
if (interactions) {
|
||||||
|
await interactions.updateOne(
|
||||||
|
{ objectUrl: url, type: "like" },
|
||||||
|
{
|
||||||
|
$set: {
|
||||||
|
objectUrl: url,
|
||||||
|
type: "like",
|
||||||
|
activityId,
|
||||||
|
recipientUrl: recipient.id?.href || "",
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ upsert: true },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.info(`[ActivityPub] Sent Like for ${url}`);
|
||||||
|
|
||||||
|
return response.json({
|
||||||
|
success: true,
|
||||||
|
type: "like",
|
||||||
|
objectUrl: url,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[ActivityPub] Like failed:", error.message);
|
||||||
|
return response.status(500).json({
|
||||||
|
success: false,
|
||||||
|
error: "Like failed. Please try again later.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /admin/reader/unlike — send an Undo(Like) activity.
|
||||||
|
*/
|
||||||
|
export function unlikeController(mountPath, plugin) {
|
||||||
|
return async (request, response, next) => {
|
||||||
|
try {
|
||||||
|
if (!validateToken(request)) {
|
||||||
|
return response.status(403).json({
|
||||||
|
success: false,
|
||||||
|
error: "Invalid CSRF token",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const { url } = request.body;
|
||||||
|
|
||||||
|
if (!url) {
|
||||||
|
return response.status(400).json({
|
||||||
|
success: false,
|
||||||
|
error: "Missing post URL",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!plugin._federation) {
|
||||||
|
return response.status(503).json({
|
||||||
|
success: false,
|
||||||
|
error: "Federation not initialized",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const { application } = request.app.locals;
|
||||||
|
const interactions = application?.collections?.get("ap_interactions");
|
||||||
|
|
||||||
|
// Look up the original interaction to get the activity ID
|
||||||
|
const existing = interactions
|
||||||
|
? await interactions.findOne({ objectUrl: url, type: "like" })
|
||||||
|
: null;
|
||||||
|
|
||||||
|
if (!existing) {
|
||||||
|
return response.status(404).json({
|
||||||
|
success: false,
|
||||||
|
error: "No like found for this post",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const { Like, Undo } = await import("@fedify/fedify");
|
||||||
|
const handle = plugin.options.actor.handle;
|
||||||
|
const ctx = plugin._federation.createContext(
|
||||||
|
new URL(plugin._publicationUrl),
|
||||||
|
{ handle, publicationUrl: plugin._publicationUrl },
|
||||||
|
);
|
||||||
|
|
||||||
|
// Resolve the recipient
|
||||||
|
const remoteObject = await ctx.lookupObject(new URL(url));
|
||||||
|
let recipient = null;
|
||||||
|
|
||||||
|
if (remoteObject && typeof remoteObject.getAttributedTo === "function") {
|
||||||
|
const author = await remoteObject.getAttributedTo();
|
||||||
|
recipient = Array.isArray(author) ? author[0] : author;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!recipient) {
|
||||||
|
// Clean up the local record even if we can't send Undo
|
||||||
|
if (interactions) {
|
||||||
|
await interactions.deleteOne({ objectUrl: url, type: "like" });
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.json({
|
||||||
|
success: true,
|
||||||
|
type: "unlike",
|
||||||
|
objectUrl: url,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Construct Undo(Like)
|
||||||
|
const like = new Like({
|
||||||
|
id: existing.activityId ? new URL(existing.activityId) : undefined,
|
||||||
|
actor: ctx.getActorUri(handle),
|
||||||
|
object: new URL(url),
|
||||||
|
});
|
||||||
|
|
||||||
|
const undo = new Undo({
|
||||||
|
actor: ctx.getActorUri(handle),
|
||||||
|
object: like,
|
||||||
|
});
|
||||||
|
|
||||||
|
await ctx.sendActivity({ identifier: handle }, recipient, undo, {
|
||||||
|
orderingKey: url,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Remove the interaction record
|
||||||
|
if (interactions) {
|
||||||
|
await interactions.deleteOne({ objectUrl: url, type: "like" });
|
||||||
|
}
|
||||||
|
|
||||||
|
console.info(`[ActivityPub] Sent Undo(Like) for ${url}`);
|
||||||
|
|
||||||
|
return response.json({
|
||||||
|
success: true,
|
||||||
|
type: "unlike",
|
||||||
|
objectUrl: url,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[ActivityPub] Unlike failed:", error.message);
|
||||||
|
return response.status(500).json({
|
||||||
|
success: false,
|
||||||
|
error: "Unlike failed. Please try again later.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
7
lib/controllers/interactions.js
Normal file
7
lib/controllers/interactions.js
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
/**
|
||||||
|
* Interaction controllers — Like, Unlike, Boost, Unboost.
|
||||||
|
* Re-exports from split modules for backward compatibility with index.js imports.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export { likeController, unlikeController } from "./interactions-like.js";
|
||||||
|
export { boostController, unboostController } from "./interactions-boost.js";
|
||||||
294
lib/controllers/moderation.js
Normal file
294
lib/controllers/moderation.js
Normal file
@@ -0,0 +1,294 @@
|
|||||||
|
/**
|
||||||
|
* Moderation controllers — Mute, Unmute, Block, Unblock.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { validateToken, getToken } from "../csrf.js";
|
||||||
|
import {
|
||||||
|
addMuted,
|
||||||
|
removeMuted,
|
||||||
|
addBlocked,
|
||||||
|
removeBlocked,
|
||||||
|
getAllMuted,
|
||||||
|
getAllBlocked,
|
||||||
|
} from "../storage/moderation.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper to get moderation collections from request.
|
||||||
|
*/
|
||||||
|
function getModerationCollections(request) {
|
||||||
|
const { application } = request.app.locals;
|
||||||
|
return {
|
||||||
|
ap_muted: application?.collections?.get("ap_muted"),
|
||||||
|
ap_blocked: application?.collections?.get("ap_blocked"),
|
||||||
|
ap_timeline: application?.collections?.get("ap_timeline"),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /admin/reader/mute — Mute an actor or keyword.
|
||||||
|
*/
|
||||||
|
export function muteController(mountPath, plugin) {
|
||||||
|
return async (request, response, next) => {
|
||||||
|
try {
|
||||||
|
if (!validateToken(request)) {
|
||||||
|
return response.status(403).json({
|
||||||
|
success: false,
|
||||||
|
error: "Invalid CSRF token",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const { url, keyword } = request.body;
|
||||||
|
|
||||||
|
if (!url && !keyword) {
|
||||||
|
return response.status(400).json({
|
||||||
|
success: false,
|
||||||
|
error: "Provide url or keyword to mute",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const collections = getModerationCollections(request);
|
||||||
|
await addMuted(collections, { url: url || undefined, keyword: keyword || undefined });
|
||||||
|
|
||||||
|
console.info(
|
||||||
|
`[ActivityPub] Muted ${url ? `actor: ${url}` : `keyword: ${keyword}`}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
return response.json({
|
||||||
|
success: true,
|
||||||
|
type: "mute",
|
||||||
|
url: url || undefined,
|
||||||
|
keyword: keyword || undefined,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[ActivityPub] Mute failed:", error.message);
|
||||||
|
return response.status(500).json({
|
||||||
|
success: false,
|
||||||
|
error: "Operation failed. Please try again later.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /admin/reader/unmute — Unmute an actor or keyword.
|
||||||
|
*/
|
||||||
|
export function unmuteController(mountPath, plugin) {
|
||||||
|
return async (request, response, next) => {
|
||||||
|
try {
|
||||||
|
if (!validateToken(request)) {
|
||||||
|
return response.status(403).json({
|
||||||
|
success: false,
|
||||||
|
error: "Invalid CSRF token",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const { url, keyword } = request.body;
|
||||||
|
|
||||||
|
if (!url && !keyword) {
|
||||||
|
return response.status(400).json({
|
||||||
|
success: false,
|
||||||
|
error: "Provide url or keyword to unmute",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const collections = getModerationCollections(request);
|
||||||
|
await removeMuted(collections, { url: url || undefined, keyword: keyword || undefined });
|
||||||
|
|
||||||
|
return response.json({
|
||||||
|
success: true,
|
||||||
|
type: "unmute",
|
||||||
|
url: url || undefined,
|
||||||
|
keyword: keyword || undefined,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
return response.status(500).json({
|
||||||
|
success: false,
|
||||||
|
error: "Operation failed. Please try again later.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /admin/reader/block — Block an actor (sends Block activity + removes timeline items).
|
||||||
|
*/
|
||||||
|
export function blockController(mountPath, plugin) {
|
||||||
|
return async (request, response, next) => {
|
||||||
|
try {
|
||||||
|
if (!validateToken(request)) {
|
||||||
|
return response.status(403).json({
|
||||||
|
success: false,
|
||||||
|
error: "Invalid CSRF token",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const { url } = request.body;
|
||||||
|
|
||||||
|
if (!url) {
|
||||||
|
return response.status(400).json({
|
||||||
|
success: false,
|
||||||
|
error: "Missing actor URL",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const collections = getModerationCollections(request);
|
||||||
|
|
||||||
|
// Store the block
|
||||||
|
await addBlocked(collections, url);
|
||||||
|
|
||||||
|
// Remove timeline items from this actor
|
||||||
|
if (collections.ap_timeline) {
|
||||||
|
await collections.ap_timeline.deleteMany({ "author.url": url });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send Block activity via federation
|
||||||
|
if (plugin._federation) {
|
||||||
|
try {
|
||||||
|
const { Block } = await import("@fedify/fedify");
|
||||||
|
const handle = plugin.options.actor.handle;
|
||||||
|
const ctx = plugin._federation.createContext(
|
||||||
|
new URL(plugin._publicationUrl),
|
||||||
|
{ handle, publicationUrl: plugin._publicationUrl },
|
||||||
|
);
|
||||||
|
|
||||||
|
const remoteActor = await ctx.lookupObject(new URL(url));
|
||||||
|
|
||||||
|
if (remoteActor) {
|
||||||
|
const block = new Block({
|
||||||
|
actor: ctx.getActorUri(handle),
|
||||||
|
object: new URL(url),
|
||||||
|
});
|
||||||
|
|
||||||
|
await ctx.sendActivity(
|
||||||
|
{ identifier: handle },
|
||||||
|
remoteActor,
|
||||||
|
block,
|
||||||
|
{ orderingKey: url },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.warn(
|
||||||
|
`[ActivityPub] Could not send Block to ${url}: ${error.message}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.info(`[ActivityPub] Blocked actor: ${url}`);
|
||||||
|
|
||||||
|
return response.json({
|
||||||
|
success: true,
|
||||||
|
type: "block",
|
||||||
|
url,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[ActivityPub] Block failed:", error.message);
|
||||||
|
return response.status(500).json({
|
||||||
|
success: false,
|
||||||
|
error: "Operation failed. Please try again later.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /admin/reader/unblock — Unblock an actor (sends Undo(Block)).
|
||||||
|
*/
|
||||||
|
export function unblockController(mountPath, plugin) {
|
||||||
|
return async (request, response, next) => {
|
||||||
|
try {
|
||||||
|
if (!validateToken(request)) {
|
||||||
|
return response.status(403).json({
|
||||||
|
success: false,
|
||||||
|
error: "Invalid CSRF token",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const { url } = request.body;
|
||||||
|
|
||||||
|
if (!url) {
|
||||||
|
return response.status(400).json({
|
||||||
|
success: false,
|
||||||
|
error: "Missing actor URL",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const collections = getModerationCollections(request);
|
||||||
|
await removeBlocked(collections, url);
|
||||||
|
|
||||||
|
// Send Undo(Block) via federation
|
||||||
|
if (plugin._federation) {
|
||||||
|
try {
|
||||||
|
const { Block, Undo } = await import("@fedify/fedify");
|
||||||
|
const handle = plugin.options.actor.handle;
|
||||||
|
const ctx = plugin._federation.createContext(
|
||||||
|
new URL(plugin._publicationUrl),
|
||||||
|
{ handle, publicationUrl: plugin._publicationUrl },
|
||||||
|
);
|
||||||
|
|
||||||
|
const remoteActor = await ctx.lookupObject(new URL(url));
|
||||||
|
|
||||||
|
if (remoteActor) {
|
||||||
|
const block = new Block({
|
||||||
|
actor: ctx.getActorUri(handle),
|
||||||
|
object: new URL(url),
|
||||||
|
});
|
||||||
|
|
||||||
|
const undo = new Undo({
|
||||||
|
actor: ctx.getActorUri(handle),
|
||||||
|
object: block,
|
||||||
|
});
|
||||||
|
|
||||||
|
await ctx.sendActivity(
|
||||||
|
{ identifier: handle },
|
||||||
|
remoteActor,
|
||||||
|
undo,
|
||||||
|
{ orderingKey: url },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.warn(
|
||||||
|
`[ActivityPub] Could not send Undo(Block) to ${url}: ${error.message}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.info(`[ActivityPub] Unblocked actor: ${url}`);
|
||||||
|
|
||||||
|
return response.json({
|
||||||
|
success: true,
|
||||||
|
type: "unblock",
|
||||||
|
url,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
return response.status(500).json({
|
||||||
|
success: false,
|
||||||
|
error: "Operation failed. Please try again later.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /admin/reader/moderation — View muted/blocked lists.
|
||||||
|
*/
|
||||||
|
export function moderationController(mountPath) {
|
||||||
|
return async (request, response, next) => {
|
||||||
|
try {
|
||||||
|
const collections = getModerationCollections(request);
|
||||||
|
const csrfToken = getToken(request.session);
|
||||||
|
|
||||||
|
const muted = await getAllMuted(collections);
|
||||||
|
const blocked = await getAllBlocked(collections);
|
||||||
|
|
||||||
|
response.render("activitypub-moderation", {
|
||||||
|
title: response.locals.__("activitypub.moderation.title"),
|
||||||
|
muted,
|
||||||
|
blocked,
|
||||||
|
csrfToken,
|
||||||
|
mountPath,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
218
lib/controllers/profile.remote.js
Normal file
218
lib/controllers/profile.remote.js
Normal file
@@ -0,0 +1,218 @@
|
|||||||
|
/**
|
||||||
|
* Remote profile controllers — view remote actors and follow/unfollow.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { getToken, validateToken } from "../csrf.js";
|
||||||
|
import { sanitizeContent } from "../timeline-store.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /admin/reader/profile — Show remote actor profile.
|
||||||
|
* @param {string} mountPath - Plugin mount path
|
||||||
|
* @param {object} plugin - ActivityPub plugin instance
|
||||||
|
*/
|
||||||
|
export function remoteProfileController(mountPath, plugin) {
|
||||||
|
return async (request, response, next) => {
|
||||||
|
try {
|
||||||
|
const { application } = request.app.locals;
|
||||||
|
const actorUrl = request.query.url || request.query.handle;
|
||||||
|
|
||||||
|
if (!actorUrl) {
|
||||||
|
return response.status(400).render("error", {
|
||||||
|
title: "Error",
|
||||||
|
content: "Missing actor URL or handle",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!plugin._federation) {
|
||||||
|
return response.status(503).render("error", {
|
||||||
|
title: "Error",
|
||||||
|
content: "Federation not initialized",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const handle = plugin.options.actor.handle;
|
||||||
|
const ctx = plugin._federation.createContext(
|
||||||
|
new URL(plugin._publicationUrl),
|
||||||
|
{ handle, publicationUrl: plugin._publicationUrl },
|
||||||
|
);
|
||||||
|
|
||||||
|
// Look up the remote actor
|
||||||
|
let actor;
|
||||||
|
|
||||||
|
try {
|
||||||
|
actor = await ctx.lookupObject(new URL(actorUrl));
|
||||||
|
} catch {
|
||||||
|
return response.status(404).render("error", {
|
||||||
|
title: "Error",
|
||||||
|
content: response.locals.__("activitypub.profile.remote.notFound"),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!actor) {
|
||||||
|
return response.status(404).render("error", {
|
||||||
|
title: "Error",
|
||||||
|
content: response.locals.__("activitypub.profile.remote.notFound"),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract actor info
|
||||||
|
const name =
|
||||||
|
actor.name?.toString() ||
|
||||||
|
actor.preferredUsername?.toString() ||
|
||||||
|
actorUrl;
|
||||||
|
const actorHandle = actor.preferredUsername?.toString() || "";
|
||||||
|
const summary = sanitizeContent(actor.summary?.toString() || "");
|
||||||
|
let icon = "";
|
||||||
|
let image = "";
|
||||||
|
|
||||||
|
try {
|
||||||
|
const iconObj = await actor.getIcon();
|
||||||
|
icon = iconObj?.url?.href || "";
|
||||||
|
} catch {
|
||||||
|
// No icon
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const imageObj = await actor.getImage();
|
||||||
|
image = imageObj?.url?.href || "";
|
||||||
|
} catch {
|
||||||
|
// No header image
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract host for "View on {instance}"
|
||||||
|
let instanceHost = "";
|
||||||
|
|
||||||
|
try {
|
||||||
|
instanceHost = new URL(actorUrl).hostname;
|
||||||
|
} catch {
|
||||||
|
// Invalid URL
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if we're following this actor
|
||||||
|
const followingCol = application?.collections?.get("ap_following");
|
||||||
|
const isFollowing = followingCol
|
||||||
|
? !!(await followingCol.findOne({ actorUrl }))
|
||||||
|
: false;
|
||||||
|
|
||||||
|
// Get their posts from our timeline (only if following)
|
||||||
|
let posts = [];
|
||||||
|
|
||||||
|
if (isFollowing) {
|
||||||
|
const timelineCol = application?.collections?.get("ap_timeline");
|
||||||
|
|
||||||
|
if (timelineCol) {
|
||||||
|
posts = await timelineCol
|
||||||
|
.find({ "author.url": actorUrl })
|
||||||
|
.sort({ published: -1 })
|
||||||
|
.limit(20)
|
||||||
|
.toArray();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check mute/block state
|
||||||
|
const mutedCol = application?.collections?.get("ap_muted");
|
||||||
|
const blockedCol = application?.collections?.get("ap_blocked");
|
||||||
|
const isMuted = mutedCol
|
||||||
|
? !!(await mutedCol.findOne({ url: actorUrl }))
|
||||||
|
: false;
|
||||||
|
const isBlocked = blockedCol
|
||||||
|
? !!(await blockedCol.findOne({ url: actorUrl }))
|
||||||
|
: false;
|
||||||
|
|
||||||
|
const csrfToken = getToken(request.session);
|
||||||
|
|
||||||
|
response.render("activitypub-remote-profile", {
|
||||||
|
title: name,
|
||||||
|
actorUrl,
|
||||||
|
name,
|
||||||
|
actorHandle,
|
||||||
|
summary,
|
||||||
|
icon,
|
||||||
|
image,
|
||||||
|
instanceHost,
|
||||||
|
isFollowing,
|
||||||
|
isMuted,
|
||||||
|
isBlocked,
|
||||||
|
posts,
|
||||||
|
csrfToken,
|
||||||
|
mountPath,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /admin/reader/follow — Follow a remote actor.
|
||||||
|
*/
|
||||||
|
export function followController(mountPath, plugin) {
|
||||||
|
return async (request, response, next) => {
|
||||||
|
try {
|
||||||
|
if (!validateToken(request)) {
|
||||||
|
return response.status(403).json({
|
||||||
|
success: false,
|
||||||
|
error: "Invalid CSRF token",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const { url } = request.body;
|
||||||
|
|
||||||
|
if (!url) {
|
||||||
|
return response.status(400).json({
|
||||||
|
success: false,
|
||||||
|
error: "Missing actor URL",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await plugin.followActor(url);
|
||||||
|
|
||||||
|
return response.json({
|
||||||
|
success: result.ok,
|
||||||
|
error: result.error || undefined,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
return response.status(500).json({
|
||||||
|
success: false,
|
||||||
|
error: "Operation failed. Please try again later.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /admin/reader/unfollow — Unfollow a remote actor.
|
||||||
|
*/
|
||||||
|
export function unfollowController(mountPath, plugin) {
|
||||||
|
return async (request, response, next) => {
|
||||||
|
try {
|
||||||
|
if (!validateToken(request)) {
|
||||||
|
return response.status(403).json({
|
||||||
|
success: false,
|
||||||
|
error: "Invalid CSRF token",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const { url } = request.body;
|
||||||
|
|
||||||
|
if (!url) {
|
||||||
|
return response.status(400).json({
|
||||||
|
success: false,
|
||||||
|
error: "Missing actor URL",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await plugin.unfollowActor(url);
|
||||||
|
|
||||||
|
return response.json({
|
||||||
|
success: result.ok,
|
||||||
|
error: result.error || undefined,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
return response.status(500).json({
|
||||||
|
success: false,
|
||||||
|
error: "Operation failed. Please try again later.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
187
lib/controllers/reader.js
Normal file
187
lib/controllers/reader.js
Normal file
@@ -0,0 +1,187 @@
|
|||||||
|
/**
|
||||||
|
* Reader controller — shows timeline of posts from followed accounts.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { getTimelineItems } from "../storage/timeline.js";
|
||||||
|
import {
|
||||||
|
getNotifications,
|
||||||
|
getUnreadNotificationCount,
|
||||||
|
markAllNotificationsRead,
|
||||||
|
} from "../storage/notifications.js";
|
||||||
|
import { getToken } from "../csrf.js";
|
||||||
|
import {
|
||||||
|
getMutedUrls,
|
||||||
|
getMutedKeywords,
|
||||||
|
getBlockedUrls,
|
||||||
|
} from "../storage/moderation.js";
|
||||||
|
|
||||||
|
// Re-export controllers from split modules for backward compatibility
|
||||||
|
export {
|
||||||
|
composeController,
|
||||||
|
submitComposeController,
|
||||||
|
} from "./compose.js";
|
||||||
|
export {
|
||||||
|
remoteProfileController,
|
||||||
|
followController,
|
||||||
|
unfollowController,
|
||||||
|
} from "./profile.remote.js";
|
||||||
|
|
||||||
|
export function readerController(mountPath) {
|
||||||
|
return async (request, response, next) => {
|
||||||
|
try {
|
||||||
|
const { application } = request.app.locals;
|
||||||
|
const collections = {
|
||||||
|
ap_timeline: application?.collections?.get("ap_timeline"),
|
||||||
|
ap_notifications: application?.collections?.get("ap_notifications"),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Query parameters
|
||||||
|
const tab = request.query.tab || "all";
|
||||||
|
const before = request.query.before;
|
||||||
|
const after = request.query.after;
|
||||||
|
const limit = Number.parseInt(request.query.limit || "20", 10);
|
||||||
|
|
||||||
|
// Build query options
|
||||||
|
const options = { before, after, limit };
|
||||||
|
|
||||||
|
// Tab filtering
|
||||||
|
if (tab === "notes") {
|
||||||
|
options.type = "note";
|
||||||
|
} else if (tab === "articles") {
|
||||||
|
options.type = "article";
|
||||||
|
} else if (tab === "boosts") {
|
||||||
|
options.type = "boost";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get timeline items
|
||||||
|
const result = await getTimelineItems(collections, options);
|
||||||
|
|
||||||
|
// Apply client-side filtering for tabs not supported by storage layer
|
||||||
|
let items = result.items;
|
||||||
|
if (tab === "replies") {
|
||||||
|
items = items.filter((item) => item.inReplyTo);
|
||||||
|
} else if (tab === "media") {
|
||||||
|
items = items.filter(
|
||||||
|
(item) =>
|
||||||
|
(item.photo && item.photo.length > 0) ||
|
||||||
|
(item.video && item.video.length > 0) ||
|
||||||
|
(item.audio && item.audio.length > 0),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply moderation filters (muted actors, keywords, blocked actors)
|
||||||
|
const modCollections = {
|
||||||
|
ap_muted: application?.collections?.get("ap_muted"),
|
||||||
|
ap_blocked: application?.collections?.get("ap_blocked"),
|
||||||
|
};
|
||||||
|
const [mutedUrls, mutedKeywords, blockedUrls] = await Promise.all([
|
||||||
|
getMutedUrls(modCollections),
|
||||||
|
getMutedKeywords(modCollections),
|
||||||
|
getBlockedUrls(modCollections),
|
||||||
|
]);
|
||||||
|
const hiddenUrls = new Set([...mutedUrls, ...blockedUrls]);
|
||||||
|
|
||||||
|
if (hiddenUrls.size > 0 || mutedKeywords.length > 0) {
|
||||||
|
items = items.filter((item) => {
|
||||||
|
// Filter by author URL
|
||||||
|
if (item.author?.url && hiddenUrls.has(item.author.url)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter by muted keywords in content
|
||||||
|
if (mutedKeywords.length > 0 && item.content?.text) {
|
||||||
|
const lower = item.content.text.toLowerCase();
|
||||||
|
|
||||||
|
if (
|
||||||
|
mutedKeywords.some((kw) => lower.includes(kw.toLowerCase()))
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get unread notification count for badge
|
||||||
|
const unreadCount = await getUnreadNotificationCount(collections);
|
||||||
|
|
||||||
|
// Get interaction state for liked/boosted indicators
|
||||||
|
const interactionsCol =
|
||||||
|
application?.collections?.get("ap_interactions");
|
||||||
|
const interactionMap = {};
|
||||||
|
|
||||||
|
if (interactionsCol) {
|
||||||
|
const itemUrls = items
|
||||||
|
.map((item) => item.url || item.originalUrl)
|
||||||
|
.filter(Boolean);
|
||||||
|
|
||||||
|
if (itemUrls.length > 0) {
|
||||||
|
const interactions = await interactionsCol
|
||||||
|
.find({ objectUrl: { $in: itemUrls } })
|
||||||
|
.toArray();
|
||||||
|
|
||||||
|
for (const interaction of interactions) {
|
||||||
|
if (!interactionMap[interaction.objectUrl]) {
|
||||||
|
interactionMap[interaction.objectUrl] = {};
|
||||||
|
}
|
||||||
|
|
||||||
|
interactionMap[interaction.objectUrl][interaction.type] = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// CSRF token for interaction forms
|
||||||
|
const csrfToken = getToken(request.session);
|
||||||
|
|
||||||
|
response.render("activitypub-reader", {
|
||||||
|
title: response.locals.__("activitypub.reader.title"),
|
||||||
|
items,
|
||||||
|
tab,
|
||||||
|
before: result.before,
|
||||||
|
after: result.after,
|
||||||
|
unreadCount,
|
||||||
|
interactionMap,
|
||||||
|
csrfToken,
|
||||||
|
mountPath,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function notificationsController(mountPath) {
|
||||||
|
return async (request, response, next) => {
|
||||||
|
try {
|
||||||
|
const { application } = request.app.locals;
|
||||||
|
const collections = {
|
||||||
|
ap_notifications: application?.collections?.get("ap_notifications"),
|
||||||
|
};
|
||||||
|
|
||||||
|
const before = request.query.before;
|
||||||
|
const limit = Number.parseInt(request.query.limit || "20", 10);
|
||||||
|
|
||||||
|
// Get notifications
|
||||||
|
const result = await getNotifications(collections, { before, limit });
|
||||||
|
|
||||||
|
// Get unread count before marking as read
|
||||||
|
const unreadCount = await getUnreadNotificationCount(collections);
|
||||||
|
|
||||||
|
// Mark all as read when page loads
|
||||||
|
if (result.items.length > 0) {
|
||||||
|
await markAllNotificationsRead(collections);
|
||||||
|
}
|
||||||
|
|
||||||
|
response.render("activitypub-notifications", {
|
||||||
|
title: response.locals.__("activitypub.notifications.title"),
|
||||||
|
items: result.items,
|
||||||
|
before: result.before,
|
||||||
|
unreadCount,
|
||||||
|
mountPath,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
49
lib/csrf.js
Normal file
49
lib/csrf.js
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
/**
|
||||||
|
* Simple CSRF token generation and validation.
|
||||||
|
* Tokens are stored in the Express session.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { randomBytes, timingSafeEqual } from "node:crypto";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get or generate a CSRF token for the current session.
|
||||||
|
* @param {object} session - Express session object
|
||||||
|
* @returns {string} CSRF token
|
||||||
|
*/
|
||||||
|
export function getToken(session) {
|
||||||
|
if (!session._csrfToken) {
|
||||||
|
session._csrfToken = randomBytes(32).toString("hex");
|
||||||
|
}
|
||||||
|
|
||||||
|
return session._csrfToken;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate a CSRF token from a request.
|
||||||
|
* Checks both the request body `_csrf` field and the `X-CSRF-Token` header.
|
||||||
|
* @param {object} request - Express request object
|
||||||
|
* @returns {boolean} Whether the token is valid
|
||||||
|
*/
|
||||||
|
export function validateToken(request) {
|
||||||
|
const sessionToken = request.session?._csrfToken;
|
||||||
|
|
||||||
|
if (!sessionToken) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const requestToken =
|
||||||
|
request.body?._csrf || request.headers["x-csrf-token"];
|
||||||
|
|
||||||
|
if (!requestToken) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sessionToken.length !== requestToken.length) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return timingSafeEqual(
|
||||||
|
Buffer.from(sessionToken),
|
||||||
|
Buffer.from(requestToken),
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -23,6 +23,9 @@ import {
|
|||||||
} from "@fedify/fedify";
|
} from "@fedify/fedify";
|
||||||
|
|
||||||
import { logActivity as logActivityShared } from "./activity-log.js";
|
import { logActivity as logActivityShared } from "./activity-log.js";
|
||||||
|
import { sanitizeContent, extractActorInfo, extractObjectData } from "./timeline-store.js";
|
||||||
|
import { addTimelineItem, deleteTimelineItem, updateTimelineItem } from "./storage/timeline.js";
|
||||||
|
import { addNotification } from "./storage/notifications.js";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Register all inbox listeners on a federation's inbox chain.
|
* Register all inbox listeners on a federation's inbox chain.
|
||||||
@@ -83,6 +86,19 @@ export function registerInboxListeners(inboxChain, options) {
|
|||||||
actorName: followerName,
|
actorName: followerName,
|
||||||
summary: `${followerName} followed you`,
|
summary: `${followerName} followed you`,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Store notification
|
||||||
|
const followerInfo = await extractActorInfo(followerActor);
|
||||||
|
await addNotification(collections, {
|
||||||
|
uid: follow.id?.href || `follow:${followerUrl}`,
|
||||||
|
type: "follow",
|
||||||
|
actorUrl: followerInfo.url,
|
||||||
|
actorName: followerInfo.name,
|
||||||
|
actorPhoto: followerInfo.photo,
|
||||||
|
actorHandle: followerInfo.handle,
|
||||||
|
published: follow.published ? new Date(follow.published) : new Date(),
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
});
|
||||||
})
|
})
|
||||||
.on(Undo, async (ctx, undo) => {
|
.on(Undo, async (ctx, undo) => {
|
||||||
const actorUrl = undo.actorId?.href || "";
|
const actorUrl = undo.actorId?.href || "";
|
||||||
@@ -139,7 +155,7 @@ export function registerInboxListeners(inboxChain, options) {
|
|||||||
const result = await collections.ap_following.findOneAndUpdate(
|
const result = await collections.ap_following.findOneAndUpdate(
|
||||||
{
|
{
|
||||||
actorUrl,
|
actorUrl,
|
||||||
source: { $in: ["refollow:sent", "microsub-reader"] },
|
source: { $in: ["refollow:sent", "reader", "microsub-reader"] },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
$set: {
|
$set: {
|
||||||
@@ -176,7 +192,7 @@ export function registerInboxListeners(inboxChain, options) {
|
|||||||
const result = await collections.ap_following.findOneAndUpdate(
|
const result = await collections.ap_following.findOneAndUpdate(
|
||||||
{
|
{
|
||||||
actorUrl,
|
actorUrl,
|
||||||
source: { $in: ["refollow:sent", "microsub-reader"] },
|
source: { $in: ["refollow:sent", "reader", "microsub-reader"] },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
$set: {
|
$set: {
|
||||||
@@ -210,17 +226,18 @@ export function registerInboxListeners(inboxChain, options) {
|
|||||||
if (!objectId || (pubUrl && !objectId.startsWith(pubUrl))) return;
|
if (!objectId || (pubUrl && !objectId.startsWith(pubUrl))) return;
|
||||||
|
|
||||||
const actorUrl = like.actorId?.href || "";
|
const actorUrl = like.actorId?.href || "";
|
||||||
let actorName = actorUrl;
|
let actorObj;
|
||||||
try {
|
try {
|
||||||
const actorObj = await like.getActor();
|
actorObj = await like.getActor();
|
||||||
actorName =
|
|
||||||
actorObj?.name?.toString() ||
|
|
||||||
actorObj?.preferredUsername?.toString() ||
|
|
||||||
actorUrl;
|
|
||||||
} catch {
|
} catch {
|
||||||
/* actor not dereferenceable — use URL */
|
actorObj = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const actorName =
|
||||||
|
actorObj?.name?.toString() ||
|
||||||
|
actorObj?.preferredUsername?.toString() ||
|
||||||
|
actorUrl;
|
||||||
|
|
||||||
await logActivity(collections, storeRawActivities, {
|
await logActivity(collections, storeRawActivities, {
|
||||||
direction: "inbound",
|
direction: "inbound",
|
||||||
type: "Like",
|
type: "Like",
|
||||||
@@ -229,35 +246,96 @@ export function registerInboxListeners(inboxChain, options) {
|
|||||||
objectUrl: objectId,
|
objectUrl: objectId,
|
||||||
summary: `${actorName} liked ${objectId}`,
|
summary: `${actorName} liked ${objectId}`,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Store notification
|
||||||
|
const actorInfo = await extractActorInfo(actorObj);
|
||||||
|
await addNotification(collections, {
|
||||||
|
uid: like.id?.href || `like:${actorUrl}:${objectId}`,
|
||||||
|
type: "like",
|
||||||
|
actorUrl: actorInfo.url,
|
||||||
|
actorName: actorInfo.name,
|
||||||
|
actorPhoto: actorInfo.photo,
|
||||||
|
actorHandle: actorInfo.handle,
|
||||||
|
targetUrl: objectId,
|
||||||
|
targetName: "", // Could fetch post title, but not critical
|
||||||
|
published: like.published ? new Date(like.published) : new Date(),
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
});
|
||||||
})
|
})
|
||||||
.on(Announce, async (ctx, announce) => {
|
.on(Announce, async (ctx, announce) => {
|
||||||
// Use .objectId — no remote fetch needed (see Like handler comment)
|
|
||||||
const objectId = announce.objectId?.href || "";
|
const objectId = announce.objectId?.href || "";
|
||||||
|
if (!objectId) return;
|
||||||
// Only log boosts of our own content
|
|
||||||
const pubUrl = collections._publicationUrl;
|
|
||||||
if (!objectId || (pubUrl && !objectId.startsWith(pubUrl))) return;
|
|
||||||
|
|
||||||
const actorUrl = announce.actorId?.href || "";
|
const actorUrl = announce.actorId?.href || "";
|
||||||
let actorName = actorUrl;
|
const pubUrl = collections._publicationUrl;
|
||||||
try {
|
|
||||||
const actorObj = await announce.getActor();
|
// Dual path logic: Notification vs Timeline
|
||||||
actorName =
|
|
||||||
|
// PATH 1: Boost of OUR content → Notification
|
||||||
|
if (pubUrl && objectId.startsWith(pubUrl)) {
|
||||||
|
let actorObj;
|
||||||
|
try {
|
||||||
|
actorObj = await announce.getActor();
|
||||||
|
} catch {
|
||||||
|
actorObj = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const actorName =
|
||||||
actorObj?.name?.toString() ||
|
actorObj?.name?.toString() ||
|
||||||
actorObj?.preferredUsername?.toString() ||
|
actorObj?.preferredUsername?.toString() ||
|
||||||
actorUrl;
|
actorUrl;
|
||||||
} catch {
|
|
||||||
/* actor not dereferenceable — use URL */
|
// Log the boost activity
|
||||||
|
await logActivity(collections, storeRawActivities, {
|
||||||
|
direction: "inbound",
|
||||||
|
type: "Announce",
|
||||||
|
actorUrl,
|
||||||
|
actorName,
|
||||||
|
objectUrl: objectId,
|
||||||
|
summary: `${actorName} boosted ${objectId}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create notification
|
||||||
|
const actorInfo = await extractActorInfo(actorObj);
|
||||||
|
await addNotification(collections, {
|
||||||
|
uid: announce.id?.href || `${actorUrl}#boost-${objectId}`,
|
||||||
|
type: "boost",
|
||||||
|
actorUrl: actorInfo.url,
|
||||||
|
actorName: actorInfo.name,
|
||||||
|
actorPhoto: actorInfo.photo,
|
||||||
|
actorHandle: actorInfo.handle,
|
||||||
|
targetUrl: objectId,
|
||||||
|
targetName: "", // Could fetch post title, but not critical
|
||||||
|
published: announce.published ? new Date(announce.published).toISOString() : new Date().toISOString(),
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Don't return — fall through to check if actor is also followed
|
||||||
}
|
}
|
||||||
|
|
||||||
await logActivity(collections, storeRawActivities, {
|
// PATH 2: Boost from someone we follow → Timeline (store original post)
|
||||||
direction: "inbound",
|
const following = await collections.ap_following.findOne({ actorUrl });
|
||||||
type: "Announce",
|
if (following) {
|
||||||
actorUrl,
|
try {
|
||||||
actorName,
|
// Fetch the original object being boosted
|
||||||
objectUrl: objectId,
|
const object = await announce.getObject();
|
||||||
summary: `${actorName} boosted ${objectId}`,
|
if (!object) return;
|
||||||
});
|
|
||||||
|
// Get booster actor info
|
||||||
|
const boosterActor = await announce.getActor();
|
||||||
|
const boosterInfo = await extractActorInfo(boosterActor);
|
||||||
|
|
||||||
|
// Extract and store with boost metadata
|
||||||
|
const timelineItem = await extractObjectData(object, {
|
||||||
|
boostedBy: boosterInfo,
|
||||||
|
boostedAt: announce.published ? new Date(announce.published).toISOString() : new Date().toISOString(),
|
||||||
|
});
|
||||||
|
|
||||||
|
await addTimelineItem(collections, timelineItem);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to store boosted timeline item:", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
})
|
})
|
||||||
.on(Create, async (ctx, create) => {
|
.on(Create, async (ctx, create) => {
|
||||||
let object;
|
let object;
|
||||||
@@ -292,6 +370,7 @@ export function registerInboxListeners(inboxChain, options) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Log replies to our posts (existing behavior for conversations)
|
// Log replies to our posts (existing behavior for conversations)
|
||||||
|
const pubUrl = collections._publicationUrl;
|
||||||
if (inReplyTo) {
|
if (inReplyTo) {
|
||||||
const content = object.content?.toString() || "";
|
const content = object.content?.toString() || "";
|
||||||
await logActivity(collections, storeRawActivities, {
|
await logActivity(collections, storeRawActivities, {
|
||||||
@@ -304,21 +383,86 @@ export function registerInboxListeners(inboxChain, options) {
|
|||||||
content,
|
content,
|
||||||
summary: `${actorName} replied to ${inReplyTo}`,
|
summary: `${actorName} replied to ${inReplyTo}`,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Create notification if reply is to one of OUR posts
|
||||||
|
if (pubUrl && inReplyTo.startsWith(pubUrl)) {
|
||||||
|
const actorInfo = await extractActorInfo(actorObj);
|
||||||
|
const rawHtml = object.content?.toString() || "";
|
||||||
|
const contentHtml = sanitizeContent(rawHtml);
|
||||||
|
const contentText = rawHtml.replace(/<[^>]*>/g, "").substring(0, 200);
|
||||||
|
|
||||||
|
await addNotification(collections, {
|
||||||
|
uid: object.id?.href || `reply:${actorUrl}:${inReplyTo}`,
|
||||||
|
type: "reply",
|
||||||
|
actorUrl: actorInfo.url,
|
||||||
|
actorName: actorInfo.name,
|
||||||
|
actorPhoto: actorInfo.photo,
|
||||||
|
actorHandle: actorInfo.handle,
|
||||||
|
targetUrl: inReplyTo,
|
||||||
|
targetName: "",
|
||||||
|
content: {
|
||||||
|
text: contentText,
|
||||||
|
html: contentHtml,
|
||||||
|
},
|
||||||
|
published: object.published ? new Date(object.published).toISOString() : new Date().toISOString(),
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for mentions of our actor
|
||||||
|
if (object.tag) {
|
||||||
|
const tags = Array.isArray(object.tag) ? object.tag : [object.tag];
|
||||||
|
const ourActorUrl = ctx.getActorUri(handle).href;
|
||||||
|
|
||||||
|
for (const tag of tags) {
|
||||||
|
if (tag.type === "Mention" && tag.href?.href === ourActorUrl) {
|
||||||
|
const actorInfo = await extractActorInfo(actorObj);
|
||||||
|
const rawMentionHtml = object.content?.toString() || "";
|
||||||
|
const mentionHtml = sanitizeContent(rawMentionHtml);
|
||||||
|
const contentText = rawMentionHtml.replace(/<[^>]*>/g, "").substring(0, 200);
|
||||||
|
|
||||||
|
await addNotification(collections, {
|
||||||
|
uid: object.id?.href || `mention:${actorUrl}:${object.id?.href}`,
|
||||||
|
type: "mention",
|
||||||
|
actorUrl: actorInfo.url,
|
||||||
|
actorName: actorInfo.name,
|
||||||
|
actorPhoto: actorInfo.photo,
|
||||||
|
actorHandle: actorInfo.handle,
|
||||||
|
content: {
|
||||||
|
text: contentText,
|
||||||
|
html: mentionHtml,
|
||||||
|
},
|
||||||
|
published: object.published ? new Date(object.published).toISOString() : new Date().toISOString(),
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
|
||||||
|
break; // Only create one mention notification per post
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store timeline items from accounts we follow (native storage)
|
||||||
|
const following = await collections.ap_following.findOne({ actorUrl });
|
||||||
|
if (following) {
|
||||||
|
try {
|
||||||
|
const timelineItem = await extractObjectData(object);
|
||||||
|
await addTimelineItem(collections, timelineItem);
|
||||||
|
} catch (error) {
|
||||||
|
// Log extraction errors but don't fail the entire handler
|
||||||
|
console.error("Failed to store timeline item:", error);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Store timeline items from accounts we follow
|
|
||||||
await storeTimelineItem(collections, {
|
|
||||||
actorUrl,
|
|
||||||
actorName,
|
|
||||||
actorObj,
|
|
||||||
object,
|
|
||||||
inReplyTo,
|
|
||||||
});
|
|
||||||
})
|
})
|
||||||
.on(Delete, async (ctx, del) => {
|
.on(Delete, async (ctx, del) => {
|
||||||
const objectId = del.objectId?.href || "";
|
const objectId = del.objectId?.href || "";
|
||||||
if (objectId) {
|
if (objectId) {
|
||||||
|
// Remove from activity log
|
||||||
await collections.ap_activities.deleteMany({ objectUrl: objectId });
|
await collections.ap_activities.deleteMany({ objectUrl: objectId });
|
||||||
|
|
||||||
|
// Remove from timeline
|
||||||
|
await deleteTimelineItem(collections, objectId);
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.on(Move, async (ctx, move) => {
|
.on(Move, async (ctx, move) => {
|
||||||
@@ -343,7 +487,44 @@ export function registerInboxListeners(inboxChain, options) {
|
|||||||
});
|
});
|
||||||
})
|
})
|
||||||
.on(Update, async (ctx, update) => {
|
.on(Update, async (ctx, update) => {
|
||||||
// Remote actor updated their profile — refresh stored follower data
|
// Update can be for a profile OR for a post (edited content)
|
||||||
|
|
||||||
|
// Try to get the object being updated
|
||||||
|
let object;
|
||||||
|
try {
|
||||||
|
object = await update.getObject();
|
||||||
|
} catch {
|
||||||
|
object = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// PATH 1: If object is a Note/Article → Update timeline item content
|
||||||
|
if (object && (object instanceof Note || object.type === "Article")) {
|
||||||
|
const objectUrl = object.id?.href || "";
|
||||||
|
if (objectUrl) {
|
||||||
|
try {
|
||||||
|
// Extract updated content
|
||||||
|
const contentHtml = object.content?.toString() || "";
|
||||||
|
const contentText = object.source?.content?.toString() || contentHtml.replace(/<[^>]*>/g, "");
|
||||||
|
|
||||||
|
const updates = {
|
||||||
|
content: {
|
||||||
|
text: contentText,
|
||||||
|
html: contentHtml,
|
||||||
|
},
|
||||||
|
name: object.name?.toString() || "",
|
||||||
|
summary: object.summary?.toString() || "",
|
||||||
|
sensitive: object.sensitive || false,
|
||||||
|
};
|
||||||
|
|
||||||
|
await updateTimelineItem(collections, objectUrl, updates);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to update timeline item:", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// PATH 2: Otherwise, assume profile update — refresh stored follower data
|
||||||
const actorObj = await update.getActor();
|
const actorObj = await update.getActor();
|
||||||
const actorUrl = actorObj?.id?.href || "";
|
const actorUrl = actorObj?.id?.href || "";
|
||||||
if (!actorUrl) return;
|
if (!actorUrl) return;
|
||||||
@@ -397,180 +578,3 @@ async function logActivity(collections, storeRaw, record, rawJson) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cached ActivityPub channel ObjectId
|
|
||||||
let _apChannelId = null;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Look up (or auto-create) the ActivityPub channel's ObjectId.
|
|
||||||
* Cached after first successful call.
|
|
||||||
*
|
|
||||||
* The channel is created with `userId: "default"` so it appears in the
|
|
||||||
* Microsub reader UI alongside user-created channels.
|
|
||||||
*
|
|
||||||
* @param {object} collections - MongoDB collections
|
|
||||||
* @returns {Promise<import("mongodb").ObjectId|null>}
|
|
||||||
*/
|
|
||||||
async function getApChannelId(collections) {
|
|
||||||
if (_apChannelId) return _apChannelId;
|
|
||||||
if (!collections.microsub_channels) return null;
|
|
||||||
|
|
||||||
let channel = await collections.microsub_channels.findOne({
|
|
||||||
uid: "activitypub",
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!channel) {
|
|
||||||
// Auto-create the channel with the same fields the Microsub plugin uses
|
|
||||||
const maxOrderDoc = await collections.microsub_channels
|
|
||||||
.find({ userId: "default" })
|
|
||||||
.sort({ order: -1 })
|
|
||||||
.limit(1)
|
|
||||||
.toArray();
|
|
||||||
const order = maxOrderDoc.length > 0 ? maxOrderDoc[0].order + 1 : 0;
|
|
||||||
|
|
||||||
const doc = {
|
|
||||||
uid: "activitypub",
|
|
||||||
name: "Fediverse",
|
|
||||||
userId: "default",
|
|
||||||
order,
|
|
||||||
settings: { excludeTypes: [], excludeRegex: null },
|
|
||||||
createdAt: new Date().toISOString(),
|
|
||||||
updatedAt: new Date().toISOString(),
|
|
||||||
};
|
|
||||||
await collections.microsub_channels.insertOne(doc);
|
|
||||||
channel = doc;
|
|
||||||
console.info("[ActivityPub] Auto-created Microsub channel 'Fediverse'");
|
|
||||||
} else if (!channel.userId) {
|
|
||||||
// Fix existing channel missing userId (created by earlier version)
|
|
||||||
await collections.microsub_channels.updateOne(
|
|
||||||
{ _id: channel._id },
|
|
||||||
{ $set: { userId: "default" } },
|
|
||||||
);
|
|
||||||
console.info("[ActivityPub] Fixed Microsub channel: set userId to 'default'");
|
|
||||||
}
|
|
||||||
|
|
||||||
_apChannelId = channel._id;
|
|
||||||
return _apChannelId;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Store a Create activity as a Microsub timeline item if the actor
|
|
||||||
* is someone we follow. Skips gracefully if the Microsub plugin
|
|
||||||
* isn't loaded or the AP channel doesn't exist yet.
|
|
||||||
*
|
|
||||||
* @param {object} collections - MongoDB collections
|
|
||||||
* @param {object} params
|
|
||||||
* @param {string} params.actorUrl - Actor URL
|
|
||||||
* @param {string} params.actorName - Actor display name
|
|
||||||
* @param {object} params.actorObj - Fedify actor object
|
|
||||||
* @param {object} params.object - Fedify Note/Article object
|
|
||||||
* @param {string|null} params.inReplyTo - URL this is a reply to (if any)
|
|
||||||
*/
|
|
||||||
async function storeTimelineItem(
|
|
||||||
collections,
|
|
||||||
{ actorUrl, actorName, actorObj, object, inReplyTo },
|
|
||||||
) {
|
|
||||||
// Skip if Microsub plugin not loaded
|
|
||||||
if (!collections.microsub_items || !collections.microsub_channels) return;
|
|
||||||
|
|
||||||
// Only store posts from accounts we follow
|
|
||||||
const following = await collections.ap_following.findOne({ actorUrl });
|
|
||||||
if (!following) return;
|
|
||||||
|
|
||||||
const channelId = await getApChannelId(collections);
|
|
||||||
if (!channelId) return;
|
|
||||||
|
|
||||||
const objectUrl = object.id?.href || "";
|
|
||||||
if (!objectUrl) return;
|
|
||||||
|
|
||||||
// Extract content
|
|
||||||
const contentHtml = object.content?.toString() || "";
|
|
||||||
const contentText = contentHtml.replace(/<[^>]*>/g, "").trim();
|
|
||||||
|
|
||||||
// Name (usually only on Article, not Note)
|
|
||||||
const name = object.name?.toString() || undefined;
|
|
||||||
const summary = object.summary?.toString() || undefined;
|
|
||||||
|
|
||||||
// Published date — Fedify returns Temporal.Instant
|
|
||||||
let published;
|
|
||||||
if (object.published) {
|
|
||||||
try {
|
|
||||||
published = new Date(Number(object.published.epochMilliseconds));
|
|
||||||
} catch {
|
|
||||||
published = new Date();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Author avatar
|
|
||||||
let authorPhoto = "";
|
|
||||||
try {
|
|
||||||
if (actorObj.icon) {
|
|
||||||
const iconObj = await actorObj.icon;
|
|
||||||
authorPhoto = iconObj?.url?.href || "";
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
/* remote fetch may fail */
|
|
||||||
}
|
|
||||||
|
|
||||||
// Tags / categories
|
|
||||||
const category = [];
|
|
||||||
try {
|
|
||||||
for await (const tag of object.getTags()) {
|
|
||||||
const tagName = tag.name?.toString();
|
|
||||||
if (tagName) category.push(tagName.replace(/^#/, ""));
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
/* ignore */
|
|
||||||
}
|
|
||||||
|
|
||||||
// Attachments (photos, videos, audio)
|
|
||||||
const photo = [];
|
|
||||||
const video = [];
|
|
||||||
const audio = [];
|
|
||||||
try {
|
|
||||||
for await (const att of object.getAttachments()) {
|
|
||||||
const mediaType = att.mediaType?.toString() || "";
|
|
||||||
const url = att.url?.href || att.id?.href || "";
|
|
||||||
if (!url) continue;
|
|
||||||
if (mediaType.startsWith("image/")) photo.push(url);
|
|
||||||
else if (mediaType.startsWith("video/")) video.push(url);
|
|
||||||
else if (mediaType.startsWith("audio/")) audio.push(url);
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
/* ignore */
|
|
||||||
}
|
|
||||||
|
|
||||||
const item = {
|
|
||||||
channelId,
|
|
||||||
feedId: null,
|
|
||||||
uid: objectUrl,
|
|
||||||
type: "entry",
|
|
||||||
url: objectUrl,
|
|
||||||
name,
|
|
||||||
content: contentHtml ? { text: contentText, html: contentHtml } : undefined,
|
|
||||||
summary,
|
|
||||||
published: published || new Date(),
|
|
||||||
author: {
|
|
||||||
name: actorName,
|
|
||||||
url: actorUrl,
|
|
||||||
photo: authorPhoto,
|
|
||||||
},
|
|
||||||
category,
|
|
||||||
photo,
|
|
||||||
video,
|
|
||||||
audio,
|
|
||||||
inReplyTo: inReplyTo ? [inReplyTo] : [],
|
|
||||||
source: {
|
|
||||||
type: "activitypub",
|
|
||||||
actorUrl,
|
|
||||||
},
|
|
||||||
readBy: [],
|
|
||||||
createdAt: new Date().toISOString(),
|
|
||||||
};
|
|
||||||
|
|
||||||
// Atomic upsert — prevents duplicates without a separate check+insert
|
|
||||||
await collections.microsub_items.updateOne(
|
|
||||||
{ channelId, uid: objectUrl },
|
|
||||||
{ $setOnInsert: item },
|
|
||||||
{ upsert: true },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
180
lib/storage/moderation.js
Normal file
180
lib/storage/moderation.js
Normal file
@@ -0,0 +1,180 @@
|
|||||||
|
/**
|
||||||
|
* Moderation storage operations (mute/block)
|
||||||
|
* @module storage/moderation
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add a muted URL or keyword
|
||||||
|
* @param {object} collections - MongoDB collections
|
||||||
|
* @param {object} data - Mute data
|
||||||
|
* @param {string} [data.url] - Actor URL to mute (mutually exclusive with keyword)
|
||||||
|
* @param {string} [data.keyword] - Keyword to mute (mutually exclusive with url)
|
||||||
|
* @returns {Promise<object>} Created mute entry
|
||||||
|
*/
|
||||||
|
export async function addMuted(collections, { url, keyword }) {
|
||||||
|
const { ap_muted } = collections;
|
||||||
|
|
||||||
|
if (!url && !keyword) {
|
||||||
|
throw new Error("Either url or keyword must be provided");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (url && keyword) {
|
||||||
|
throw new Error("Cannot mute both url and keyword in same entry");
|
||||||
|
}
|
||||||
|
|
||||||
|
const entry = {
|
||||||
|
url: url || null,
|
||||||
|
keyword: keyword || null,
|
||||||
|
mutedAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Upsert to avoid duplicates
|
||||||
|
const filter = url ? { url } : { keyword };
|
||||||
|
await ap_muted.updateOne(filter, { $setOnInsert: entry }, { upsert: true });
|
||||||
|
|
||||||
|
return await ap_muted.findOne(filter);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove a muted URL or keyword
|
||||||
|
* @param {object} collections - MongoDB collections
|
||||||
|
* @param {object} data - Mute identifier
|
||||||
|
* @param {string} [data.url] - Actor URL to unmute
|
||||||
|
* @param {string} [data.keyword] - Keyword to unmute
|
||||||
|
* @returns {Promise<object>} Delete result
|
||||||
|
*/
|
||||||
|
export async function removeMuted(collections, { url, keyword }) {
|
||||||
|
const { ap_muted } = collections;
|
||||||
|
|
||||||
|
const filter = {};
|
||||||
|
if (url) {
|
||||||
|
filter.url = url;
|
||||||
|
} else if (keyword) {
|
||||||
|
filter.keyword = keyword;
|
||||||
|
} else {
|
||||||
|
throw new Error("Either url or keyword must be provided");
|
||||||
|
}
|
||||||
|
|
||||||
|
return await ap_muted.deleteOne(filter);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all muted URLs
|
||||||
|
* @param {object} collections - MongoDB collections
|
||||||
|
* @returns {Promise<string[]>} Array of muted URLs
|
||||||
|
*/
|
||||||
|
export async function getMutedUrls(collections) {
|
||||||
|
const { ap_muted } = collections;
|
||||||
|
const entries = await ap_muted.find({ url: { $ne: null } }).toArray();
|
||||||
|
return entries.map((entry) => entry.url);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all muted keywords
|
||||||
|
* @param {object} collections - MongoDB collections
|
||||||
|
* @returns {Promise<string[]>} Array of muted keywords
|
||||||
|
*/
|
||||||
|
export async function getMutedKeywords(collections) {
|
||||||
|
const { ap_muted } = collections;
|
||||||
|
const entries = await ap_muted.find({ keyword: { $ne: null } }).toArray();
|
||||||
|
return entries.map((entry) => entry.keyword);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a URL is muted
|
||||||
|
* @param {object} collections - MongoDB collections
|
||||||
|
* @param {string} url - URL to check
|
||||||
|
* @returns {Promise<boolean>} True if muted
|
||||||
|
*/
|
||||||
|
export async function isUrlMuted(collections, url) {
|
||||||
|
const { ap_muted } = collections;
|
||||||
|
const entry = await ap_muted.findOne({ url });
|
||||||
|
return !!entry;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if content contains muted keywords
|
||||||
|
* @param {object} collections - MongoDB collections
|
||||||
|
* @param {string} content - Content text to check
|
||||||
|
* @returns {Promise<boolean>} True if contains muted keyword
|
||||||
|
*/
|
||||||
|
export async function containsMutedKeyword(collections, content) {
|
||||||
|
const keywords = await getMutedKeywords(collections);
|
||||||
|
const lowerContent = content.toLowerCase();
|
||||||
|
|
||||||
|
return keywords.some((keyword) => lowerContent.includes(keyword.toLowerCase()));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add a blocked actor URL
|
||||||
|
* @param {object} collections - MongoDB collections
|
||||||
|
* @param {string} url - Actor URL to block
|
||||||
|
* @returns {Promise<object>} Created block entry
|
||||||
|
*/
|
||||||
|
export async function addBlocked(collections, url) {
|
||||||
|
const { ap_blocked } = collections;
|
||||||
|
|
||||||
|
const entry = {
|
||||||
|
url,
|
||||||
|
blockedAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Upsert to avoid duplicates
|
||||||
|
await ap_blocked.updateOne({ url }, { $setOnInsert: entry }, { upsert: true });
|
||||||
|
|
||||||
|
return await ap_blocked.findOne({ url });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove a blocked actor URL
|
||||||
|
* @param {object} collections - MongoDB collections
|
||||||
|
* @param {string} url - Actor URL to unblock
|
||||||
|
* @returns {Promise<object>} Delete result
|
||||||
|
*/
|
||||||
|
export async function removeBlocked(collections, url) {
|
||||||
|
const { ap_blocked } = collections;
|
||||||
|
return await ap_blocked.deleteOne({ url });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all blocked URLs
|
||||||
|
* @param {object} collections - MongoDB collections
|
||||||
|
* @returns {Promise<string[]>} Array of blocked URLs
|
||||||
|
*/
|
||||||
|
export async function getBlockedUrls(collections) {
|
||||||
|
const { ap_blocked } = collections;
|
||||||
|
const entries = await ap_blocked.find({}).toArray();
|
||||||
|
return entries.map((entry) => entry.url);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a URL is blocked
|
||||||
|
* @param {object} collections - MongoDB collections
|
||||||
|
* @param {string} url - URL to check
|
||||||
|
* @returns {Promise<boolean>} True if blocked
|
||||||
|
*/
|
||||||
|
export async function isUrlBlocked(collections, url) {
|
||||||
|
const { ap_blocked } = collections;
|
||||||
|
const entry = await ap_blocked.findOne({ url });
|
||||||
|
return !!entry;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get list of all muted entries (URLs and keywords)
|
||||||
|
* @param {object} collections - MongoDB collections
|
||||||
|
* @returns {Promise<object[]>} Array of mute entries
|
||||||
|
*/
|
||||||
|
export async function getAllMuted(collections) {
|
||||||
|
const { ap_muted } = collections;
|
||||||
|
return await ap_muted.find({}).toArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get list of all blocked entries
|
||||||
|
* @param {object} collections - MongoDB collections
|
||||||
|
* @returns {Promise<object[]>} Array of block entries
|
||||||
|
*/
|
||||||
|
export async function getAllBlocked(collections) {
|
||||||
|
const { ap_blocked } = collections;
|
||||||
|
return await ap_blocked.find({}).toArray();
|
||||||
|
}
|
||||||
132
lib/storage/notifications.js
Normal file
132
lib/storage/notifications.js
Normal file
@@ -0,0 +1,132 @@
|
|||||||
|
/**
|
||||||
|
* Notification storage operations
|
||||||
|
* @module storage/notifications
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add a notification (uses atomic upsert for deduplication)
|
||||||
|
* @param {object} collections - MongoDB collections
|
||||||
|
* @param {object} notification - Notification data
|
||||||
|
* @param {string} notification.uid - Activity ID or constructed dedup key
|
||||||
|
* @param {string} notification.type - "like" | "boost" | "follow" | "mention" | "reply"
|
||||||
|
* @param {string} notification.actorUrl - Remote actor URL
|
||||||
|
* @param {string} notification.actorName - Display name
|
||||||
|
* @param {string} notification.actorPhoto - Avatar URL
|
||||||
|
* @param {string} notification.actorHandle - @user@instance
|
||||||
|
* @param {string} [notification.targetUrl] - The post they liked/boosted/replied to
|
||||||
|
* @param {string} [notification.targetName] - Post title
|
||||||
|
* @param {object} [notification.content] - { text, html } for mentions/replies
|
||||||
|
* @param {Date} notification.published - Activity timestamp (kept as Date for sort)
|
||||||
|
* @param {string} notification.createdAt - ISO string creation timestamp
|
||||||
|
* @returns {Promise<object>} Created or existing notification
|
||||||
|
*/
|
||||||
|
export async function addNotification(collections, notification) {
|
||||||
|
const { ap_notifications } = collections;
|
||||||
|
|
||||||
|
const result = await ap_notifications.updateOne(
|
||||||
|
{ uid: notification.uid },
|
||||||
|
{
|
||||||
|
$setOnInsert: {
|
||||||
|
...notification,
|
||||||
|
read: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ upsert: true },
|
||||||
|
);
|
||||||
|
|
||||||
|
if (result.upsertedCount > 0) {
|
||||||
|
return await ap_notifications.findOne({ uid: notification.uid });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return existing document if it was a duplicate
|
||||||
|
return await ap_notifications.findOne({ uid: notification.uid });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get notifications with cursor-based pagination
|
||||||
|
* @param {object} collections - MongoDB collections
|
||||||
|
* @param {object} options - Query options
|
||||||
|
* @param {string} [options.before] - Before cursor (published date)
|
||||||
|
* @param {number} [options.limit=20] - Items per page
|
||||||
|
* @param {boolean} [options.unreadOnly=false] - Show only unread notifications
|
||||||
|
* @returns {Promise<object>} { items, before }
|
||||||
|
*/
|
||||||
|
export async function getNotifications(collections, options = {}) {
|
||||||
|
const { ap_notifications } = collections;
|
||||||
|
const parsedLimit = Number.parseInt(options.limit, 10);
|
||||||
|
const limit = Math.min(
|
||||||
|
Number.isFinite(parsedLimit) && parsedLimit > 0 ? parsedLimit : 20,
|
||||||
|
100,
|
||||||
|
);
|
||||||
|
|
||||||
|
const query = {};
|
||||||
|
|
||||||
|
// Unread filter
|
||||||
|
if (options.unreadOnly) {
|
||||||
|
query.read = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cursor pagination
|
||||||
|
if (options.before) {
|
||||||
|
query.published = { $lt: new Date(options.before) };
|
||||||
|
}
|
||||||
|
|
||||||
|
const rawItems = await ap_notifications
|
||||||
|
.find(query)
|
||||||
|
.sort({ published: -1 })
|
||||||
|
.limit(limit)
|
||||||
|
.toArray();
|
||||||
|
|
||||||
|
// Normalize published dates to ISO strings for Nunjucks | date filter
|
||||||
|
const items = rawItems.map((item) => ({
|
||||||
|
...item,
|
||||||
|
published: item.published instanceof Date
|
||||||
|
? item.published.toISOString()
|
||||||
|
: item.published,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Generate cursor for next page
|
||||||
|
const before =
|
||||||
|
items.length > 0
|
||||||
|
? items[items.length - 1].published
|
||||||
|
: null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
items,
|
||||||
|
before,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get count of unread notifications
|
||||||
|
* @param {object} collections - MongoDB collections
|
||||||
|
* @returns {Promise<number>} Unread notification count
|
||||||
|
*/
|
||||||
|
export async function getUnreadNotificationCount(collections) {
|
||||||
|
const { ap_notifications } = collections;
|
||||||
|
return await ap_notifications.countDocuments({ read: false });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mark notifications as read
|
||||||
|
* @param {object} collections - MongoDB collections
|
||||||
|
* @param {string[]} uids - Notification UIDs to mark read
|
||||||
|
* @returns {Promise<object>} Update result
|
||||||
|
*/
|
||||||
|
export async function markNotificationsRead(collections, uids) {
|
||||||
|
const { ap_notifications } = collections;
|
||||||
|
return await ap_notifications.updateMany(
|
||||||
|
{ uid: { $in: uids } },
|
||||||
|
{ $set: { read: true } },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mark all notifications as read
|
||||||
|
* @param {object} collections - MongoDB collections
|
||||||
|
* @returns {Promise<object>} Update result
|
||||||
|
*/
|
||||||
|
export async function markAllNotificationsRead(collections) {
|
||||||
|
const { ap_notifications } = collections;
|
||||||
|
return await ap_notifications.updateMany({}, { $set: { read: true } });
|
||||||
|
}
|
||||||
210
lib/storage/timeline.js
Normal file
210
lib/storage/timeline.js
Normal file
@@ -0,0 +1,210 @@
|
|||||||
|
/**
|
||||||
|
* Timeline item storage operations
|
||||||
|
* @module storage/timeline
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add a timeline item (uses atomic upsert for deduplication)
|
||||||
|
* @param {object} collections - MongoDB collections
|
||||||
|
* @param {object} item - Timeline item data
|
||||||
|
* @param {string} item.uid - Canonical AP object URL (dedup key)
|
||||||
|
* @param {string} item.type - "note" | "article" | "boost"
|
||||||
|
* @param {string} item.url - Post URL
|
||||||
|
* @param {string} [item.name] - Post title (articles only)
|
||||||
|
* @param {object} item.content - { text, html }
|
||||||
|
* @param {string} [item.summary] - Content warning text
|
||||||
|
* @param {boolean} item.sensitive - Sensitive content flag
|
||||||
|
* @param {Date} item.published - Published date (kept as Date for sort queries)
|
||||||
|
* @param {object} item.author - { name, url, photo, handle }
|
||||||
|
* @param {string[]} item.category - Tags/categories
|
||||||
|
* @param {string[]} item.photo - Photo URLs
|
||||||
|
* @param {string[]} item.video - Video URLs
|
||||||
|
* @param {string[]} item.audio - Audio URLs
|
||||||
|
* @param {string} [item.inReplyTo] - Parent post URL
|
||||||
|
* @param {object} [item.boostedBy] - { name, url, photo, handle } for boosts
|
||||||
|
* @param {Date} [item.boostedAt] - Boost timestamp
|
||||||
|
* @param {string} [item.originalUrl] - Original post URL for boosts
|
||||||
|
* @param {string} item.createdAt - ISO string creation timestamp
|
||||||
|
* @returns {Promise<object>} Created or existing item
|
||||||
|
*/
|
||||||
|
export async function addTimelineItem(collections, item) {
|
||||||
|
const { ap_timeline } = collections;
|
||||||
|
|
||||||
|
const result = await ap_timeline.updateOne(
|
||||||
|
{ uid: item.uid },
|
||||||
|
{
|
||||||
|
$setOnInsert: {
|
||||||
|
...item,
|
||||||
|
readBy: [],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ upsert: true },
|
||||||
|
);
|
||||||
|
|
||||||
|
if (result.upsertedCount > 0) {
|
||||||
|
return await ap_timeline.findOne({ uid: item.uid });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return existing document if it was a duplicate
|
||||||
|
return await ap_timeline.findOne({ uid: item.uid });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get timeline items with cursor-based pagination
|
||||||
|
* @param {object} collections - MongoDB collections
|
||||||
|
* @param {object} options - Query options
|
||||||
|
* @param {string} [options.before] - Before cursor (published date)
|
||||||
|
* @param {string} [options.after] - After cursor (published date)
|
||||||
|
* @param {number} [options.limit=20] - Items per page
|
||||||
|
* @param {string} [options.type] - Filter by type
|
||||||
|
* @param {string} [options.authorUrl] - Filter by author URL
|
||||||
|
* @returns {Promise<object>} { items, before, after }
|
||||||
|
*/
|
||||||
|
export async function getTimelineItems(collections, options = {}) {
|
||||||
|
const { ap_timeline } = collections;
|
||||||
|
const parsedLimit = Number.parseInt(options.limit, 10);
|
||||||
|
const limit = Math.min(
|
||||||
|
Number.isFinite(parsedLimit) && parsedLimit > 0 ? parsedLimit : 20,
|
||||||
|
100,
|
||||||
|
);
|
||||||
|
|
||||||
|
const query = {};
|
||||||
|
|
||||||
|
// Type filter
|
||||||
|
if (options.type) {
|
||||||
|
query.type = options.type;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Author filter (for profile view) — validate string type to prevent operator injection
|
||||||
|
if (options.authorUrl) {
|
||||||
|
if (typeof options.authorUrl !== "string") {
|
||||||
|
throw new Error("Invalid authorUrl");
|
||||||
|
}
|
||||||
|
|
||||||
|
query["author.url"] = options.authorUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cursor pagination — validate dates
|
||||||
|
if (options.before) {
|
||||||
|
const beforeDate = new Date(options.before);
|
||||||
|
|
||||||
|
if (Number.isNaN(beforeDate.getTime())) {
|
||||||
|
throw new Error("Invalid before cursor");
|
||||||
|
}
|
||||||
|
|
||||||
|
query.published = { $lt: beforeDate };
|
||||||
|
} else if (options.after) {
|
||||||
|
const afterDate = new Date(options.after);
|
||||||
|
|
||||||
|
if (Number.isNaN(afterDate.getTime())) {
|
||||||
|
throw new Error("Invalid after cursor");
|
||||||
|
}
|
||||||
|
|
||||||
|
query.published = { $gt: afterDate };
|
||||||
|
}
|
||||||
|
|
||||||
|
const rawItems = await ap_timeline
|
||||||
|
.find(query)
|
||||||
|
.sort({ published: -1 })
|
||||||
|
.limit(limit)
|
||||||
|
.toArray();
|
||||||
|
|
||||||
|
// Normalize published dates to ISO strings for Nunjucks | date filter
|
||||||
|
const items = rawItems.map((item) => ({
|
||||||
|
...item,
|
||||||
|
published: item.published instanceof Date
|
||||||
|
? item.published.toISOString()
|
||||||
|
: item.published,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Generate cursors for pagination
|
||||||
|
const before =
|
||||||
|
items.length > 0
|
||||||
|
? items[0].published
|
||||||
|
: null;
|
||||||
|
const after =
|
||||||
|
items.length > 0
|
||||||
|
? items[items.length - 1].published
|
||||||
|
: null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
items,
|
||||||
|
before,
|
||||||
|
after,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a single timeline item by UID
|
||||||
|
* @param {object} collections - MongoDB collections
|
||||||
|
* @param {string} uid - Item UID (canonical URL)
|
||||||
|
* @returns {Promise<object|null>} Timeline item or null
|
||||||
|
*/
|
||||||
|
export async function getTimelineItem(collections, uid) {
|
||||||
|
const { ap_timeline } = collections;
|
||||||
|
return await ap_timeline.findOne({ uid });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete a timeline item by UID
|
||||||
|
* @param {object} collections - MongoDB collections
|
||||||
|
* @param {string} uid - Item UID
|
||||||
|
* @returns {Promise<object>} Delete result
|
||||||
|
*/
|
||||||
|
export async function deleteTimelineItem(collections, uid) {
|
||||||
|
const { ap_timeline } = collections;
|
||||||
|
return await ap_timeline.deleteOne({ uid });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update a timeline item's content (for Update activities)
|
||||||
|
* @param {object} collections - MongoDB collections
|
||||||
|
* @param {string} uid - Item UID
|
||||||
|
* @param {object} updates - Fields to update
|
||||||
|
* @param {object} [updates.content] - New content
|
||||||
|
* @param {string} [updates.name] - New title
|
||||||
|
* @param {string} [updates.summary] - New content warning
|
||||||
|
* @param {boolean} [updates.sensitive] - New sensitive flag
|
||||||
|
* @returns {Promise<object>} Update result
|
||||||
|
*/
|
||||||
|
export async function updateTimelineItem(collections, uid, updates) {
|
||||||
|
const { ap_timeline } = collections;
|
||||||
|
return await ap_timeline.updateOne({ uid }, { $set: updates });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete timeline items older than a cutoff date (retention cleanup)
|
||||||
|
* @param {object} collections - MongoDB collections
|
||||||
|
* @param {Date} cutoffDate - Delete items published before this date
|
||||||
|
* @returns {Promise<number>} Number of items deleted
|
||||||
|
*/
|
||||||
|
export async function deleteOldTimelineItems(collections, cutoffDate) {
|
||||||
|
const { ap_timeline } = collections;
|
||||||
|
const result = await ap_timeline.deleteMany({ published: { $lt: cutoffDate } });
|
||||||
|
return result.deletedCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete timeline items by count-based retention (keep N newest)
|
||||||
|
* @param {object} collections - MongoDB collections
|
||||||
|
* @param {number} keepCount - Number of items to keep
|
||||||
|
* @returns {Promise<number>} Number of items deleted
|
||||||
|
*/
|
||||||
|
export async function cleanupTimelineByCount(collections, keepCount) {
|
||||||
|
const { ap_timeline } = collections;
|
||||||
|
|
||||||
|
// Find the Nth newest item's published date
|
||||||
|
const items = await ap_timeline
|
||||||
|
.find({})
|
||||||
|
.sort({ published: -1 })
|
||||||
|
.skip(keepCount)
|
||||||
|
.limit(1)
|
||||||
|
.toArray();
|
||||||
|
|
||||||
|
if (items.length === 0) {
|
||||||
|
return 0; // Fewer than keepCount items exist
|
||||||
|
}
|
||||||
|
|
||||||
|
const cutoffDate = items[0].published;
|
||||||
|
return await deleteOldTimelineItems(collections, cutoffDate);
|
||||||
|
}
|
||||||
88
lib/timeline-cleanup.js
Normal file
88
lib/timeline-cleanup.js
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
/**
|
||||||
|
* Timeline retention cleanup — removes old timeline items to prevent
|
||||||
|
* unbounded collection growth and cleans up stale interaction tracking.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove timeline items beyond the retention limit and clean up
|
||||||
|
* corresponding ap_interactions entries.
|
||||||
|
*
|
||||||
|
* Uses aggregation to identify exact items to delete by UID,
|
||||||
|
* avoiding race conditions between finding and deleting.
|
||||||
|
*
|
||||||
|
* @param {object} collections - MongoDB collections
|
||||||
|
* @param {number} retentionLimit - Max number of timeline items to keep
|
||||||
|
* @returns {Promise<{removed: number, interactionsRemoved: number}>}
|
||||||
|
*/
|
||||||
|
export async function cleanupTimeline(collections, retentionLimit) {
|
||||||
|
if (!collections.ap_timeline || retentionLimit <= 0) {
|
||||||
|
return { removed: 0, interactionsRemoved: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
const totalCount = await collections.ap_timeline.countDocuments();
|
||||||
|
if (totalCount <= retentionLimit) {
|
||||||
|
return { removed: 0, interactionsRemoved: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use aggregation to get exact UIDs beyond the retention limit.
|
||||||
|
// This avoids race conditions: we delete by UID, not by date.
|
||||||
|
const toDelete = await collections.ap_timeline
|
||||||
|
.aggregate([
|
||||||
|
{ $sort: { published: -1 } },
|
||||||
|
{ $skip: retentionLimit },
|
||||||
|
{ $project: { uid: 1 } },
|
||||||
|
])
|
||||||
|
.toArray();
|
||||||
|
|
||||||
|
if (!toDelete.length) {
|
||||||
|
return { removed: 0, interactionsRemoved: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
const removedUids = toDelete.map((item) => item.uid).filter(Boolean);
|
||||||
|
|
||||||
|
// Delete old timeline items by UID
|
||||||
|
const deleteResult = await collections.ap_timeline.deleteMany({
|
||||||
|
_id: { $in: toDelete.map((item) => item._id) },
|
||||||
|
});
|
||||||
|
|
||||||
|
// Clean up stale interactions for removed items
|
||||||
|
let interactionsRemoved = 0;
|
||||||
|
if (removedUids.length > 0 && collections.ap_interactions) {
|
||||||
|
const interactionResult = await collections.ap_interactions.deleteMany({
|
||||||
|
objectUrl: { $in: removedUids },
|
||||||
|
});
|
||||||
|
interactionsRemoved = interactionResult.deletedCount || 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
const removed = deleteResult.deletedCount || 0;
|
||||||
|
|
||||||
|
if (removed > 0) {
|
||||||
|
console.info(
|
||||||
|
`[ActivityPub] Timeline cleanup: removed ${removed} items, ${interactionsRemoved} stale interactions`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { removed, interactionsRemoved };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Schedule periodic timeline cleanup.
|
||||||
|
*
|
||||||
|
* @param {object} collections - MongoDB collections
|
||||||
|
* @param {number} retentionLimit - Max number of timeline items to keep
|
||||||
|
* @param {number} intervalMs - Cleanup interval in milliseconds (default: 24 hours)
|
||||||
|
* @returns {NodeJS.Timeout} The interval timer (for cleanup if needed)
|
||||||
|
*/
|
||||||
|
export function scheduleCleanup(collections, retentionLimit, intervalMs = 86_400_000) {
|
||||||
|
// Run immediately on startup
|
||||||
|
cleanupTimeline(collections, retentionLimit).catch((error) => {
|
||||||
|
console.error("[ActivityPub] Timeline cleanup failed:", error.message);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Then run periodically
|
||||||
|
return setInterval(() => {
|
||||||
|
cleanupTimeline(collections, retentionLimit).catch((error) => {
|
||||||
|
console.error("[ActivityPub] Timeline cleanup failed:", error.message);
|
||||||
|
});
|
||||||
|
}, intervalMs);
|
||||||
|
}
|
||||||
207
lib/timeline-store.js
Normal file
207
lib/timeline-store.js
Normal file
@@ -0,0 +1,207 @@
|
|||||||
|
/**
|
||||||
|
* Timeline item extraction helpers
|
||||||
|
* @module timeline-store
|
||||||
|
*/
|
||||||
|
|
||||||
|
import sanitizeHtml from "sanitize-html";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sanitize HTML content for safe display
|
||||||
|
* @param {string} html - Raw HTML content
|
||||||
|
* @returns {string} Sanitized HTML
|
||||||
|
*/
|
||||||
|
export function sanitizeContent(html) {
|
||||||
|
if (!html) return "";
|
||||||
|
|
||||||
|
return sanitizeHtml(html, {
|
||||||
|
allowedTags: [
|
||||||
|
"p", "br", "a", "strong", "em", "ul", "ol", "li",
|
||||||
|
"blockquote", "code", "pre", "h1", "h2", "h3", "h4", "h5", "h6",
|
||||||
|
"span", "div", "img"
|
||||||
|
],
|
||||||
|
allowedAttributes: {
|
||||||
|
a: ["href", "rel", "class"],
|
||||||
|
img: ["src", "alt", "class"],
|
||||||
|
span: ["class"],
|
||||||
|
div: ["class"]
|
||||||
|
},
|
||||||
|
allowedSchemes: ["http", "https", "mailto"],
|
||||||
|
allowedSchemesByTag: {
|
||||||
|
img: ["http", "https", "data"]
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract actor information from Fedify Person/Application/Service object
|
||||||
|
* @param {object} actor - Fedify actor object
|
||||||
|
* @returns {object} { name, url, photo, handle }
|
||||||
|
*/
|
||||||
|
export async function extractActorInfo(actor) {
|
||||||
|
if (!actor) {
|
||||||
|
return {
|
||||||
|
name: "Unknown",
|
||||||
|
url: "",
|
||||||
|
photo: "",
|
||||||
|
handle: ""
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const rawName = actor.name?.toString() || actor.preferredUsername?.toString() || "Unknown";
|
||||||
|
// Strip all HTML from actor names to prevent stored XSS
|
||||||
|
const name = sanitizeHtml(rawName, { allowedTags: [], allowedAttributes: {} });
|
||||||
|
const url = actor.id?.href || "";
|
||||||
|
|
||||||
|
// Extract photo URL from icon (Fedify uses async getters)
|
||||||
|
let photo = "";
|
||||||
|
try {
|
||||||
|
if (typeof actor.getIcon === "function") {
|
||||||
|
const iconObj = await actor.getIcon();
|
||||||
|
photo = iconObj?.url?.href || "";
|
||||||
|
} else {
|
||||||
|
const iconObj = await actor.icon;
|
||||||
|
photo = iconObj?.url?.href || "";
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// No icon available
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract handle from actor URL
|
||||||
|
let handle = "";
|
||||||
|
try {
|
||||||
|
const actorUrl = new URL(url);
|
||||||
|
const username = actor.preferredUsername?.toString() || "";
|
||||||
|
if (username) {
|
||||||
|
handle = `@${username}@${actorUrl.hostname}`;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Invalid URL, keep handle empty
|
||||||
|
}
|
||||||
|
|
||||||
|
return { name, url, photo, handle };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract timeline item data from Fedify Note/Article object
|
||||||
|
* @param {object} object - Fedify Note or Article object
|
||||||
|
* @param {object} options - Extraction options
|
||||||
|
* @param {object} [options.boostedBy] - Actor info for boosts
|
||||||
|
* @param {Date} [options.boostedAt] - Boost timestamp
|
||||||
|
* @returns {Promise<object>} Timeline item data
|
||||||
|
*/
|
||||||
|
export async function extractObjectData(object, options = {}) {
|
||||||
|
if (!object) {
|
||||||
|
throw new Error("Object is required");
|
||||||
|
}
|
||||||
|
|
||||||
|
const uid = object.id?.href || "";
|
||||||
|
const url = object.url?.href || uid;
|
||||||
|
|
||||||
|
// Determine type
|
||||||
|
let type = "note";
|
||||||
|
if (object.type?.toLowerCase() === "article") {
|
||||||
|
type = "article";
|
||||||
|
}
|
||||||
|
if (options.boostedBy) {
|
||||||
|
type = "boost";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract content
|
||||||
|
const contentHtml = object.content?.toString() || "";
|
||||||
|
const contentText = object.source?.content?.toString() || contentHtml.replace(/<[^>]*>/g, "");
|
||||||
|
|
||||||
|
const content = {
|
||||||
|
text: contentText,
|
||||||
|
html: sanitizeContent(contentHtml)
|
||||||
|
};
|
||||||
|
|
||||||
|
// Extract name (articles only)
|
||||||
|
const name = type === "article" ? (object.name?.toString() || "") : "";
|
||||||
|
|
||||||
|
// Content warning / summary
|
||||||
|
const summary = object.summary?.toString() || "";
|
||||||
|
const sensitive = object.sensitive || false;
|
||||||
|
|
||||||
|
// Published date — store as ISO string per Indiekit convention
|
||||||
|
const published = object.published
|
||||||
|
? new Date(object.published).toISOString()
|
||||||
|
: new Date().toISOString();
|
||||||
|
|
||||||
|
// Extract author — use async getAttributedTo() for Fedify objects
|
||||||
|
let authorObj = null;
|
||||||
|
try {
|
||||||
|
if (typeof object.getAttributedTo === "function") {
|
||||||
|
const attr = await object.getAttributedTo();
|
||||||
|
authorObj = Array.isArray(attr) ? attr[0] : attr;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Fallback: try direct property access for plain objects
|
||||||
|
authorObj = object.attribution || object.attributedTo || null;
|
||||||
|
}
|
||||||
|
const author = await extractActorInfo(authorObj);
|
||||||
|
|
||||||
|
// Extract tags/categories
|
||||||
|
const category = [];
|
||||||
|
if (object.tag) {
|
||||||
|
const tags = Array.isArray(object.tag) ? object.tag : [object.tag];
|
||||||
|
for (const tag of tags) {
|
||||||
|
if (tag.type === "Hashtag" && tag.name) {
|
||||||
|
category.push(tag.name.toString().replace(/^#/, ""));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract media attachments
|
||||||
|
const photo = [];
|
||||||
|
const video = [];
|
||||||
|
const audio = [];
|
||||||
|
|
||||||
|
if (object.attachment) {
|
||||||
|
const attachments = Array.isArray(object.attachment) ? object.attachment : [object.attachment];
|
||||||
|
for (const att of attachments) {
|
||||||
|
const mediaUrl = att.url?.href || "";
|
||||||
|
if (!mediaUrl) continue;
|
||||||
|
|
||||||
|
const mediaType = att.mediaType?.toLowerCase() || "";
|
||||||
|
|
||||||
|
if (mediaType.startsWith("image/")) {
|
||||||
|
photo.push(mediaUrl);
|
||||||
|
} else if (mediaType.startsWith("video/")) {
|
||||||
|
video.push(mediaUrl);
|
||||||
|
} else if (mediaType.startsWith("audio/")) {
|
||||||
|
audio.push(mediaUrl);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// In-reply-to
|
||||||
|
const inReplyTo = object.inReplyTo?.href || "";
|
||||||
|
|
||||||
|
// Build base timeline item
|
||||||
|
const item = {
|
||||||
|
uid,
|
||||||
|
type,
|
||||||
|
url,
|
||||||
|
name,
|
||||||
|
content,
|
||||||
|
summary,
|
||||||
|
sensitive,
|
||||||
|
published,
|
||||||
|
author,
|
||||||
|
category,
|
||||||
|
photo,
|
||||||
|
video,
|
||||||
|
audio,
|
||||||
|
inReplyTo,
|
||||||
|
createdAt: new Date().toISOString()
|
||||||
|
};
|
||||||
|
|
||||||
|
// Add boost metadata if this is a boost
|
||||||
|
if (options.boostedBy) {
|
||||||
|
item.boostedBy = options.boostedBy;
|
||||||
|
item.boostedAt = options.boostedAt || new Date().toISOString();
|
||||||
|
item.originalUrl = url;
|
||||||
|
}
|
||||||
|
|
||||||
|
return item;
|
||||||
|
}
|
||||||
@@ -49,7 +49,16 @@
|
|||||||
"authorizedFetchLabel": "Require authorized fetch (secure mode)",
|
"authorizedFetchLabel": "Require authorized fetch (secure mode)",
|
||||||
"authorizedFetchHint": "When enabled, only servers with valid HTTP Signatures can fetch your actor and collections. This improves privacy but may reduce compatibility with some clients.",
|
"authorizedFetchHint": "When enabled, only servers with valid HTTP Signatures can fetch your actor and collections. This improves privacy but may reduce compatibility with some clients.",
|
||||||
"save": "Save profile",
|
"save": "Save profile",
|
||||||
"saved": "Profile saved. Changes are now visible to the fediverse."
|
"saved": "Profile saved. Changes are now visible to the fediverse.",
|
||||||
|
"remote": {
|
||||||
|
"follow": "Follow",
|
||||||
|
"unfollow": "Unfollow",
|
||||||
|
"viewOn": "View on",
|
||||||
|
"postsTitle": "Posts",
|
||||||
|
"noPosts": "No posts from this account yet.",
|
||||||
|
"followToSee": "Follow this account to see their posts in your timeline.",
|
||||||
|
"notFound": "Could not find this account. It may have been deleted or the server may be unavailable."
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"migrate": {
|
"migrate": {
|
||||||
"title": "Mastodon migration",
|
"title": "Mastodon migration",
|
||||||
@@ -94,6 +103,80 @@
|
|||||||
"paused": "Paused",
|
"paused": "Paused",
|
||||||
"completed": "Completed"
|
"completed": "Completed"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"moderation": {
|
||||||
|
"title": "Moderation",
|
||||||
|
"blockedTitle": "Blocked accounts",
|
||||||
|
"mutedActorsTitle": "Muted accounts",
|
||||||
|
"mutedKeywordsTitle": "Muted keywords",
|
||||||
|
"noBlocked": "No blocked accounts.",
|
||||||
|
"noMutedActors": "No muted accounts.",
|
||||||
|
"noMutedKeywords": "No muted keywords.",
|
||||||
|
"unblock": "Unblock",
|
||||||
|
"unmute": "Unmute",
|
||||||
|
"addKeywordTitle": "Add muted keyword",
|
||||||
|
"keywordPlaceholder": "Enter keyword or phrase…",
|
||||||
|
"addKeyword": "Add",
|
||||||
|
"muteActor": "Mute",
|
||||||
|
"blockActor": "Block"
|
||||||
|
},
|
||||||
|
"compose": {
|
||||||
|
"title": "Compose reply",
|
||||||
|
"modeLabel": "Reply mode",
|
||||||
|
"modeMicropub": "Post as blog reply",
|
||||||
|
"modeMicropubHint": "Creates a permanent post on your blog, syndicated to the fediverse",
|
||||||
|
"modeQuick": "Quick reply",
|
||||||
|
"modeQuickHint": "Sends a reply directly to the fediverse (no blog post created)",
|
||||||
|
"placeholder": "Write your reply…",
|
||||||
|
"syndicateLabel": "Syndicate to",
|
||||||
|
"submitMicropub": "Post reply",
|
||||||
|
"submitQuick": "Send reply",
|
||||||
|
"cancel": "Cancel",
|
||||||
|
"errorEmpty": "Reply content cannot be empty"
|
||||||
|
},
|
||||||
|
"notifications": {
|
||||||
|
"title": "Notifications",
|
||||||
|
"empty": "No notifications yet. Interactions from other fediverse users will appear here.",
|
||||||
|
"liked": "liked your post",
|
||||||
|
"boostedPost": "boosted your post",
|
||||||
|
"followedYou": "followed you",
|
||||||
|
"repliedTo": "replied to your post",
|
||||||
|
"mentionedYou": "mentioned you"
|
||||||
|
},
|
||||||
|
"reader": {
|
||||||
|
"title": "Reader",
|
||||||
|
"tabs": {
|
||||||
|
"all": "All",
|
||||||
|
"notes": "Notes",
|
||||||
|
"articles": "Articles",
|
||||||
|
"replies": "Replies",
|
||||||
|
"boosts": "Boosts",
|
||||||
|
"media": "Media"
|
||||||
|
},
|
||||||
|
"pagination": {
|
||||||
|
"newer": "← Newer",
|
||||||
|
"older": "Older →"
|
||||||
|
},
|
||||||
|
"empty": "Your timeline is empty. Follow some accounts to see their posts here.",
|
||||||
|
"boosted": "boosted",
|
||||||
|
"replyingTo": "Replying to",
|
||||||
|
"showContent": "Show content",
|
||||||
|
"hideContent": "Hide content",
|
||||||
|
"sensitiveContent": "Sensitive content",
|
||||||
|
"videoNotSupported": "Your browser does not support the video element.",
|
||||||
|
"audioNotSupported": "Your browser does not support the audio element.",
|
||||||
|
"actions": {
|
||||||
|
"reply": "Reply",
|
||||||
|
"boost": "Boost",
|
||||||
|
"unboost": "Undo boost",
|
||||||
|
"like": "Like",
|
||||||
|
"unlike": "Unlike",
|
||||||
|
"viewOriginal": "View original",
|
||||||
|
"liked": "Liked",
|
||||||
|
"boosted": "Boosted",
|
||||||
|
"likeError": "Could not like this post",
|
||||||
|
"boostError": "Could not boost this post"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
204
package-lock.json
generated
204
package-lock.json
generated
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "@rmdes/indiekit-endpoint-activitypub",
|
"name": "@rmdes/indiekit-endpoint-activitypub",
|
||||||
"version": "1.0.21",
|
"version": "1.0.29",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "@rmdes/indiekit-endpoint-activitypub",
|
"name": "@rmdes/indiekit-endpoint-activitypub",
|
||||||
"version": "1.0.21",
|
"version": "1.0.29",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fedify/express": "^1.10.3",
|
"@fedify/express": "^1.10.3",
|
||||||
@@ -14,7 +14,8 @@
|
|||||||
"@fedify/redis": "^1.10.3",
|
"@fedify/redis": "^1.10.3",
|
||||||
"@js-temporal/polyfill": "^0.5.0",
|
"@js-temporal/polyfill": "^0.5.0",
|
||||||
"express": "^5.0.0",
|
"express": "^5.0.0",
|
||||||
"ioredis": "^5.9.3"
|
"ioredis": "^5.9.3",
|
||||||
|
"sanitize-html": "^2.13.1"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=22"
|
"node": ">=22"
|
||||||
@@ -438,6 +439,15 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/deepmerge": {
|
||||||
|
"version": "4.3.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz",
|
||||||
|
"integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.10.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/denque": {
|
"node_modules/denque": {
|
||||||
"version": "2.1.0",
|
"version": "2.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz",
|
||||||
@@ -456,6 +466,61 @@
|
|||||||
"node": ">= 0.8"
|
"node": ">= 0.8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/dom-serializer": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"domelementtype": "^2.3.0",
|
||||||
|
"domhandler": "^5.0.2",
|
||||||
|
"entities": "^4.2.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/cheeriojs/dom-serializer?sponsor=1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/domelementtype": {
|
||||||
|
"version": "2.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz",
|
||||||
|
"integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==",
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/fb55"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "BSD-2-Clause"
|
||||||
|
},
|
||||||
|
"node_modules/domhandler": {
|
||||||
|
"version": "5.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz",
|
||||||
|
"integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==",
|
||||||
|
"license": "BSD-2-Clause",
|
||||||
|
"dependencies": {
|
||||||
|
"domelementtype": "^2.3.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 4"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/fb55/domhandler?sponsor=1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/domutils": {
|
||||||
|
"version": "3.2.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz",
|
||||||
|
"integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==",
|
||||||
|
"license": "BSD-2-Clause",
|
||||||
|
"dependencies": {
|
||||||
|
"dom-serializer": "^2.0.0",
|
||||||
|
"domelementtype": "^2.3.0",
|
||||||
|
"domhandler": "^5.0.3"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/fb55/domutils?sponsor=1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/dunder-proto": {
|
"node_modules/dunder-proto": {
|
||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
|
||||||
@@ -485,6 +550,18 @@
|
|||||||
"node": ">= 0.8"
|
"node": ">= 0.8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/entities": {
|
||||||
|
"version": "4.5.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz",
|
||||||
|
"integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==",
|
||||||
|
"license": "BSD-2-Clause",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.12"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/fb55/entities?sponsor=1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/es-define-property": {
|
"node_modules/es-define-property": {
|
||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
|
||||||
@@ -531,6 +608,18 @@
|
|||||||
"integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==",
|
"integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/escape-string-regexp": {
|
||||||
|
"version": "4.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz",
|
||||||
|
"integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/etag": {
|
"node_modules/etag": {
|
||||||
"version": "1.8.1",
|
"version": "1.8.1",
|
||||||
"resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz",
|
"resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz",
|
||||||
@@ -704,6 +793,25 @@
|
|||||||
"node": ">= 0.4"
|
"node": ">= 0.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/htmlparser2": {
|
||||||
|
"version": "8.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-8.0.2.tgz",
|
||||||
|
"integrity": "sha512-GYdjWKDkbRLkZ5geuHs5NY1puJ+PXwP7+fHPRz06Eirsb9ugf6d8kkXav6ADhcODhFFPMIXyxkxSuMf3D6NCFA==",
|
||||||
|
"funding": [
|
||||||
|
"https://github.com/fb55/htmlparser2?sponsor=1",
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/fb55"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"domelementtype": "^2.3.0",
|
||||||
|
"domhandler": "^5.0.3",
|
||||||
|
"domutils": "^3.0.1",
|
||||||
|
"entities": "^4.4.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/http-errors": {
|
"node_modules/http-errors": {
|
||||||
"version": "2.0.1",
|
"version": "2.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
|
||||||
@@ -779,6 +887,15 @@
|
|||||||
"node": ">= 0.10"
|
"node": ">= 0.10"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/is-plain-object": {
|
||||||
|
"version": "5.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-5.0.0.tgz",
|
||||||
|
"integrity": "sha512-VRSzKkbMm5jMDoKLbltAkFQ5Qr7VDiTFGXxYFXXowVj387GeGNOCsOH6Msy00SGZ3Fp84b1Naa1psqgcCIEP5Q==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.10.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/is-promise": {
|
"node_modules/is-promise": {
|
||||||
"version": "4.0.0",
|
"version": "4.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz",
|
||||||
@@ -926,6 +1043,24 @@
|
|||||||
"integrity": "sha512-HoMUjhH9T8DDBNT+6xzkrd9ga/XiBI4xLr58LJACwK6G3HTOPeMz4nB4KJs33L2BelrIJa7P0VuNaVF3hMYfjg==",
|
"integrity": "sha512-HoMUjhH9T8DDBNT+6xzkrd9ga/XiBI4xLr58LJACwK6G3HTOPeMz4nB4KJs33L2BelrIJa7P0VuNaVF3hMYfjg==",
|
||||||
"license": "(Apache-2.0 AND MIT)"
|
"license": "(Apache-2.0 AND MIT)"
|
||||||
},
|
},
|
||||||
|
"node_modules/nanoid": {
|
||||||
|
"version": "3.3.11",
|
||||||
|
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
|
||||||
|
"integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==",
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/ai"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"bin": {
|
||||||
|
"nanoid": "bin/nanoid.cjs"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/negotiator": {
|
"node_modules/negotiator": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz",
|
||||||
@@ -968,6 +1103,12 @@
|
|||||||
"wrappy": "1"
|
"wrappy": "1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/parse-srcset": {
|
||||||
|
"version": "1.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/parse-srcset/-/parse-srcset-1.0.2.tgz",
|
||||||
|
"integrity": "sha512-/2qh0lav6CmI15FzA3i/2Bzk2zCgQhGMkvhOhKNcBVQ1ldgpbfiNTVslmooUmWJcADi1f1kIeynbDRVzNlfR6Q==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/parseurl": {
|
"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",
|
||||||
@@ -987,6 +1128,12 @@
|
|||||||
"url": "https://opencollective.com/express"
|
"url": "https://opencollective.com/express"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/picocolors": {
|
||||||
|
"version": "1.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
||||||
|
"integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
|
||||||
|
"license": "ISC"
|
||||||
|
},
|
||||||
"node_modules/pkijs": {
|
"node_modules/pkijs": {
|
||||||
"version": "3.3.3",
|
"version": "3.3.3",
|
||||||
"resolved": "https://registry.npmjs.org/pkijs/-/pkijs-3.3.3.tgz",
|
"resolved": "https://registry.npmjs.org/pkijs/-/pkijs-3.3.3.tgz",
|
||||||
@@ -1004,6 +1151,34 @@
|
|||||||
"node": ">=16.0.0"
|
"node": ">=16.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/postcss": {
|
||||||
|
"version": "8.5.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz",
|
||||||
|
"integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==",
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/postcss/"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "tidelift",
|
||||||
|
"url": "https://tidelift.com/funding/github/npm/postcss"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/ai"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"nanoid": "^3.3.11",
|
||||||
|
"picocolors": "^1.1.1",
|
||||||
|
"source-map-js": "^1.2.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "^10 || ^12 || >=14"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/proxy-addr": {
|
"node_modules/proxy-addr": {
|
||||||
"version": "2.0.7",
|
"version": "2.0.7",
|
||||||
"resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
|
"resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
|
||||||
@@ -1129,6 +1304,20 @@
|
|||||||
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
|
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/sanitize-html": {
|
||||||
|
"version": "2.17.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/sanitize-html/-/sanitize-html-2.17.1.tgz",
|
||||||
|
"integrity": "sha512-ehFCW+q1a4CSOWRAdX97BX/6/PDEkCqw7/0JXZAGQV57FQB3YOkTa/rrzHPeJ+Aghy4vZAFfWMYyfxIiB7F/gw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"deepmerge": "^4.2.2",
|
||||||
|
"escape-string-regexp": "^4.0.0",
|
||||||
|
"htmlparser2": "^8.0.0",
|
||||||
|
"is-plain-object": "^5.0.0",
|
||||||
|
"parse-srcset": "^1.0.2",
|
||||||
|
"postcss": "^8.3.11"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/send": {
|
"node_modules/send": {
|
||||||
"version": "1.2.1",
|
"version": "1.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz",
|
||||||
@@ -1258,6 +1447,15 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/source-map-js": {
|
||||||
|
"version": "1.2.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
|
||||||
|
"integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
|
||||||
|
"license": "BSD-3-Clause",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.10.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/standard-as-callback": {
|
"node_modules/standard-as-callback": {
|
||||||
"version": "2.1.0",
|
"version": "2.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/standard-as-callback/-/standard-as-callback-2.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/standard-as-callback/-/standard-as-callback-2.1.0.tgz",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@rmdes/indiekit-endpoint-activitypub",
|
"name": "@rmdes/indiekit-endpoint-activitypub",
|
||||||
"version": "1.0.29",
|
"version": "1.1.0",
|
||||||
"description": "ActivityPub federation endpoint for Indiekit via Fedify. Adds full fediverse support: actor, inbox, outbox, followers, following, syndication, and Mastodon migration.",
|
"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,7 +42,8 @@
|
|||||||
"@fedify/redis": "^1.10.3",
|
"@fedify/redis": "^1.10.3",
|
||||||
"@js-temporal/polyfill": "^0.5.0",
|
"@js-temporal/polyfill": "^0.5.0",
|
||||||
"express": "^5.0.0",
|
"express": "^5.0.0",
|
||||||
"ioredis": "^5.9.3"
|
"ioredis": "^5.9.3",
|
||||||
|
"sanitize-html": "^2.13.1"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@indiekit/error": "^1.0.0-beta.25",
|
"@indiekit/error": "^1.0.0-beta.25",
|
||||||
|
|||||||
94
views/activitypub-compose.njk
Normal file
94
views/activitypub-compose.njk
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
{% extends "layouts/reader.njk" %}
|
||||||
|
|
||||||
|
{% from "heading/macro.njk" import heading with context %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
{{ heading({
|
||||||
|
text: title,
|
||||||
|
level: 1,
|
||||||
|
parent: { text: __("activitypub.reader.title"), href: mountPath + "/admin/reader" }
|
||||||
|
}) }}
|
||||||
|
|
||||||
|
{# Reply context — show the post being replied to #}
|
||||||
|
{% if replyContext %}
|
||||||
|
<div class="ap-compose__context">
|
||||||
|
<div class="ap-compose__context-label">{{ __("activitypub.reader.replyingTo") }}</div>
|
||||||
|
{% if replyContext.author %}
|
||||||
|
<div class="ap-compose__context-author">
|
||||||
|
<a href="{{ replyContext.author.url }}">{{ replyContext.author.name }}</a>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% if replyContext.content and replyContext.content.text %}
|
||||||
|
<blockquote class="ap-compose__context-text">
|
||||||
|
{{ replyContext.content.text | truncate(300) }}
|
||||||
|
</blockquote>
|
||||||
|
{% endif %}
|
||||||
|
<a href="{{ replyTo }}" class="ap-compose__context-link" target="_blank" rel="noopener">{{ replyTo }}</a>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<form method="post" action="{{ mountPath }}/admin/reader/compose" class="ap-compose__form"
|
||||||
|
x-data="{
|
||||||
|
mode: 'micropub',
|
||||||
|
content: '',
|
||||||
|
maxChars: 500,
|
||||||
|
get remaining() { return this.maxChars - this.content.length; }
|
||||||
|
}">
|
||||||
|
<input type="hidden" name="_csrf" value="{{ csrfToken }}">
|
||||||
|
{% if replyTo %}
|
||||||
|
<input type="hidden" name="in-reply-to" value="{{ replyTo }}">
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{# Mode toggle #}
|
||||||
|
<fieldset class="ap-compose__mode">
|
||||||
|
<legend>{{ __("activitypub.compose.modeLabel") }}</legend>
|
||||||
|
<label class="ap-compose__mode-option">
|
||||||
|
<input type="radio" name="mode" value="micropub" x-model="mode" checked>
|
||||||
|
{{ __("activitypub.compose.modeMicropub") }}
|
||||||
|
<span class="ap-compose__mode-hint">{{ __("activitypub.compose.modeMicropubHint") }}</span>
|
||||||
|
</label>
|
||||||
|
<label class="ap-compose__mode-option">
|
||||||
|
<input type="radio" name="mode" value="quick" x-model="mode">
|
||||||
|
{{ __("activitypub.compose.modeQuick") }}
|
||||||
|
<span class="ap-compose__mode-hint">{{ __("activitypub.compose.modeQuickHint") }}</span>
|
||||||
|
</label>
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
{# Content textarea #}
|
||||||
|
<div class="ap-compose__editor">
|
||||||
|
<textarea name="content" class="ap-compose__textarea"
|
||||||
|
rows="6"
|
||||||
|
:maxlength="mode === 'quick' ? maxChars : undefined"
|
||||||
|
x-model="content"
|
||||||
|
placeholder="{{ __('activitypub.compose.placeholder') }}"
|
||||||
|
required></textarea>
|
||||||
|
<div class="ap-compose__counter" x-show="mode === 'quick'" x-cloak>
|
||||||
|
<span :class="{ 'ap-compose__counter--warn': remaining < 50, 'ap-compose__counter--over': remaining < 0 }"
|
||||||
|
x-text="remaining"></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# Syndication targets (Micropub mode only) #}
|
||||||
|
{% if syndicationTargets.length > 0 %}
|
||||||
|
<fieldset class="ap-compose__syndication" x-show="mode === 'micropub'">
|
||||||
|
<legend>{{ __("activitypub.compose.syndicateLabel") }}</legend>
|
||||||
|
{% for target in syndicationTargets %}
|
||||||
|
<label class="ap-compose__syndication-target">
|
||||||
|
<input type="checkbox" name="mp-syndicate-to" value="{{ target.uid }}" checked>
|
||||||
|
{{ target.name }}
|
||||||
|
</label>
|
||||||
|
{% endfor %}
|
||||||
|
</fieldset>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<div class="ap-compose__actions">
|
||||||
|
<button type="submit" class="ap-compose__submit">
|
||||||
|
<span x-show="mode === 'micropub'">{{ __("activitypub.compose.submitMicropub") }}</span>
|
||||||
|
<span x-show="mode === 'quick'">{{ __("activitypub.compose.submitQuick") }}</span>
|
||||||
|
</button>
|
||||||
|
<a href="{{ mountPath }}/admin/reader" class="ap-compose__cancel">
|
||||||
|
{{ __("activitypub.compose.cancel") }}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
{% endblock %}
|
||||||
118
views/activitypub-moderation.njk
Normal file
118
views/activitypub-moderation.njk
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
{% extends "layouts/reader.njk" %}
|
||||||
|
|
||||||
|
{% from "heading/macro.njk" import heading with context %}
|
||||||
|
{% from "prose/macro.njk" import prose with context %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
{{ heading({
|
||||||
|
text: title,
|
||||||
|
level: 1,
|
||||||
|
parent: { text: __("activitypub.reader.title"), href: mountPath + "/admin/reader" }
|
||||||
|
}) }}
|
||||||
|
|
||||||
|
{# Blocked actors #}
|
||||||
|
<section class="ap-moderation__section">
|
||||||
|
<h2>{{ __("activitypub.moderation.blockedTitle") }}</h2>
|
||||||
|
{% if blocked.length > 0 %}
|
||||||
|
<ul class="ap-moderation__list">
|
||||||
|
{% for entry in blocked %}
|
||||||
|
<li class="ap-moderation__entry"
|
||||||
|
x-data="{ removing: false }">
|
||||||
|
<a href="{{ entry.url }}">{{ entry.url }}</a>
|
||||||
|
<button class="ap-moderation__remove"
|
||||||
|
:disabled="removing"
|
||||||
|
@click="
|
||||||
|
removing = true;
|
||||||
|
fetch('{{ mountPath }}/admin/reader/unblock', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': '{{ csrfToken }}' },
|
||||||
|
body: JSON.stringify({ url: '{{ entry.url }}' })
|
||||||
|
}).then(r => r.json()).then(d => { if (d.success) $el.closest('li').remove(); else removing = false; }).catch(() => removing = false);
|
||||||
|
">{{ __("activitypub.moderation.unblock") }}</button>
|
||||||
|
</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
{% else %}
|
||||||
|
{{ prose({ text: __("activitypub.moderation.noBlocked") }) }}
|
||||||
|
{% endif %}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{# Muted actors #}
|
||||||
|
<section class="ap-moderation__section">
|
||||||
|
<h2>{{ __("activitypub.moderation.mutedActorsTitle") }}</h2>
|
||||||
|
{% set mutedActors = muted | selectattr("url") %}
|
||||||
|
{% if mutedActors | length > 0 %}
|
||||||
|
<ul class="ap-moderation__list">
|
||||||
|
{% for entry in mutedActors %}
|
||||||
|
<li class="ap-moderation__entry"
|
||||||
|
x-data="{ removing: false }">
|
||||||
|
<a href="{{ entry.url }}">{{ entry.url }}</a>
|
||||||
|
<button class="ap-moderation__remove"
|
||||||
|
:disabled="removing"
|
||||||
|
@click="
|
||||||
|
removing = true;
|
||||||
|
fetch('{{ mountPath }}/admin/reader/unmute', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': '{{ csrfToken }}' },
|
||||||
|
body: JSON.stringify({ url: '{{ entry.url }}' })
|
||||||
|
}).then(r => r.json()).then(d => { if (d.success) $el.closest('li').remove(); else removing = false; }).catch(() => removing = false);
|
||||||
|
">{{ __("activitypub.moderation.unmute") }}</button>
|
||||||
|
</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
{% else %}
|
||||||
|
{{ prose({ text: __("activitypub.moderation.noMutedActors") }) }}
|
||||||
|
{% endif %}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{# Muted keywords #}
|
||||||
|
<section class="ap-moderation__section">
|
||||||
|
<h2>{{ __("activitypub.moderation.mutedKeywordsTitle") }}</h2>
|
||||||
|
{% set mutedKeywords = muted | selectattr("keyword") %}
|
||||||
|
{% if mutedKeywords | length > 0 %}
|
||||||
|
<ul class="ap-moderation__list">
|
||||||
|
{% for entry in mutedKeywords %}
|
||||||
|
<li class="ap-moderation__entry"
|
||||||
|
x-data="{ removing: false }">
|
||||||
|
<code>{{ entry.keyword }}</code>
|
||||||
|
<button class="ap-moderation__remove"
|
||||||
|
:disabled="removing"
|
||||||
|
@click="
|
||||||
|
removing = true;
|
||||||
|
fetch('{{ mountPath }}/admin/reader/unmute', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': '{{ csrfToken }}' },
|
||||||
|
body: JSON.stringify({ keyword: '{{ entry.keyword }}' })
|
||||||
|
}).then(r => r.json()).then(d => { if (d.success) $el.closest('li').remove(); else removing = false; }).catch(() => removing = false);
|
||||||
|
">{{ __("activitypub.moderation.unmute") }}</button>
|
||||||
|
</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
{% else %}
|
||||||
|
{{ prose({ text: __("activitypub.moderation.noMutedKeywords") }) }}
|
||||||
|
{% endif %}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{# Add keyword mute form #}
|
||||||
|
<section class="ap-moderation__section">
|
||||||
|
<h2>{{ __("activitypub.moderation.addKeywordTitle") }}</h2>
|
||||||
|
<form class="ap-moderation__add-form"
|
||||||
|
x-data="{ keyword: '', submitting: false }"
|
||||||
|
@submit.prevent="
|
||||||
|
if (!keyword.trim()) return;
|
||||||
|
submitting = true;
|
||||||
|
fetch('{{ mountPath }}/admin/reader/mute', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': '{{ csrfToken }}' },
|
||||||
|
body: JSON.stringify({ keyword: keyword.trim() })
|
||||||
|
}).then(r => r.json()).then(d => { if (d.success) location.reload(); submitting = false; }).catch(() => submitting = false);
|
||||||
|
">
|
||||||
|
<input type="text" x-model="keyword"
|
||||||
|
placeholder="{{ __('activitypub.moderation.keywordPlaceholder') }}"
|
||||||
|
class="ap-moderation__input">
|
||||||
|
<button type="submit" :disabled="submitting" class="ap-moderation__add-btn">
|
||||||
|
{{ __("activitypub.moderation.addKeyword") }}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
{% endblock %}
|
||||||
31
views/activitypub-notifications.njk
Normal file
31
views/activitypub-notifications.njk
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
{% extends "layouts/reader.njk" %}
|
||||||
|
|
||||||
|
{% from "heading/macro.njk" import heading with context %}
|
||||||
|
{% from "prose/macro.njk" import prose with context %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
{{ heading({
|
||||||
|
text: __("activitypub.notifications.title"),
|
||||||
|
level: 1,
|
||||||
|
parent: { text: __("activitypub.reader.title"), href: mountPath + "/admin/reader" }
|
||||||
|
}) }}
|
||||||
|
|
||||||
|
{% if items.length > 0 %}
|
||||||
|
<div class="ap-timeline">
|
||||||
|
{% for item in items %}
|
||||||
|
{% include "partials/ap-notification-card.njk" %}
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# Pagination #}
|
||||||
|
{% if before %}
|
||||||
|
<nav class="ap-pagination">
|
||||||
|
<a href="?before={{ before }}" class="ap-pagination__next">
|
||||||
|
{{ __("activitypub.reader.pagination.older") }}
|
||||||
|
</a>
|
||||||
|
</nav>
|
||||||
|
{% endif %}
|
||||||
|
{% else %}
|
||||||
|
{{ prose({ text: __("activitypub.notifications.empty") }) }}
|
||||||
|
{% endif %}
|
||||||
|
{% endblock %}
|
||||||
61
views/activitypub-reader.njk
Normal file
61
views/activitypub-reader.njk
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
{% extends "layouts/reader.njk" %}
|
||||||
|
|
||||||
|
{% from "heading/macro.njk" import heading with context %}
|
||||||
|
{% from "prose/macro.njk" import prose with context %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
{{ heading({
|
||||||
|
text: __("activitypub.reader.title"),
|
||||||
|
level: 1,
|
||||||
|
parent: { text: __("activitypub.title"), href: mountPath }
|
||||||
|
}) }}
|
||||||
|
|
||||||
|
{# Tab navigation #}
|
||||||
|
<nav class="ap-tabs" role="tablist">
|
||||||
|
<a href="?tab=all" class="ap-tab{% if tab == 'all' %} ap-tab--active{% endif %}" role="tab">
|
||||||
|
{{ __("activitypub.reader.tabs.all") }}
|
||||||
|
</a>
|
||||||
|
<a href="?tab=notes" class="ap-tab{% if tab == 'notes' %} ap-tab--active{% endif %}" role="tab">
|
||||||
|
{{ __("activitypub.reader.tabs.notes") }}
|
||||||
|
</a>
|
||||||
|
<a href="?tab=articles" class="ap-tab{% if tab == 'articles' %} ap-tab--active{% endif %}" role="tab">
|
||||||
|
{{ __("activitypub.reader.tabs.articles") }}
|
||||||
|
</a>
|
||||||
|
<a href="?tab=replies" class="ap-tab{% if tab == 'replies' %} ap-tab--active{% endif %}" role="tab">
|
||||||
|
{{ __("activitypub.reader.tabs.replies") }}
|
||||||
|
</a>
|
||||||
|
<a href="?tab=boosts" class="ap-tab{% if tab == 'boosts' %} ap-tab--active{% endif %}" role="tab">
|
||||||
|
{{ __("activitypub.reader.tabs.boosts") }}
|
||||||
|
</a>
|
||||||
|
<a href="?tab=media" class="ap-tab{% if tab == 'media' %} ap-tab--active{% endif %}" role="tab">
|
||||||
|
{{ __("activitypub.reader.tabs.media") }}
|
||||||
|
</a>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
{# Timeline items #}
|
||||||
|
{% if items.length > 0 %}
|
||||||
|
<div class="ap-timeline">
|
||||||
|
{% for item in items %}
|
||||||
|
{% include "partials/ap-item-card.njk" %}
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# Pagination #}
|
||||||
|
{% if before or after %}
|
||||||
|
<nav class="ap-pagination">
|
||||||
|
{% if after %}
|
||||||
|
<a href="?tab={{ tab }}&after={{ after }}" class="ap-pagination__prev">
|
||||||
|
{{ __("activitypub.reader.pagination.newer") }}
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
{% if before %}
|
||||||
|
<a href="?tab={{ tab }}&before={{ before }}" class="ap-pagination__next">
|
||||||
|
{{ __("activitypub.reader.pagination.older") }}
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
</nav>
|
||||||
|
{% endif %}
|
||||||
|
{% else %}
|
||||||
|
{{ prose({ text: __("activitypub.reader.empty") }) }}
|
||||||
|
{% endif %}
|
||||||
|
{% endblock %}
|
||||||
117
views/activitypub-remote-profile.njk
Normal file
117
views/activitypub-remote-profile.njk
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
{% extends "layouts/reader.njk" %}
|
||||||
|
|
||||||
|
{% from "heading/macro.njk" import heading with context %}
|
||||||
|
{% from "prose/macro.njk" import prose with context %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
{{ heading({
|
||||||
|
text: title,
|
||||||
|
level: 1,
|
||||||
|
parent: { text: __("activitypub.reader.title"), href: mountPath + "/admin/reader" }
|
||||||
|
}) }}
|
||||||
|
|
||||||
|
<div class="ap-profile"
|
||||||
|
x-data="{
|
||||||
|
following: {{ 'true' if isFollowing else 'false' }},
|
||||||
|
muted: {{ 'true' if isMuted else 'false' }},
|
||||||
|
blocked: {{ 'true' if isBlocked else 'false' }},
|
||||||
|
loading: false,
|
||||||
|
async action(endpoint, body) {
|
||||||
|
if (this.loading) return;
|
||||||
|
this.loading = true;
|
||||||
|
try {
|
||||||
|
const res = await fetch('{{ mountPath }}/admin/reader/' + endpoint, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'X-CSRF-Token': '{{ csrfToken }}'
|
||||||
|
},
|
||||||
|
body: JSON.stringify(body)
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
return data.success;
|
||||||
|
} catch { return false; }
|
||||||
|
finally { this.loading = false; }
|
||||||
|
}
|
||||||
|
}">
|
||||||
|
|
||||||
|
{# Header image #}
|
||||||
|
{% if image %}
|
||||||
|
<div class="ap-profile__header">
|
||||||
|
<img src="{{ image }}" alt="" class="ap-profile__header-img">
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{# Profile info #}
|
||||||
|
<div class="ap-profile__info">
|
||||||
|
<div class="ap-profile__avatar-wrap">
|
||||||
|
{% if icon %}
|
||||||
|
<img src="{{ icon }}" alt="{{ name }}" class="ap-profile__avatar">
|
||||||
|
{% else %}
|
||||||
|
<div class="ap-profile__avatar ap-profile__avatar--placeholder">{{ name[0] }}</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="ap-profile__details">
|
||||||
|
<h2 class="ap-profile__name">{{ name }}</h2>
|
||||||
|
{% if actorHandle %}
|
||||||
|
<div class="ap-profile__handle">@{{ actorHandle }}@{{ instanceHost }}</div>
|
||||||
|
{% endif %}
|
||||||
|
{% if summary %}
|
||||||
|
<div class="ap-profile__bio">{{ summary | safe }}</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# Action buttons #}
|
||||||
|
<div class="ap-profile__actions">
|
||||||
|
<button class="ap-profile__action ap-profile__action--follow"
|
||||||
|
:class="{ 'ap-profile__action--active': following }"
|
||||||
|
:disabled="loading"
|
||||||
|
@click="
|
||||||
|
const ok = await action(following ? 'unfollow' : 'follow', { url: '{{ actorUrl }}' });
|
||||||
|
if (ok) following = !following;
|
||||||
|
">
|
||||||
|
<span x-text="following ? '{{ __('activitypub.profile.remote.unfollow') }}' : '{{ __('activitypub.profile.remote.follow') }}'"></span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button class="ap-profile__action"
|
||||||
|
:disabled="loading"
|
||||||
|
@click="
|
||||||
|
const ok = await action(muted ? 'unmute' : 'mute', { url: '{{ actorUrl }}' });
|
||||||
|
if (ok) muted = !muted;
|
||||||
|
">
|
||||||
|
<span x-text="muted ? '{{ __('activitypub.moderation.unmute') }}' : '{{ __('activitypub.moderation.muteActor') }}'"></span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button class="ap-profile__action ap-profile__action--danger"
|
||||||
|
:disabled="loading"
|
||||||
|
@click="
|
||||||
|
const ok = await action(blocked ? 'unblock' : 'block', { url: '{{ actorUrl }}' });
|
||||||
|
if (ok) blocked = !blocked;
|
||||||
|
">
|
||||||
|
<span x-text="blocked ? '{{ __('activitypub.moderation.unblock') }}' : '{{ __('activitypub.moderation.blockActor') }}'"></span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<a href="{{ actorUrl }}" class="ap-profile__action" target="_blank" rel="noopener">
|
||||||
|
{{ __("activitypub.profile.remote.viewOn") }} {{ instanceHost }}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# Posts from this actor #}
|
||||||
|
<div class="ap-profile__posts">
|
||||||
|
<h3>{{ __("activitypub.profile.remote.postsTitle") }}</h3>
|
||||||
|
{% if posts.length > 0 %}
|
||||||
|
<div class="ap-timeline">
|
||||||
|
{% for item in posts %}
|
||||||
|
{% include "partials/ap-item-card.njk" %}
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% elif isFollowing %}
|
||||||
|
{{ prose({ text: __("activitypub.profile.remote.noPosts") }) }}
|
||||||
|
{% else %}
|
||||||
|
{{ prose({ text: __("activitypub.profile.remote.followToSee") }) }}
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
9
views/layouts/reader.njk
Normal file
9
views/layouts/reader.njk
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
{% extends "document.njk" %}
|
||||||
|
|
||||||
|
{% block head %}
|
||||||
|
{# Alpine.js for client-side reactivity #}
|
||||||
|
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.14/dist/cdn.min.js"></script>
|
||||||
|
|
||||||
|
{# Reader stylesheet #}
|
||||||
|
<link rel="stylesheet" href="/assets/@rmdes-indiekit-endpoint-activitypub/reader.css">
|
||||||
|
{% endblock %}
|
||||||
157
views/partials/ap-item-card.njk
Normal file
157
views/partials/ap-item-card.njk
Normal file
@@ -0,0 +1,157 @@
|
|||||||
|
{# Timeline item card partial - reusable across timeline and profile views #}
|
||||||
|
|
||||||
|
<article class="ap-card">
|
||||||
|
{# Boost header if this is a boosted post #}
|
||||||
|
{% if item.type == "boost" and item.boostedBy %}
|
||||||
|
<div class="ap-card__boost">
|
||||||
|
🔁 <a href="{{ item.boostedBy.url }}">{{ item.boostedBy.name }}</a> {{ __("activitypub.reader.boosted") }}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{# Reply context if this is a reply #}
|
||||||
|
{% if item.inReplyTo %}
|
||||||
|
<div class="ap-card__reply-to">
|
||||||
|
↩ {{ __("activitypub.reader.replyingTo") }} <a href="{{ item.inReplyTo }}">{{ item.inReplyTo }}</a>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{# Author header #}
|
||||||
|
<header class="ap-card__author">
|
||||||
|
<img src="{{ item.author.photo }}" alt="{{ item.author.name }}" class="ap-card__avatar">
|
||||||
|
<div class="ap-card__author-info">
|
||||||
|
<div class="ap-card__author-name">
|
||||||
|
<a href="{{ item.author.url }}">{{ item.author.name }}</a>
|
||||||
|
</div>
|
||||||
|
<div class="ap-card__author-handle">{{ item.author.handle }}</div>
|
||||||
|
</div>
|
||||||
|
<time datetime="{{ item.published }}" class="ap-card__timestamp">
|
||||||
|
{{ item.published | date("PPp") }}
|
||||||
|
</time>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{# Post title (articles only) #}
|
||||||
|
{% if item.name %}
|
||||||
|
<h2 class="ap-card__title">
|
||||||
|
<a href="{{ item.url }}">{{ item.name }}</a>
|
||||||
|
</h2>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{# Determine if content should be hidden behind CW #}
|
||||||
|
{% set hasCW = item.summary or item.sensitive %}
|
||||||
|
{% set cwLabel = item.summary if item.summary else __("activitypub.reader.sensitiveContent") %}
|
||||||
|
|
||||||
|
{% if hasCW %}
|
||||||
|
<div class="ap-card__cw" x-data="{ shown: false }">
|
||||||
|
<button @click="shown = !shown" class="ap-card__cw-toggle">
|
||||||
|
<span x-show="!shown">⚠️ {{ cwLabel }} — {{ __("activitypub.reader.showContent") }}</span>
|
||||||
|
<span x-show="shown" x-cloak>{{ __("activitypub.reader.hideContent") }}</span>
|
||||||
|
</button>
|
||||||
|
<div x-show="shown" x-cloak>
|
||||||
|
{% if item.content and item.content.html %}
|
||||||
|
<div class="ap-card__content">
|
||||||
|
{{ item.content.html | safe }}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{# Media hidden behind CW #}
|
||||||
|
{% include "partials/ap-item-media.njk" %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
{# Regular content (no CW) #}
|
||||||
|
{% if item.content and item.content.html %}
|
||||||
|
<div class="ap-card__content">
|
||||||
|
{{ item.content.html | safe }}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{# Media visible directly #}
|
||||||
|
{% include "partials/ap-item-media.njk" %}
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{# Tags/categories #}
|
||||||
|
{% if item.category and item.category.length > 0 %}
|
||||||
|
<div class="ap-card__tags">
|
||||||
|
{% for tag in item.category %}
|
||||||
|
<a href="?tag={{ tag }}" class="ap-card__tag">#{{ tag }}</a>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{# Interaction buttons — Alpine.js for optimistic updates #}
|
||||||
|
{# Dynamic data moved to data-* attributes to prevent XSS from inline interpolation #}
|
||||||
|
{% set itemUrl = item.url or item.originalUrl %}
|
||||||
|
{% set isLiked = interactionMap[itemUrl].like if interactionMap[itemUrl] else false %}
|
||||||
|
{% set isBoosted = interactionMap[itemUrl].boost if interactionMap[itemUrl] else false %}
|
||||||
|
<footer class="ap-card__actions"
|
||||||
|
data-item-url="{{ itemUrl }}"
|
||||||
|
data-csrf-token="{{ csrfToken }}"
|
||||||
|
data-mount-path="{{ mountPath }}"
|
||||||
|
x-data="{
|
||||||
|
liked: {{ 'true' if isLiked else 'false' }},
|
||||||
|
boosted: {{ 'true' if isBoosted else 'false' }},
|
||||||
|
loading: false,
|
||||||
|
error: '',
|
||||||
|
async interact(action) {
|
||||||
|
if (this.loading) return;
|
||||||
|
this.loading = true;
|
||||||
|
this.error = '';
|
||||||
|
const el = this.$root;
|
||||||
|
const itemUrl = el.dataset.itemUrl;
|
||||||
|
const csrfToken = el.dataset.csrfToken;
|
||||||
|
const basePath = el.dataset.mountPath;
|
||||||
|
const prev = { liked: this.liked, boosted: this.boosted };
|
||||||
|
if (action === 'like') this.liked = true;
|
||||||
|
else if (action === 'unlike') this.liked = false;
|
||||||
|
else if (action === 'boost') this.boosted = true;
|
||||||
|
else if (action === 'unboost') this.boosted = false;
|
||||||
|
try {
|
||||||
|
const res = await fetch(basePath + '/admin/reader/' + action, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'X-CSRF-Token': csrfToken
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ url: itemUrl })
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
if (!data.success) {
|
||||||
|
this.liked = prev.liked;
|
||||||
|
this.boosted = prev.boosted;
|
||||||
|
this.error = data.error || 'Failed';
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
this.liked = prev.liked;
|
||||||
|
this.boosted = prev.boosted;
|
||||||
|
this.error = e.message;
|
||||||
|
}
|
||||||
|
this.loading = false;
|
||||||
|
if (this.error) setTimeout(() => this.error = '', 3000);
|
||||||
|
}
|
||||||
|
}">
|
||||||
|
<a href="{{ mountPath }}/admin/reader/compose?replyTo={{ itemUrl | urlencode }}"
|
||||||
|
class="ap-card__action ap-card__action--reply"
|
||||||
|
title="{{ __('activitypub.reader.actions.reply') }}">
|
||||||
|
↩ {{ __("activitypub.reader.actions.reply") }}
|
||||||
|
</a>
|
||||||
|
<button class="ap-card__action ap-card__action--boost"
|
||||||
|
:class="{ 'ap-card__action--active': boosted }"
|
||||||
|
:title="boosted ? '{{ __('activitypub.reader.actions.unboost') }}' : '{{ __('activitypub.reader.actions.boost') }}'"
|
||||||
|
:disabled="loading"
|
||||||
|
@click="interact(boosted ? 'unboost' : 'boost')">
|
||||||
|
🔁 <span x-text="boosted ? '{{ __('activitypub.reader.actions.boosted') }}' : '{{ __('activitypub.reader.actions.boost') }}'"></span>
|
||||||
|
</button>
|
||||||
|
<button class="ap-card__action ap-card__action--like"
|
||||||
|
:class="{ 'ap-card__action--active': liked }"
|
||||||
|
:title="liked ? '{{ __('activitypub.reader.actions.unlike') }}' : '{{ __('activitypub.reader.actions.like') }}'"
|
||||||
|
:disabled="loading"
|
||||||
|
@click="interact(liked ? 'unlike' : 'like')">
|
||||||
|
<span x-text="liked ? '❤️' : '♥'"></span>
|
||||||
|
<span x-text="liked ? '{{ __('activitypub.reader.actions.liked') }}' : '{{ __('activitypub.reader.actions.like') }}'"></span>
|
||||||
|
</button>
|
||||||
|
<a href="{{ itemUrl }}" class="ap-card__action ap-card__action--link" target="_blank" rel="noopener">
|
||||||
|
🔗 {{ __("activitypub.reader.actions.viewOriginal") }}
|
||||||
|
</a>
|
||||||
|
<div x-show="error" x-text="error" class="ap-card__action-error" x-transition></div>
|
||||||
|
</footer>
|
||||||
|
</article>
|
||||||
37
views/partials/ap-item-media.njk
Normal file
37
views/partials/ap-item-media.njk
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
{# Media attachments partial — included from ap-item-card.njk #}
|
||||||
|
|
||||||
|
{# Photo gallery #}
|
||||||
|
{% if item.photo and item.photo.length > 0 %}
|
||||||
|
{% set displayCount = [item.photo.length, 4] | min %}
|
||||||
|
{% set extraCount = item.photo.length - 4 %}
|
||||||
|
<div class="ap-card__gallery ap-card__gallery--{{ displayCount }}">
|
||||||
|
{% for photoUrl in item.photo %}
|
||||||
|
{% if loop.index0 < 4 %}
|
||||||
|
<a href="{{ photoUrl }}" target="_blank" rel="noopener" class="ap-card__gallery-link{% if loop.index0 == 3 and extraCount > 0 %} ap-card__gallery-link--more{% endif %}">
|
||||||
|
<img src="{{ photoUrl }}" alt="" loading="lazy">
|
||||||
|
{% if loop.index0 == 3 and extraCount > 0 %}
|
||||||
|
<span class="ap-card__gallery-more">+{{ extraCount }}</span>
|
||||||
|
{% endif %}
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{# Video embed #}
|
||||||
|
{% if item.video and item.video.length > 0 %}
|
||||||
|
<div class="ap-card__video">
|
||||||
|
<video controls preload="metadata" src="{{ item.video[0] }}">
|
||||||
|
{{ __("activitypub.reader.videoNotSupported") }}
|
||||||
|
</video>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{# Audio player #}
|
||||||
|
{% if item.audio and item.audio.length > 0 %}
|
||||||
|
<div class="ap-card__audio">
|
||||||
|
<audio controls preload="metadata" src="{{ item.audio[0] }}">
|
||||||
|
{{ __("activitypub.reader.audioNotSupported") }}
|
||||||
|
</audio>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
58
views/partials/ap-notification-card.njk
Normal file
58
views/partials/ap-notification-card.njk
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
{# Notification card partial #}
|
||||||
|
|
||||||
|
<div class="ap-notification{% if not item.read %} ap-notification--unread{% endif %}">
|
||||||
|
{# Type icon #}
|
||||||
|
<div class="ap-notification__icon">
|
||||||
|
{% if item.type == "like" %}
|
||||||
|
❤
|
||||||
|
{% elif item.type == "boost" %}
|
||||||
|
🔁
|
||||||
|
{% elif item.type == "follow" %}
|
||||||
|
👤
|
||||||
|
{% elif item.type == "reply" %}
|
||||||
|
💬
|
||||||
|
{% elif item.type == "mention" %}
|
||||||
|
@
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# Notification body #}
|
||||||
|
<div class="ap-notification__body">
|
||||||
|
<span class="ap-notification__actor">
|
||||||
|
<a href="{{ item.actorUrl }}">{{ item.actorName }}</a>
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<span class="ap-notification__action">
|
||||||
|
{% if item.type == "like" %}
|
||||||
|
{{ __("activitypub.notifications.liked") }}
|
||||||
|
{% elif item.type == "boost" %}
|
||||||
|
{{ __("activitypub.notifications.boostedPost") }}
|
||||||
|
{% elif item.type == "follow" %}
|
||||||
|
{{ __("activitypub.notifications.followedYou") }}
|
||||||
|
{% elif item.type == "reply" %}
|
||||||
|
{{ __("activitypub.notifications.repliedTo") }}
|
||||||
|
{% elif item.type == "mention" %}
|
||||||
|
{{ __("activitypub.notifications.mentionedYou") }}
|
||||||
|
{% endif %}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
{% if item.targetUrl %}
|
||||||
|
<a href="{{ item.targetUrl }}" class="ap-notification__target">
|
||||||
|
{{ item.targetName or item.targetUrl }}
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if item.content and item.content.text %}
|
||||||
|
<div class="ap-notification__excerpt">
|
||||||
|
{{ item.content.text | truncate(200) }}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# Timestamp #}
|
||||||
|
{% if item.published %}
|
||||||
|
<time datetime="{{ item.published }}" class="ap-notification__time">
|
||||||
|
{{ item.published | date("PPp") }}
|
||||||
|
</time>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
Reference in New Issue
Block a user