mirror of
https://github.com/svemagie/indiekit-endpoint-microsub.git
synced 2026-04-02 15:35:00 +02:00
feat: restore full microsub implementation with reader UI
Restores complete implementation from feat/endpoint-microsub branch: - Reader UI with views (reader.njk, channel.njk, feeds.njk, etc.) - Feed polling, parsing, and normalization - WebSub subscriber - SSE realtime updates - Redis caching - Search indexing - Media proxy - Webmention processing
This commit is contained in:
764
assets/styles.css
Normal file
764
assets/styles.css
Normal file
@@ -0,0 +1,764 @@
|
||||
/**
|
||||
* Microsub Reader Styles
|
||||
* Inspired by Aperture/Monocle
|
||||
*/
|
||||
|
||||
/* ==========================================================================
|
||||
Reader Layout
|
||||
========================================================================== */
|
||||
|
||||
.reader {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-m);
|
||||
}
|
||||
|
||||
.reader__header {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-s);
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.reader__actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-xs);
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
Channel List
|
||||
========================================================================== */
|
||||
|
||||
.reader__channels {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-xs);
|
||||
}
|
||||
|
||||
.reader__channel {
|
||||
align-items: center;
|
||||
background: var(--color-offset);
|
||||
border-radius: var(--border-radius);
|
||||
color: inherit;
|
||||
display: flex;
|
||||
gap: var(--space-s);
|
||||
padding: var(--space-s) var(--space-m);
|
||||
text-decoration: none;
|
||||
transition:
|
||||
background-color 0.2s ease,
|
||||
box-shadow 0.2s ease;
|
||||
}
|
||||
|
||||
.reader__channel:hover {
|
||||
background: var(--color-offset-active);
|
||||
}
|
||||
|
||||
.reader__channel--active {
|
||||
background: var(--color-primary);
|
||||
color: var(--color-background);
|
||||
}
|
||||
|
||||
.reader__channel-name {
|
||||
flex: 1;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.reader__channel-badge {
|
||||
align-items: center;
|
||||
background: var(--color-primary);
|
||||
border-radius: 0.75rem;
|
||||
color: var(--color-background);
|
||||
display: inline-flex;
|
||||
font-size: var(--font-size-small);
|
||||
font-weight: 600;
|
||||
height: 1.5rem;
|
||||
justify-content: center;
|
||||
min-width: 1.5rem;
|
||||
padding: 0 var(--space-xs);
|
||||
}
|
||||
|
||||
.reader__channel--active .reader__channel-badge {
|
||||
background: var(--color-background);
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
/* Dot indicator for boolean unread state */
|
||||
.reader__channel-badge--dot {
|
||||
height: 0.75rem;
|
||||
min-width: 0.75rem;
|
||||
padding: 0;
|
||||
width: 0.75rem;
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
Timeline
|
||||
========================================================================== */
|
||||
|
||||
.timeline {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-m);
|
||||
}
|
||||
|
||||
.timeline__paging {
|
||||
border-top: 1px solid var(--color-offset);
|
||||
display: flex;
|
||||
gap: var(--space-m);
|
||||
justify-content: space-between;
|
||||
padding-top: var(--space-m);
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
Item Card
|
||||
========================================================================== */
|
||||
|
||||
.item-card {
|
||||
background: var(--color-background);
|
||||
border: 1px solid var(--color-offset);
|
||||
border-radius: var(--border-radius);
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
transition:
|
||||
box-shadow 0.2s ease,
|
||||
transform 0.1s ease;
|
||||
}
|
||||
|
||||
.item-card:hover {
|
||||
border-color: var(--color-offset-active);
|
||||
}
|
||||
|
||||
/* Unread state - yellow glow (Aperture pattern) */
|
||||
.item-card:not(.item-card--read) {
|
||||
border-color: rgba(255, 204, 0, 0.5);
|
||||
box-shadow: 0 0 10px 0 rgba(255, 204, 0, 0.8);
|
||||
}
|
||||
|
||||
.item-card--read {
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.item-card__link {
|
||||
color: inherit;
|
||||
display: block;
|
||||
padding: var(--space-m);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
/* Author */
|
||||
.item-card__author {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
gap: var(--space-s);
|
||||
margin-bottom: var(--space-s);
|
||||
}
|
||||
|
||||
.item-card__author-photo {
|
||||
border: 1px solid var(--color-offset);
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
height: 40px;
|
||||
object-fit: cover;
|
||||
width: 40px;
|
||||
}
|
||||
|
||||
.item-card__author-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.item-card__author-name {
|
||||
font-size: var(--font-size-body);
|
||||
font-weight: 600;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.item-card__source {
|
||||
color: var(--color-text-muted);
|
||||
font-size: var(--font-size-small);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Post type indicator */
|
||||
.item-card__type {
|
||||
align-items: center;
|
||||
background: var(--color-offset);
|
||||
border-radius: var(--border-radius);
|
||||
color: var(--color-text-muted);
|
||||
display: inline-flex;
|
||||
font-size: var(--font-size-small);
|
||||
gap: var(--space-xs);
|
||||
margin-bottom: var(--space-s);
|
||||
padding: var(--space-xs) var(--space-s);
|
||||
}
|
||||
|
||||
.item-card__type svg {
|
||||
height: 1em;
|
||||
width: 1em;
|
||||
}
|
||||
|
||||
/* Context bar for interactions (Aperture pattern) */
|
||||
.item-card__context {
|
||||
align-items: center;
|
||||
background: var(--color-offset);
|
||||
display: flex;
|
||||
font-size: var(--font-size-small);
|
||||
gap: var(--space-xs);
|
||||
margin: calc(-1 * var(--space-m));
|
||||
margin-bottom: var(--space-s);
|
||||
padding: var(--space-s) var(--space-m);
|
||||
}
|
||||
|
||||
.item-card__context a {
|
||||
color: var(--color-primary);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.item-card__context a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.item-card__context svg {
|
||||
flex-shrink: 0;
|
||||
height: 1em;
|
||||
width: 1em;
|
||||
}
|
||||
|
||||
/* Title */
|
||||
.item-card__title {
|
||||
font-size: var(--font-size-heading-4);
|
||||
font-weight: 600;
|
||||
line-height: 1.3;
|
||||
margin-bottom: var(--space-xs);
|
||||
}
|
||||
|
||||
/* Content with expandable overflow (Aperture pattern) */
|
||||
.item-card__content {
|
||||
color: var(--color-text);
|
||||
line-height: 1.5;
|
||||
margin-bottom: var(--space-s);
|
||||
max-height: 200px;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.item-card__content--expanded {
|
||||
max-height: none;
|
||||
}
|
||||
|
||||
.item-card__content--truncated::after {
|
||||
background: linear-gradient(to bottom, transparent, var(--color-background));
|
||||
bottom: 0;
|
||||
content: "";
|
||||
height: 60px;
|
||||
left: 0;
|
||||
pointer-events: none;
|
||||
position: absolute;
|
||||
right: 0;
|
||||
}
|
||||
|
||||
.item-card__read-more {
|
||||
color: var(--color-primary);
|
||||
cursor: pointer;
|
||||
display: block;
|
||||
font-size: var(--font-size-small);
|
||||
padding: var(--space-xs);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* Photo grid (Aperture multi-photo pattern) */
|
||||
.item-card__photos {
|
||||
border-radius: var(--border-radius);
|
||||
display: grid;
|
||||
gap: 2px;
|
||||
margin-bottom: var(--space-s);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Single photo */
|
||||
.item-card__photos--1 {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
/* 2 photos - side by side */
|
||||
.item-card__photos--2 {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
|
||||
/* 3 photos - one large, two small */
|
||||
.item-card__photos--3 {
|
||||
grid-template-columns: 2fr 1fr;
|
||||
grid-template-rows: 1fr 1fr;
|
||||
}
|
||||
|
||||
/* 4+ photos - grid */
|
||||
.item-card__photos--4 {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
grid-template-rows: 1fr 1fr;
|
||||
}
|
||||
|
||||
/* Base photo styles - must come before specific overrides */
|
||||
.item-card__photo {
|
||||
background: var(--color-offset);
|
||||
height: 150px;
|
||||
object-fit: cover;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.item-card__photos--1 .item-card__photo {
|
||||
height: auto;
|
||||
max-height: 400px;
|
||||
}
|
||||
|
||||
.item-card__photos--3 .item-card__photo:first-child {
|
||||
grid-row: 1 / 3;
|
||||
height: 302px;
|
||||
}
|
||||
|
||||
/* Video/Audio */
|
||||
.item-card__video,
|
||||
.item-card__audio {
|
||||
border-radius: var(--border-radius);
|
||||
margin-bottom: var(--space-s);
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* Footer */
|
||||
.item-card__footer {
|
||||
align-items: center;
|
||||
border-top: 1px solid var(--color-offset);
|
||||
color: var(--color-text-muted);
|
||||
display: flex;
|
||||
font-size: var(--font-size-small);
|
||||
justify-content: space-between;
|
||||
padding-top: var(--space-s);
|
||||
}
|
||||
|
||||
.item-card__date {
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.item-card__unread {
|
||||
color: var(--color-warning, #ffcc00);
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
/* Categories/Tags */
|
||||
.item-card__categories {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-xs);
|
||||
margin-bottom: var(--space-s);
|
||||
}
|
||||
|
||||
.item-card__category {
|
||||
background: var(--color-offset);
|
||||
border-radius: var(--border-radius);
|
||||
color: var(--color-text-muted);
|
||||
display: inline-block;
|
||||
font-size: var(--font-size-small);
|
||||
padding: 2px var(--space-xs);
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
Item Actions (inline on cards)
|
||||
========================================================================== */
|
||||
|
||||
.item-actions {
|
||||
border-top: 1px solid var(--color-offset);
|
||||
display: flex;
|
||||
gap: var(--space-s);
|
||||
padding-top: var(--space-s);
|
||||
}
|
||||
|
||||
.item-actions__button {
|
||||
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;
|
||||
}
|
||||
|
||||
.item-actions__button:hover {
|
||||
background: var(--color-offset);
|
||||
border-color: var(--color-offset-active);
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.item-actions__button svg {
|
||||
height: 1em;
|
||||
width: 1em;
|
||||
}
|
||||
|
||||
.item-actions__button--primary {
|
||||
background: var(--color-primary);
|
||||
border-color: var(--color-primary);
|
||||
color: var(--color-background);
|
||||
}
|
||||
|
||||
.item-actions__button--primary:hover {
|
||||
background: var(--color-primary-dark, var(--color-primary));
|
||||
border-color: var(--color-primary-dark, var(--color-primary));
|
||||
color: var(--color-background);
|
||||
}
|
||||
|
||||
/* Mark as read button */
|
||||
.item-actions__mark-read {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
Single Item View
|
||||
========================================================================== */
|
||||
|
||||
.item {
|
||||
max-width: 40rem;
|
||||
}
|
||||
|
||||
.item__header {
|
||||
margin-bottom: var(--space-m);
|
||||
}
|
||||
|
||||
.item__author {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
gap: var(--space-s);
|
||||
margin-bottom: var(--space-m);
|
||||
}
|
||||
|
||||
.item__author-photo {
|
||||
border-radius: 50%;
|
||||
height: 48px;
|
||||
object-fit: cover;
|
||||
width: 48px;
|
||||
}
|
||||
|
||||
.item__author-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.item__author-name {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.item__date {
|
||||
color: var(--color-text-muted);
|
||||
font-size: var(--font-size-small);
|
||||
}
|
||||
|
||||
.item__title {
|
||||
font-size: var(--font-size-heading-2);
|
||||
margin-bottom: var(--space-m);
|
||||
}
|
||||
|
||||
.item__content {
|
||||
line-height: 1.6;
|
||||
margin-bottom: var(--space-m);
|
||||
}
|
||||
|
||||
.item__content img {
|
||||
border-radius: var(--border-radius);
|
||||
height: auto;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.item__photos {
|
||||
display: grid;
|
||||
gap: var(--space-s);
|
||||
margin-bottom: var(--space-m);
|
||||
}
|
||||
|
||||
.item__photo {
|
||||
border-radius: var(--border-radius);
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.item__context {
|
||||
background: var(--color-offset);
|
||||
border-radius: var(--border-radius);
|
||||
margin-bottom: var(--space-m);
|
||||
padding: var(--space-m);
|
||||
}
|
||||
|
||||
.item__context-label {
|
||||
color: var(--color-text-muted);
|
||||
font-size: var(--font-size-small);
|
||||
margin-bottom: var(--space-xs);
|
||||
}
|
||||
|
||||
.item__actions {
|
||||
border-top: 1px solid var(--color-offset);
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-s);
|
||||
padding-top: var(--space-m);
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
Channel Header
|
||||
========================================================================== */
|
||||
|
||||
.channel__header {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-s);
|
||||
justify-content: space-between;
|
||||
margin-bottom: var(--space-m);
|
||||
}
|
||||
|
||||
.channel__actions {
|
||||
display: flex;
|
||||
gap: var(--space-xs);
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
Feeds Management
|
||||
========================================================================== */
|
||||
|
||||
.feeds {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-m);
|
||||
}
|
||||
|
||||
.feeds__header {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-s);
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.feeds__list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-s);
|
||||
}
|
||||
|
||||
.feeds__item {
|
||||
align-items: center;
|
||||
background: var(--color-offset);
|
||||
border-radius: var(--border-radius);
|
||||
display: flex;
|
||||
gap: var(--space-m);
|
||||
padding: var(--space-m);
|
||||
}
|
||||
|
||||
.feeds__photo {
|
||||
border-radius: var(--border-radius);
|
||||
flex-shrink: 0;
|
||||
height: 48px;
|
||||
object-fit: cover;
|
||||
width: 48px;
|
||||
}
|
||||
|
||||
.feeds__info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.feeds__name {
|
||||
font-weight: 600;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.feeds__url {
|
||||
color: var(--color-text-muted);
|
||||
font-size: var(--font-size-small);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.feeds__actions {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.feeds__add {
|
||||
background: var(--color-offset);
|
||||
border-radius: var(--border-radius);
|
||||
padding: var(--space-m);
|
||||
}
|
||||
|
||||
.feeds__form {
|
||||
display: flex;
|
||||
gap: var(--space-s);
|
||||
}
|
||||
|
||||
.feeds__form input {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
Search
|
||||
========================================================================== */
|
||||
|
||||
.search {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-m);
|
||||
}
|
||||
|
||||
.search__form {
|
||||
display: flex;
|
||||
gap: var(--space-s);
|
||||
}
|
||||
|
||||
.search__form input {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.search__results {
|
||||
margin-top: var(--space-m);
|
||||
}
|
||||
|
||||
.search__list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-s);
|
||||
}
|
||||
|
||||
.search__item {
|
||||
align-items: center;
|
||||
background: var(--color-offset);
|
||||
border-radius: var(--border-radius);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: var(--space-m);
|
||||
}
|
||||
|
||||
.search__feed {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.search__url {
|
||||
color: var(--color-text-muted);
|
||||
font-size: var(--font-size-small);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
Compose
|
||||
========================================================================== */
|
||||
|
||||
.compose {
|
||||
max-width: 40rem;
|
||||
}
|
||||
|
||||
.compose__context {
|
||||
background: var(--color-offset);
|
||||
border-radius: var(--border-radius);
|
||||
margin-bottom: var(--space-m);
|
||||
padding: var(--space-m);
|
||||
}
|
||||
|
||||
.compose__counter {
|
||||
color: var(--color-text-muted);
|
||||
font-size: var(--font-size-small);
|
||||
margin-top: var(--space-xs);
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
Settings
|
||||
========================================================================== */
|
||||
|
||||
.settings {
|
||||
max-width: 40rem;
|
||||
}
|
||||
|
||||
.settings .divider {
|
||||
border-top: 1px solid var(--color-offset);
|
||||
margin: var(--space-l) 0;
|
||||
}
|
||||
|
||||
.settings .danger-zone {
|
||||
background: rgba(var(--color-error-rgb, 255, 0, 0), 0.1);
|
||||
border: 1px solid var(--color-error);
|
||||
border-radius: var(--border-radius);
|
||||
padding: var(--space-m);
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
Keyboard Navigation Focus
|
||||
========================================================================== */
|
||||
|
||||
.item-card:focus-within,
|
||||
.item-card.item-card--focused {
|
||||
outline: 2px solid var(--color-primary);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
Empty States
|
||||
========================================================================== */
|
||||
|
||||
.reader__empty {
|
||||
color: var(--color-text-muted);
|
||||
padding: var(--space-xl);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.reader__empty svg {
|
||||
height: 4rem;
|
||||
margin-bottom: var(--space-m);
|
||||
opacity: 0.5;
|
||||
width: 4rem;
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
Responsive
|
||||
========================================================================== */
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.item-card__photos--3 {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
grid-template-rows: auto auto;
|
||||
}
|
||||
|
||||
.item-card__photos--3 .item-card__photo:first-child {
|
||||
grid-column: 1 / 3;
|
||||
grid-row: 1;
|
||||
height: 200px;
|
||||
}
|
||||
|
||||
.item-card__photos--3 .item-card__photo:nth-child(2),
|
||||
.item-card__photos--3 .item-card__photo:nth-child(3) {
|
||||
height: 100px;
|
||||
}
|
||||
|
||||
.feeds__item {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.feeds__info {
|
||||
order: 1;
|
||||
width: calc(100% - 64px);
|
||||
}
|
||||
|
||||
.feeds__actions {
|
||||
margin-top: var(--space-s);
|
||||
order: 2;
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
113
index.js
113
index.js
@@ -1,12 +1,20 @@
|
||||
import path from "node:path";
|
||||
|
||||
import express from "express";
|
||||
|
||||
import { microsubController } from "./lib/controllers/microsub.js";
|
||||
import { readerController } from "./lib/controllers/reader.js";
|
||||
import { handleMediaProxy } from "./lib/media/proxy.js";
|
||||
import { startScheduler, stopScheduler } from "./lib/polling/scheduler.js";
|
||||
import { createIndexes } from "./lib/storage/items.js";
|
||||
import { webmentionReceiver } from "./lib/webmention/receiver.js";
|
||||
import { websubHandler } from "./lib/websub/handler.js";
|
||||
|
||||
const defaults = {
|
||||
mountPath: "/microsub",
|
||||
};
|
||||
const router = express.Router();
|
||||
const readerRouter = express.Router();
|
||||
|
||||
export default class MicrosubEndpoint {
|
||||
name = "Microsub endpoint";
|
||||
@@ -21,7 +29,32 @@ export default class MicrosubEndpoint {
|
||||
}
|
||||
|
||||
/**
|
||||
* Microsub API routes (authenticated)
|
||||
* Navigation items for Indiekit admin
|
||||
* @returns {object} Navigation item configuration
|
||||
*/
|
||||
get navigationItems() {
|
||||
return {
|
||||
href: path.join(this.options.mountPath, "reader"),
|
||||
text: "microsub.reader.title",
|
||||
requiresDatabase: true,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Shortcut items for quick actions
|
||||
* @returns {object} Shortcut item configuration
|
||||
*/
|
||||
get shortcutItems() {
|
||||
return {
|
||||
url: path.join(this.options.mountPath, "reader", "channels"),
|
||||
name: "microsub.channels.title",
|
||||
iconName: "feed",
|
||||
requiresDatabase: true,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Microsub API and reader UI routes (authenticated)
|
||||
* @returns {import("express").Router} Express router
|
||||
*/
|
||||
get routes() {
|
||||
@@ -29,9 +62,65 @@ export default class MicrosubEndpoint {
|
||||
router.get("/", microsubController.get);
|
||||
router.post("/", microsubController.post);
|
||||
|
||||
// WebSub callback endpoint
|
||||
router.get("/websub/:id", websubHandler.verify);
|
||||
router.post("/websub/:id", websubHandler.receive);
|
||||
|
||||
// Webmention receiving endpoint
|
||||
router.post("/webmention", webmentionReceiver.receive);
|
||||
|
||||
// Media proxy endpoint
|
||||
router.get("/media/:hash", handleMediaProxy);
|
||||
|
||||
// Reader UI routes (mounted as sub-router for correct baseUrl)
|
||||
readerRouter.get("/", readerController.index);
|
||||
readerRouter.get("/channels", readerController.channels);
|
||||
readerRouter.get("/channels/new", readerController.newChannel);
|
||||
readerRouter.post("/channels/new", readerController.createChannel);
|
||||
readerRouter.get("/channels/:uid", readerController.channel);
|
||||
readerRouter.get("/channels/:uid/settings", readerController.settings);
|
||||
readerRouter.post(
|
||||
"/channels/:uid/settings",
|
||||
readerController.updateSettings,
|
||||
);
|
||||
readerRouter.post("/channels/:uid/delete", readerController.deleteChannel);
|
||||
readerRouter.get("/channels/:uid/feeds", readerController.feeds);
|
||||
readerRouter.post("/channels/:uid/feeds", readerController.addFeed);
|
||||
readerRouter.post(
|
||||
"/channels/:uid/feeds/remove",
|
||||
readerController.removeFeed,
|
||||
);
|
||||
readerRouter.get("/item/:id", readerController.item);
|
||||
readerRouter.get("/compose", readerController.compose);
|
||||
readerRouter.post("/compose", readerController.submitCompose);
|
||||
readerRouter.get("/search", readerController.searchPage);
|
||||
readerRouter.post("/search", readerController.searchFeeds);
|
||||
readerRouter.post("/subscribe", readerController.subscribe);
|
||||
router.use("/reader", readerRouter);
|
||||
|
||||
return router;
|
||||
}
|
||||
|
||||
/**
|
||||
* Public routes (no authentication required)
|
||||
* @returns {import("express").Router} Express router
|
||||
*/
|
||||
get routesPublic() {
|
||||
const publicRouter = express.Router();
|
||||
|
||||
// WebSub verification must be public for hubs to verify
|
||||
publicRouter.get("/websub/:id", websubHandler.verify);
|
||||
publicRouter.post("/websub/:id", websubHandler.receive);
|
||||
|
||||
// Webmention endpoint must be public
|
||||
publicRouter.post("/webmention", webmentionReceiver.receive);
|
||||
|
||||
// Media proxy must be public for images to load
|
||||
publicRouter.get("/media/:hash", handleMediaProxy);
|
||||
|
||||
return publicRouter;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize plugin
|
||||
* @param {object} indiekit - Indiekit instance
|
||||
@@ -41,7 +130,11 @@ export default class MicrosubEndpoint {
|
||||
|
||||
// Register MongoDB collections
|
||||
indiekit.addCollection("microsub_channels");
|
||||
indiekit.addCollection("microsub_feeds");
|
||||
indiekit.addCollection("microsub_items");
|
||||
indiekit.addCollection("microsub_notifications");
|
||||
indiekit.addCollection("microsub_muted");
|
||||
indiekit.addCollection("microsub_blocked");
|
||||
|
||||
console.info("[Microsub] Registered MongoDB collections");
|
||||
|
||||
@@ -53,11 +146,27 @@ export default class MicrosubEndpoint {
|
||||
indiekit.config.application.microsubEndpoint = this.mountPath;
|
||||
}
|
||||
|
||||
// Create indexes for optimal performance (runs in background)
|
||||
// Start feed polling scheduler when server starts
|
||||
// This will be called after the server is ready
|
||||
if (indiekit.database) {
|
||||
console.info("[Microsub] Database available, starting scheduler");
|
||||
startScheduler(indiekit);
|
||||
|
||||
// Create indexes for optimal performance (runs in background)
|
||||
createIndexes(indiekit).catch((error) => {
|
||||
console.warn("[Microsub] Index creation failed:", error.message);
|
||||
});
|
||||
} else {
|
||||
console.warn(
|
||||
"[Microsub] Database not available at init, scheduler not started",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleanup on shutdown
|
||||
*/
|
||||
destroy() {
|
||||
stopScheduler();
|
||||
}
|
||||
}
|
||||
|
||||
181
lib/cache/redis.js
vendored
Normal file
181
lib/cache/redis.js
vendored
Normal file
@@ -0,0 +1,181 @@
|
||||
/**
|
||||
* Redis caching utilities
|
||||
* @module cache/redis
|
||||
*/
|
||||
|
||||
import Redis from "ioredis";
|
||||
|
||||
let redisClient;
|
||||
|
||||
/**
|
||||
* Get Redis client from application
|
||||
* @param {object} application - Indiekit application
|
||||
* @returns {object|undefined} Redis client or undefined
|
||||
*/
|
||||
export function getRedisClient(application) {
|
||||
// Check if Redis is already initialized on the application
|
||||
if (application.redis) {
|
||||
return application.redis;
|
||||
}
|
||||
|
||||
// Check if we already created a client
|
||||
if (redisClient) {
|
||||
return redisClient;
|
||||
}
|
||||
|
||||
// Check for Redis URL in config
|
||||
const redisUrl = application.config?.application?.redisUrl;
|
||||
if (redisUrl) {
|
||||
try {
|
||||
redisClient = new Redis(redisUrl, {
|
||||
maxRetriesPerRequest: 3,
|
||||
retryStrategy(times) {
|
||||
const delay = Math.min(times * 50, 2000);
|
||||
return delay;
|
||||
},
|
||||
lazyConnect: true,
|
||||
});
|
||||
|
||||
redisClient.on("error", (error) => {
|
||||
console.error("[Microsub] Redis error:", error.message);
|
||||
});
|
||||
|
||||
redisClient.on("connect", () => {
|
||||
console.info("[Microsub] Redis connected");
|
||||
});
|
||||
|
||||
// Connect asynchronously
|
||||
redisClient.connect().catch((error) => {
|
||||
console.warn("[Microsub] Redis connection failed:", error.message);
|
||||
});
|
||||
|
||||
return redisClient;
|
||||
} catch (error) {
|
||||
console.warn("[Microsub] Failed to initialize Redis:", error.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get value from cache
|
||||
* @param {object} redis - Redis client
|
||||
* @param {string} key - Cache key
|
||||
* @returns {Promise<object|undefined>} Cached value or undefined
|
||||
*/
|
||||
export async function getCache(redis, key) {
|
||||
if (!redis) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const value = await redis.get(key);
|
||||
if (value) {
|
||||
return JSON.parse(value);
|
||||
}
|
||||
} catch {
|
||||
// Ignore cache errors
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set value in cache
|
||||
* @param {object} redis - Redis client
|
||||
* @param {string} key - Cache key
|
||||
* @param {object} value - Value to cache
|
||||
* @param {number} [ttl] - Time to live in seconds
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
export async function setCache(redis, key, value, ttl = 300) {
|
||||
if (!redis) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const serialized = JSON.stringify(value);
|
||||
await (ttl
|
||||
? redis.set(key, serialized, "EX", ttl)
|
||||
: redis.set(key, serialized));
|
||||
} catch {
|
||||
// Ignore cache errors
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete value from cache
|
||||
* @param {object} redis - Redis client
|
||||
* @param {string} key - Cache key
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
export async function deleteCache(redis, key) {
|
||||
if (!redis) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await redis.del(key);
|
||||
} catch {
|
||||
// Ignore cache errors
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Publish event to channel
|
||||
* @param {object} redis - Redis client
|
||||
* @param {string} channel - Channel name
|
||||
* @param {object} data - Event data
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
export async function publishEvent(redis, channel, data) {
|
||||
if (!redis) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await redis.publish(channel, JSON.stringify(data));
|
||||
} catch {
|
||||
// Ignore pub/sub errors
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to channel
|
||||
* @param {object} redis - Redis client (must be separate connection for pub/sub)
|
||||
* @param {string} channel - Channel name
|
||||
* @param {(data: object) => void} callback - Callback function for messages
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
export async function subscribeToChannel(redis, channel, callback) {
|
||||
if (!redis) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await redis.subscribe(channel);
|
||||
redis.on("message", (receivedChannel, message) => {
|
||||
if (receivedChannel === channel) {
|
||||
try {
|
||||
const data = JSON.parse(message);
|
||||
callback(data);
|
||||
} catch {
|
||||
callback(message);
|
||||
}
|
||||
}
|
||||
});
|
||||
} catch {
|
||||
// Ignore subscription errors
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleanup Redis connection on shutdown
|
||||
*/
|
||||
export async function closeRedis() {
|
||||
if (redisClient) {
|
||||
try {
|
||||
await redisClient.quit();
|
||||
redisClient = undefined;
|
||||
} catch {
|
||||
// Ignore cleanup errors
|
||||
}
|
||||
}
|
||||
}
|
||||
86
lib/controllers/block.js
Normal file
86
lib/controllers/block.js
Normal file
@@ -0,0 +1,86 @@
|
||||
/**
|
||||
* Block controller
|
||||
* @module controllers/block
|
||||
*/
|
||||
|
||||
import { deleteItemsByAuthorUrl } from "../storage/items.js";
|
||||
import { getUserId } from "../utils/auth.js";
|
||||
import { validateUrl } from "../utils/validation.js";
|
||||
|
||||
/**
|
||||
* Get blocked collection
|
||||
* @param {object} application - Indiekit application
|
||||
* @returns {object} MongoDB collection
|
||||
*/
|
||||
function getCollection(application) {
|
||||
return application.collections.get("microsub_blocked");
|
||||
}
|
||||
|
||||
/**
|
||||
* List blocked URLs
|
||||
* GET ?action=block
|
||||
* @param {object} request - Express request
|
||||
* @param {object} response - Express response
|
||||
*/
|
||||
export async function list(request, response) {
|
||||
const { application } = request.app.locals;
|
||||
const userId = getUserId(request);
|
||||
|
||||
const collection = getCollection(application);
|
||||
const blocked = await collection.find({ userId }).toArray();
|
||||
const items = blocked.map((b) => ({ url: b.url }));
|
||||
|
||||
response.json({ items });
|
||||
}
|
||||
|
||||
/**
|
||||
* Block a URL
|
||||
* POST ?action=block
|
||||
* @param {object} request - Express request
|
||||
* @param {object} response - Express response
|
||||
*/
|
||||
export async function block(request, response) {
|
||||
const { application } = request.app.locals;
|
||||
const userId = getUserId(request);
|
||||
const { url } = request.body;
|
||||
|
||||
validateUrl(url);
|
||||
|
||||
const collection = getCollection(application);
|
||||
|
||||
// Check if already blocked
|
||||
const existing = await collection.findOne({ userId, url });
|
||||
if (!existing) {
|
||||
await collection.insertOne({
|
||||
userId,
|
||||
url,
|
||||
createdAt: new Date(),
|
||||
});
|
||||
}
|
||||
|
||||
// Remove past items from blocked URL
|
||||
await deleteItemsByAuthorUrl(application, userId, url);
|
||||
|
||||
response.json({ result: "ok" });
|
||||
}
|
||||
|
||||
/**
|
||||
* Unblock a URL
|
||||
* POST ?action=unblock
|
||||
* @param {object} request - Express request
|
||||
* @param {object} response - Express response
|
||||
*/
|
||||
export async function unblock(request, response) {
|
||||
const { application } = request.app.locals;
|
||||
const userId = getUserId(request);
|
||||
const { url } = request.body;
|
||||
|
||||
validateUrl(url);
|
||||
|
||||
const collection = getCollection(application);
|
||||
await collection.deleteOne({ userId, url });
|
||||
|
||||
response.json({ result: "ok" });
|
||||
}
|
||||
|
||||
export const blockController = { list, block, unblock };
|
||||
@@ -7,6 +7,7 @@ import { IndiekitError } from "@indiekit/error";
|
||||
|
||||
import {
|
||||
getChannels,
|
||||
getChannel,
|
||||
createChannel,
|
||||
updateChannel,
|
||||
deleteChannel,
|
||||
@@ -16,7 +17,7 @@ import { getUserId } from "../utils/auth.js";
|
||||
import {
|
||||
validateChannel,
|
||||
validateChannelName,
|
||||
parseArrayParameter,
|
||||
parseArrayParameter as parseArrayParametereter,
|
||||
} from "../utils/validation.js";
|
||||
|
||||
/**
|
||||
@@ -39,7 +40,6 @@ export async function list(request, response) {
|
||||
* POST ?action=channels
|
||||
* @param {object} request - Express request
|
||||
* @param {object} response - Express response
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
export async function action(request, response) {
|
||||
const { application } = request.app.locals;
|
||||
@@ -62,7 +62,7 @@ export async function action(request, response) {
|
||||
|
||||
// Reorder channels
|
||||
if (method === "order") {
|
||||
const channelUids = parseArrayParameter(request.body, "channels");
|
||||
const channelUids = parseArrayParametereter(request.body, "channels");
|
||||
if (channelUids.length === 0) {
|
||||
throw new IndiekitError("Missing channels[] parameter", {
|
||||
status: 400,
|
||||
@@ -107,4 +107,30 @@ export async function action(request, response) {
|
||||
});
|
||||
}
|
||||
|
||||
export const channelsController = { list, action };
|
||||
/**
|
||||
* Get a single channel by UID
|
||||
* @param {object} request - Express request
|
||||
* @param {object} response - Express response
|
||||
*/
|
||||
export async function get(request, response) {
|
||||
const { application } = request.app.locals;
|
||||
const userId = getUserId(request);
|
||||
const { uid } = request.params;
|
||||
|
||||
validateChannel(uid);
|
||||
|
||||
const channel = await getChannel(application, uid, userId);
|
||||
if (!channel) {
|
||||
throw new IndiekitError("Channel not found", {
|
||||
status: 404,
|
||||
});
|
||||
}
|
||||
|
||||
response.json({
|
||||
uid: channel.uid,
|
||||
name: channel.name,
|
||||
settings: channel.settings,
|
||||
});
|
||||
}
|
||||
|
||||
export const channelsController = { list, action, get };
|
||||
|
||||
57
lib/controllers/events.js
Normal file
57
lib/controllers/events.js
Normal file
@@ -0,0 +1,57 @@
|
||||
/**
|
||||
* Server-Sent Events controller
|
||||
* @module controllers/events
|
||||
*/
|
||||
|
||||
import {
|
||||
addClient,
|
||||
removeClient,
|
||||
sendEvent,
|
||||
subscribeClient,
|
||||
} from "../realtime/broker.js";
|
||||
import { getUserId } from "../utils/auth.js";
|
||||
|
||||
/**
|
||||
* SSE stream endpoint
|
||||
* GET ?action=events
|
||||
* @param {object} request - Express request
|
||||
* @param {object} response - Express response
|
||||
*/
|
||||
export async function stream(request, response) {
|
||||
const { application } = request.app.locals;
|
||||
const userId = getUserId(request);
|
||||
|
||||
// Set SSE headers
|
||||
response.setHeader("Content-Type", "text/event-stream");
|
||||
response.setHeader("Cache-Control", "no-cache");
|
||||
response.setHeader("Connection", "keep-alive");
|
||||
response.setHeader("X-Accel-Buffering", "no"); // Disable nginx buffering
|
||||
|
||||
// Flush headers immediately
|
||||
response.flushHeaders();
|
||||
|
||||
// Add client to broker (handles ping internally)
|
||||
const client = addClient(response, userId, application);
|
||||
|
||||
// Subscribe to channels from query parameter
|
||||
const { channels } = request.query;
|
||||
if (channels) {
|
||||
const channelList = Array.isArray(channels) ? channels : [channels];
|
||||
for (const channelId of channelList) {
|
||||
subscribeClient(response, channelId);
|
||||
}
|
||||
}
|
||||
|
||||
// Send initial event
|
||||
sendEvent(response, "started", {
|
||||
version: "1.0.0",
|
||||
channels: [...client.channels],
|
||||
});
|
||||
|
||||
// Handle client disconnect
|
||||
request.on("close", () => {
|
||||
removeClient(response);
|
||||
});
|
||||
}
|
||||
|
||||
export const eventsController = { stream };
|
||||
128
lib/controllers/follow.js
Normal file
128
lib/controllers/follow.js
Normal file
@@ -0,0 +1,128 @@
|
||||
/**
|
||||
* Follow/unfollow controller
|
||||
* @module controllers/follow
|
||||
*/
|
||||
|
||||
import { IndiekitError } from "@indiekit/error";
|
||||
|
||||
import { refreshFeedNow } from "../polling/scheduler.js";
|
||||
import { getChannel } from "../storage/channels.js";
|
||||
import {
|
||||
createFeed,
|
||||
deleteFeed,
|
||||
getFeedByUrl,
|
||||
getFeedsForChannel,
|
||||
} from "../storage/feeds.js";
|
||||
import { getUserId } from "../utils/auth.js";
|
||||
import { createFeedResponse } from "../utils/jf2.js";
|
||||
import { validateChannel, validateUrl } from "../utils/validation.js";
|
||||
import {
|
||||
unsubscribe as websubUnsubscribe,
|
||||
getCallbackUrl,
|
||||
} from "../websub/subscriber.js";
|
||||
|
||||
/**
|
||||
* List followed feeds for a channel
|
||||
* GET ?action=follow&channel=<uid>
|
||||
* @param {object} request - Express request
|
||||
* @param {object} response - Express response
|
||||
*/
|
||||
export async function list(request, response) {
|
||||
const { application } = request.app.locals;
|
||||
const userId = getUserId(request);
|
||||
const { channel } = request.query;
|
||||
|
||||
validateChannel(channel);
|
||||
|
||||
const channelDocument = await getChannel(application, channel, userId);
|
||||
if (!channelDocument) {
|
||||
throw new IndiekitError("Channel not found", { status: 404 });
|
||||
}
|
||||
|
||||
const feeds = await getFeedsForChannel(application, channelDocument._id);
|
||||
const items = feeds.map((feed) => createFeedResponse(feed));
|
||||
|
||||
response.json({ items });
|
||||
}
|
||||
|
||||
/**
|
||||
* Follow a feed URL
|
||||
* POST ?action=follow
|
||||
* @param {object} request - Express request
|
||||
* @param {object} response - Express response
|
||||
*/
|
||||
export async function follow(request, response) {
|
||||
const { application } = request.app.locals;
|
||||
const userId = getUserId(request);
|
||||
const { channel, url } = request.body;
|
||||
|
||||
validateChannel(channel);
|
||||
validateUrl(url);
|
||||
|
||||
const channelDocument = await getChannel(application, channel, userId);
|
||||
if (!channelDocument) {
|
||||
throw new IndiekitError("Channel not found", { status: 404 });
|
||||
}
|
||||
|
||||
// Create feed subscription
|
||||
const feed = await createFeed(application, {
|
||||
channelId: channelDocument._id,
|
||||
url,
|
||||
title: undefined, // Will be populated on first fetch
|
||||
photo: undefined,
|
||||
});
|
||||
|
||||
// Trigger immediate fetch in background (don't await)
|
||||
// This will also discover and subscribe to WebSub hubs
|
||||
refreshFeedNow(application, feed._id).catch((error) => {
|
||||
console.error(`[Microsub] Error fetching new feed ${url}:`, error.message);
|
||||
});
|
||||
|
||||
response.status(201).json(createFeedResponse(feed));
|
||||
}
|
||||
|
||||
/**
|
||||
* Unfollow a feed URL
|
||||
* POST ?action=unfollow
|
||||
* @param {object} request - Express request
|
||||
* @param {object} response - Express response
|
||||
*/
|
||||
export async function unfollow(request, response) {
|
||||
const { application } = request.app.locals;
|
||||
const userId = getUserId(request);
|
||||
const { channel, url } = request.body;
|
||||
|
||||
validateChannel(channel);
|
||||
validateUrl(url);
|
||||
|
||||
const channelDocument = await getChannel(application, channel, userId);
|
||||
if (!channelDocument) {
|
||||
throw new IndiekitError("Channel not found", { status: 404 });
|
||||
}
|
||||
|
||||
// Get feed before deletion to check for WebSub subscription
|
||||
const feed = await getFeedByUrl(application, channelDocument._id, url);
|
||||
|
||||
// Unsubscribe from WebSub hub if active
|
||||
if (feed?.websub?.hub) {
|
||||
const baseUrl = application.url;
|
||||
if (baseUrl) {
|
||||
const callbackUrl = getCallbackUrl(baseUrl, feed._id.toString());
|
||||
websubUnsubscribe(application, feed, callbackUrl).catch((error) => {
|
||||
console.error(
|
||||
`[Microsub] WebSub unsubscribe error for ${url}:`,
|
||||
error.message,
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const deleted = await deleteFeed(application, channelDocument._id, url);
|
||||
if (!deleted) {
|
||||
throw new IndiekitError("Feed not found", { status: 404 });
|
||||
}
|
||||
|
||||
response.json({ result: "ok" });
|
||||
}
|
||||
|
||||
export const followController = { list, follow, unfollow };
|
||||
@@ -7,7 +7,13 @@ import { IndiekitError } from "@indiekit/error";
|
||||
|
||||
import { validateAction } from "../utils/validation.js";
|
||||
|
||||
import { list as listBlocked, block, unblock } from "./block.js";
|
||||
import { list as listChannels, action as channelAction } from "./channels.js";
|
||||
import { stream as eventsStream } from "./events.js";
|
||||
import { list as listFollows, follow, unfollow } from "./follow.js";
|
||||
import { list as listMuted, mute, unmute } from "./mute.js";
|
||||
import { get as getPreview, preview } from "./preview.js";
|
||||
import { discover, search } from "./search.js";
|
||||
import { get as getTimeline, action as timelineAction } from "./timeline.js";
|
||||
|
||||
/**
|
||||
@@ -15,18 +21,14 @@ import { get as getTimeline, action as timelineAction } from "./timeline.js";
|
||||
* @param {object} request - Express request
|
||||
* @param {object} response - Express response
|
||||
* @param {Function} next - Express next function
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
export async function get(request, response, next) {
|
||||
try {
|
||||
const { action } = request.query;
|
||||
|
||||
// If no action provided, redirect to reader UI
|
||||
if (!action) {
|
||||
// Return basic endpoint info
|
||||
return response.json({
|
||||
type: "microsub",
|
||||
actions: ["channels", "timeline"],
|
||||
});
|
||||
return response.redirect(request.baseUrl + "/reader");
|
||||
}
|
||||
|
||||
validateAction(action);
|
||||
@@ -40,6 +42,31 @@ export async function get(request, response, next) {
|
||||
return getTimeline(request, response);
|
||||
}
|
||||
|
||||
case "follow": {
|
||||
return listFollows(request, response);
|
||||
}
|
||||
|
||||
case "preview": {
|
||||
return getPreview(request, response);
|
||||
}
|
||||
|
||||
case "mute": {
|
||||
return listMuted(request, response);
|
||||
}
|
||||
|
||||
case "block": {
|
||||
return listBlocked(request, response);
|
||||
}
|
||||
|
||||
case "events": {
|
||||
return eventsStream(request, response);
|
||||
}
|
||||
|
||||
case "search": {
|
||||
// Search is typically POST, but GET is allowed for feed discovery
|
||||
return discover(request, response);
|
||||
}
|
||||
|
||||
default: {
|
||||
throw new IndiekitError(`Unsupported GET action: ${action}`, {
|
||||
status: 400,
|
||||
@@ -56,7 +83,6 @@ export async function get(request, response, next) {
|
||||
* @param {object} request - Express request
|
||||
* @param {object} response - Express response
|
||||
* @param {Function} next - Express next function
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
export async function post(request, response, next) {
|
||||
try {
|
||||
@@ -72,6 +98,38 @@ export async function post(request, response, next) {
|
||||
return timelineAction(request, response);
|
||||
}
|
||||
|
||||
case "follow": {
|
||||
return follow(request, response);
|
||||
}
|
||||
|
||||
case "unfollow": {
|
||||
return unfollow(request, response);
|
||||
}
|
||||
|
||||
case "search": {
|
||||
return search(request, response);
|
||||
}
|
||||
|
||||
case "preview": {
|
||||
return preview(request, response);
|
||||
}
|
||||
|
||||
case "mute": {
|
||||
return mute(request, response);
|
||||
}
|
||||
|
||||
case "unmute": {
|
||||
return unmute(request, response);
|
||||
}
|
||||
|
||||
case "block": {
|
||||
return block(request, response);
|
||||
}
|
||||
|
||||
case "unblock": {
|
||||
return unblock(request, response);
|
||||
}
|
||||
|
||||
default: {
|
||||
throw new IndiekitError(`Unsupported POST action: ${action}`, {
|
||||
status: 400,
|
||||
|
||||
125
lib/controllers/mute.js
Normal file
125
lib/controllers/mute.js
Normal file
@@ -0,0 +1,125 @@
|
||||
/**
|
||||
* Mute controller
|
||||
* @module controllers/mute
|
||||
*/
|
||||
|
||||
import { IndiekitError } from "@indiekit/error";
|
||||
|
||||
import { getUserId } from "../utils/auth.js";
|
||||
import { validateChannel, validateUrl } from "../utils/validation.js";
|
||||
|
||||
/**
|
||||
* Get muted collection
|
||||
* @param {object} application - Indiekit application
|
||||
* @returns {object} MongoDB collection
|
||||
*/
|
||||
function getCollection(application) {
|
||||
return application.collections.get("microsub_muted");
|
||||
}
|
||||
|
||||
/**
|
||||
* List muted URLs for a channel
|
||||
* GET ?action=mute&channel=<uid>
|
||||
* @param {object} request - Express request
|
||||
* @param {object} response - Express response
|
||||
*/
|
||||
export async function list(request, response) {
|
||||
const { application } = request.app.locals;
|
||||
const userId = getUserId(request);
|
||||
const { channel } = request.query;
|
||||
|
||||
// Channel can be "global" or a specific channel UID
|
||||
const isGlobal = channel === "global";
|
||||
|
||||
const collection = getCollection(application);
|
||||
const filter = { userId };
|
||||
|
||||
if (!isGlobal && channel) {
|
||||
// Get channel-specific mutes
|
||||
const channelsCollection = application.collections.get("microsub_channels");
|
||||
const channelDocument = await channelsCollection.findOne({ uid: channel });
|
||||
if (channelDocument) {
|
||||
filter.channelId = channelDocument._id;
|
||||
}
|
||||
}
|
||||
// For global mutes, we query without channelId (matches all channels)
|
||||
|
||||
// eslint-disable-next-line unicorn/no-array-callback-reference -- filter is MongoDB query object
|
||||
const muted = await collection.find(filter).toArray();
|
||||
const items = muted.map((m) => ({ url: m.url }));
|
||||
|
||||
response.json({ items });
|
||||
}
|
||||
|
||||
/**
|
||||
* Mute a URL
|
||||
* POST ?action=mute
|
||||
* @param {object} request - Express request
|
||||
* @param {object} response - Express response
|
||||
*/
|
||||
export async function mute(request, response) {
|
||||
const { application } = request.app.locals;
|
||||
const userId = getUserId(request);
|
||||
const { channel, url } = request.body;
|
||||
|
||||
validateUrl(url);
|
||||
|
||||
const collection = getCollection(application);
|
||||
const isGlobal = channel === "global" || !channel;
|
||||
|
||||
let channelId;
|
||||
if (!isGlobal) {
|
||||
validateChannel(channel);
|
||||
const channelsCollection = application.collections.get("microsub_channels");
|
||||
const channelDocument = await channelsCollection.findOne({ uid: channel });
|
||||
if (!channelDocument) {
|
||||
throw new IndiekitError("Channel not found", { status: 404 });
|
||||
}
|
||||
channelId = channelDocument._id;
|
||||
}
|
||||
|
||||
// Check if already muted
|
||||
const existing = await collection.findOne({ userId, channelId, url });
|
||||
if (!existing) {
|
||||
await collection.insertOne({
|
||||
userId,
|
||||
channelId,
|
||||
url,
|
||||
createdAt: new Date(),
|
||||
});
|
||||
}
|
||||
|
||||
response.json({ result: "ok" });
|
||||
}
|
||||
|
||||
/**
|
||||
* Unmute a URL
|
||||
* POST ?action=unmute
|
||||
* @param {object} request - Express request
|
||||
* @param {object} response - Express response
|
||||
*/
|
||||
export async function unmute(request, response) {
|
||||
const { application } = request.app.locals;
|
||||
const userId = getUserId(request);
|
||||
const { channel, url } = request.body;
|
||||
|
||||
validateUrl(url);
|
||||
|
||||
const collection = getCollection(application);
|
||||
const isGlobal = channel === "global" || !channel;
|
||||
|
||||
let channelId;
|
||||
if (!isGlobal) {
|
||||
const channelsCollection = application.collections.get("microsub_channels");
|
||||
const channelDocument = await channelsCollection.findOne({ uid: channel });
|
||||
if (channelDocument) {
|
||||
channelId = channelDocument._id;
|
||||
}
|
||||
}
|
||||
|
||||
await collection.deleteOne({ userId, channelId, url });
|
||||
|
||||
response.json({ result: "ok" });
|
||||
}
|
||||
|
||||
export const muteController = { list, mute, unmute };
|
||||
67
lib/controllers/preview.js
Normal file
67
lib/controllers/preview.js
Normal file
@@ -0,0 +1,67 @@
|
||||
/**
|
||||
* Preview controller
|
||||
* @module controllers/preview
|
||||
*/
|
||||
|
||||
import { IndiekitError } from "@indiekit/error";
|
||||
|
||||
import { fetchAndParseFeed } from "../feeds/fetcher.js";
|
||||
import { validateUrl } from "../utils/validation.js";
|
||||
|
||||
const MAX_PREVIEW_ITEMS = 10;
|
||||
|
||||
/**
|
||||
* Fetch and preview a feed
|
||||
* @param {string} url - Feed URL
|
||||
* @returns {Promise<object>} Preview response
|
||||
*/
|
||||
async function fetchPreview(url) {
|
||||
try {
|
||||
const parsed = await fetchAndParseFeed(url);
|
||||
|
||||
// Return feed metadata and sample items
|
||||
return {
|
||||
type: "feed",
|
||||
url: parsed.url,
|
||||
name: parsed.name,
|
||||
photo: parsed.photo,
|
||||
items: parsed.items.slice(0, MAX_PREVIEW_ITEMS),
|
||||
};
|
||||
} catch (error) {
|
||||
throw new IndiekitError(`Failed to preview feed: ${error.message}`, {
|
||||
status: 502,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Preview a feed URL (GET)
|
||||
* GET ?action=preview&url=<feed>
|
||||
* @param {object} request - Express request
|
||||
* @param {object} response - Express response
|
||||
*/
|
||||
export async function get(request, response) {
|
||||
const { url } = request.query;
|
||||
|
||||
validateUrl(url);
|
||||
|
||||
const preview = await fetchPreview(url);
|
||||
response.json(preview);
|
||||
}
|
||||
|
||||
/**
|
||||
* Preview a feed URL (POST)
|
||||
* POST ?action=preview
|
||||
* @param {object} request - Express request
|
||||
* @param {object} response - Express response
|
||||
*/
|
||||
export async function preview(request, response) {
|
||||
const { url } = request.body;
|
||||
|
||||
validateUrl(url);
|
||||
|
||||
const previewData = await fetchPreview(url);
|
||||
response.json(previewData);
|
||||
}
|
||||
|
||||
export const previewController = { get, preview };
|
||||
586
lib/controllers/reader.js
Normal file
586
lib/controllers/reader.js
Normal file
@@ -0,0 +1,586 @@
|
||||
/**
|
||||
* Reader UI controller
|
||||
* @module controllers/reader
|
||||
*/
|
||||
|
||||
import { discoverFeedsFromUrl } from "../feeds/fetcher.js";
|
||||
import { refreshFeedNow } from "../polling/scheduler.js";
|
||||
import {
|
||||
getChannels,
|
||||
getChannel,
|
||||
createChannel,
|
||||
updateChannelSettings,
|
||||
deleteChannel,
|
||||
} from "../storage/channels.js";
|
||||
import {
|
||||
getFeedsForChannel,
|
||||
createFeed,
|
||||
deleteFeed,
|
||||
} from "../storage/feeds.js";
|
||||
import { getTimelineItems, getItemById } from "../storage/items.js";
|
||||
import { getUserId } from "../utils/auth.js";
|
||||
import {
|
||||
validateChannelName,
|
||||
validateExcludeTypes,
|
||||
validateExcludeRegex,
|
||||
} from "../utils/validation.js";
|
||||
|
||||
/**
|
||||
* Reader index - redirect to channels
|
||||
* @param {object} request - Express request
|
||||
* @param {object} response - Express response
|
||||
*/
|
||||
export async function index(request, response) {
|
||||
response.redirect(`${request.baseUrl}/channels`);
|
||||
}
|
||||
|
||||
/**
|
||||
* List channels
|
||||
* @param {object} request - Express request
|
||||
* @param {object} response - Express response
|
||||
*/
|
||||
export async function channels(request, response) {
|
||||
const { application } = request.app.locals;
|
||||
const userId = getUserId(request);
|
||||
|
||||
const channelList = await getChannels(application, userId);
|
||||
|
||||
response.render("reader", {
|
||||
title: request.__("microsub.reader.title"),
|
||||
channels: channelList,
|
||||
baseUrl: request.baseUrl,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* New channel form
|
||||
* @param {object} request - Express request
|
||||
* @param {object} response - Express response
|
||||
*/
|
||||
export async function newChannel(request, response) {
|
||||
response.render("channel-new", {
|
||||
title: request.__("microsub.channels.new"),
|
||||
baseUrl: request.baseUrl,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Create channel
|
||||
* @param {object} request - Express request
|
||||
* @param {object} response - Express response
|
||||
*/
|
||||
export async function createChannelAction(request, response) {
|
||||
const { application } = request.app.locals;
|
||||
const userId = getUserId(request);
|
||||
const { name } = request.body;
|
||||
|
||||
validateChannelName(name);
|
||||
|
||||
await createChannel(application, { name, userId });
|
||||
|
||||
response.redirect(`${request.baseUrl}/channels`);
|
||||
}
|
||||
|
||||
/**
|
||||
* View channel timeline
|
||||
* @param {object} request - Express request
|
||||
* @param {object} response - Express response
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
export async function channel(request, response) {
|
||||
const { application } = request.app.locals;
|
||||
const userId = getUserId(request);
|
||||
const { uid } = request.params;
|
||||
const { before, after } = request.query;
|
||||
|
||||
const channelDocument = await getChannel(application, uid, userId);
|
||||
if (!channelDocument) {
|
||||
return response.status(404).render("404");
|
||||
}
|
||||
|
||||
const timeline = await getTimelineItems(application, channelDocument._id, {
|
||||
before,
|
||||
after,
|
||||
userId,
|
||||
});
|
||||
|
||||
response.render("channel", {
|
||||
title: channelDocument.name,
|
||||
channel: channelDocument,
|
||||
items: timeline.items,
|
||||
paging: timeline.paging,
|
||||
baseUrl: request.baseUrl,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Channel settings form
|
||||
* @param {object} request - Express request
|
||||
* @param {object} response - Express response
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
export async function settings(request, response) {
|
||||
const { application } = request.app.locals;
|
||||
const userId = getUserId(request);
|
||||
const { uid } = request.params;
|
||||
|
||||
const channelDocument = await getChannel(application, uid, userId);
|
||||
if (!channelDocument) {
|
||||
return response.status(404).render("404");
|
||||
}
|
||||
|
||||
response.render("settings", {
|
||||
title: request.__("microsub.settings.title", {
|
||||
channel: channelDocument.name,
|
||||
}),
|
||||
channel: channelDocument,
|
||||
baseUrl: request.baseUrl,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Update channel settings
|
||||
* @param {object} request - Express request
|
||||
* @param {object} response - Express response
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
export async function updateSettings(request, response) {
|
||||
const { application } = request.app.locals;
|
||||
const userId = getUserId(request);
|
||||
const { uid } = request.params;
|
||||
const { excludeTypes, excludeRegex } = request.body;
|
||||
|
||||
const channelDocument = await getChannel(application, uid, userId);
|
||||
if (!channelDocument) {
|
||||
return response.status(404).render("404");
|
||||
}
|
||||
|
||||
const validatedTypes = validateExcludeTypes(
|
||||
Array.isArray(excludeTypes) ? excludeTypes : [excludeTypes].filter(Boolean),
|
||||
);
|
||||
const validatedRegex = validateExcludeRegex(excludeRegex);
|
||||
|
||||
await updateChannelSettings(
|
||||
application,
|
||||
uid,
|
||||
{
|
||||
excludeTypes: validatedTypes,
|
||||
excludeRegex: validatedRegex,
|
||||
},
|
||||
userId,
|
||||
);
|
||||
|
||||
response.redirect(`${request.baseUrl}/channels/${uid}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete channel
|
||||
* @param {object} request - Express request
|
||||
* @param {object} response - Express response
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
export async function deleteChannelAction(request, response) {
|
||||
const { application } = request.app.locals;
|
||||
const userId = getUserId(request);
|
||||
const { uid } = request.params;
|
||||
|
||||
// Don't allow deleting notifications channel
|
||||
if (uid === "notifications") {
|
||||
return response.redirect(`${request.baseUrl}/channels`);
|
||||
}
|
||||
|
||||
const channelDocument = await getChannel(application, uid, userId);
|
||||
if (!channelDocument) {
|
||||
return response.status(404).render("404");
|
||||
}
|
||||
|
||||
await deleteChannel(application, uid, userId);
|
||||
|
||||
response.redirect(`${request.baseUrl}/channels`);
|
||||
}
|
||||
|
||||
/**
|
||||
* View feeds for a channel
|
||||
* @param {object} request - Express request
|
||||
* @param {object} response - Express response
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
export async function feeds(request, response) {
|
||||
const { application } = request.app.locals;
|
||||
const userId = getUserId(request);
|
||||
const { uid } = request.params;
|
||||
|
||||
const channelDocument = await getChannel(application, uid, userId);
|
||||
if (!channelDocument) {
|
||||
return response.status(404).render("404");
|
||||
}
|
||||
|
||||
const feedList = await getFeedsForChannel(application, channelDocument._id);
|
||||
|
||||
response.render("feeds", {
|
||||
title: request.__("microsub.feeds.title"),
|
||||
channel: channelDocument,
|
||||
feeds: feedList,
|
||||
baseUrl: request.baseUrl,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Add feed to channel
|
||||
* @param {object} request - Express request
|
||||
* @param {object} response - Express response
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
export async function addFeed(request, response) {
|
||||
const { application } = request.app.locals;
|
||||
const userId = getUserId(request);
|
||||
const { uid } = request.params;
|
||||
const { url } = request.body;
|
||||
|
||||
const channelDocument = await getChannel(application, uid, userId);
|
||||
if (!channelDocument) {
|
||||
return response.status(404).render("404");
|
||||
}
|
||||
|
||||
// Create feed subscription
|
||||
const feed = await createFeed(application, {
|
||||
channelId: channelDocument._id,
|
||||
url,
|
||||
title: undefined,
|
||||
photo: undefined,
|
||||
});
|
||||
|
||||
// Trigger immediate fetch in background
|
||||
refreshFeedNow(application, feed._id).catch((error) => {
|
||||
console.error(`[Microsub] Error fetching new feed ${url}:`, error.message);
|
||||
});
|
||||
|
||||
response.redirect(`${request.baseUrl}/channels/${uid}/feeds`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove feed from channel
|
||||
* @param {object} request - Express request
|
||||
* @param {object} response - Express response
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
export async function removeFeed(request, response) {
|
||||
const { application } = request.app.locals;
|
||||
const userId = getUserId(request);
|
||||
const { uid } = request.params;
|
||||
const { url } = request.body;
|
||||
|
||||
const channelDocument = await getChannel(application, uid, userId);
|
||||
if (!channelDocument) {
|
||||
return response.status(404).render("404");
|
||||
}
|
||||
|
||||
await deleteFeed(application, channelDocument._id, url);
|
||||
|
||||
response.redirect(`${request.baseUrl}/channels/${uid}/feeds`);
|
||||
}
|
||||
|
||||
/**
|
||||
* View single item
|
||||
* @param {object} request - Express request
|
||||
* @param {object} response - Express response
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
export async function item(request, response) {
|
||||
const { application } = request.app.locals;
|
||||
const userId = getUserId(request);
|
||||
const { id } = request.params;
|
||||
|
||||
const itemDocument = await getItemById(application, id, userId);
|
||||
if (!itemDocument) {
|
||||
return response.status(404).render("404");
|
||||
}
|
||||
|
||||
response.render("item", {
|
||||
title: itemDocument.name || "Item",
|
||||
item: itemDocument,
|
||||
baseUrl: request.baseUrl,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure value is a string URL
|
||||
* @param {string|object|undefined} value - Value to check
|
||||
* @returns {string|undefined} String value or undefined
|
||||
*/
|
||||
function ensureString(value) {
|
||||
if (!value) return;
|
||||
if (typeof value === "string") return value;
|
||||
if (typeof value === "object" && value.url) return value.url;
|
||||
return String(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Compose response form
|
||||
* @param {object} request - Express request
|
||||
* @param {object} response - Express response
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
export async function compose(request, response) {
|
||||
// Support both long-form (replyTo) and short-form (reply) query params
|
||||
const {
|
||||
replyTo,
|
||||
reply,
|
||||
likeOf,
|
||||
like,
|
||||
repostOf,
|
||||
repost,
|
||||
bookmarkOf,
|
||||
bookmark,
|
||||
} = request.query;
|
||||
|
||||
response.render("compose", {
|
||||
title: request.__("microsub.compose.title"),
|
||||
replyTo: ensureString(replyTo || reply),
|
||||
likeOf: ensureString(likeOf || like),
|
||||
repostOf: ensureString(repostOf || repost),
|
||||
bookmarkOf: ensureString(bookmarkOf || bookmark),
|
||||
baseUrl: request.baseUrl,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Submit composed response via Micropub
|
||||
* @param {object} request - Express request
|
||||
* @param {object} response - Express response
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
export async function submitCompose(request, response) {
|
||||
const { application } = request.app.locals;
|
||||
const { content } = request.body;
|
||||
const inReplyTo = request.body["in-reply-to"];
|
||||
const likeOf = request.body["like-of"];
|
||||
const repostOf = request.body["repost-of"];
|
||||
const bookmarkOf = request.body["bookmark-of"];
|
||||
|
||||
// Debug logging
|
||||
console.info(
|
||||
"[Microsub] submitCompose request.body:",
|
||||
JSON.stringify(request.body),
|
||||
);
|
||||
console.info("[Microsub] Extracted values:", {
|
||||
content,
|
||||
inReplyTo,
|
||||
likeOf,
|
||||
repostOf,
|
||||
bookmarkOf,
|
||||
});
|
||||
|
||||
// Get Micropub endpoint
|
||||
const micropubEndpoint = application.micropubEndpoint;
|
||||
if (!micropubEndpoint) {
|
||||
return response.status(500).render("error", {
|
||||
title: "Error",
|
||||
content: "Micropub endpoint not configured",
|
||||
});
|
||||
}
|
||||
|
||||
// Build absolute Micropub URL
|
||||
const micropubUrl = micropubEndpoint.startsWith("http")
|
||||
? micropubEndpoint
|
||||
: new URL(micropubEndpoint, application.url).href;
|
||||
|
||||
// Get auth token from session
|
||||
const token = request.session?.access_token;
|
||||
if (!token) {
|
||||
return response.redirect("/session/login?redirect=" + request.originalUrl);
|
||||
}
|
||||
|
||||
// Build Micropub request body
|
||||
const micropubData = new URLSearchParams();
|
||||
micropubData.append("h", "entry");
|
||||
|
||||
if (likeOf) {
|
||||
// Like post (no content needed)
|
||||
micropubData.append("like-of", likeOf);
|
||||
} else if (repostOf) {
|
||||
// Repost (no content needed)
|
||||
micropubData.append("repost-of", repostOf);
|
||||
} else if (bookmarkOf) {
|
||||
// Bookmark (content optional)
|
||||
micropubData.append("bookmark-of", bookmarkOf);
|
||||
if (content) {
|
||||
micropubData.append("content", content);
|
||||
}
|
||||
} else if (inReplyTo) {
|
||||
// Reply
|
||||
micropubData.append("in-reply-to", inReplyTo);
|
||||
micropubData.append("content", content || "");
|
||||
} else {
|
||||
// Regular note
|
||||
micropubData.append("content", content || "");
|
||||
}
|
||||
|
||||
// Debug: log what we're sending
|
||||
console.info("[Microsub] Sending to Micropub:", {
|
||||
url: micropubUrl,
|
||||
body: micropubData.toString(),
|
||||
});
|
||||
|
||||
try {
|
||||
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
|
||||
) {
|
||||
// Success - get the Location header for the new post URL
|
||||
const location = micropubResponse.headers.get("Location");
|
||||
console.info(
|
||||
`[Microsub] Created post via Micropub: ${location || "success"}`,
|
||||
);
|
||||
|
||||
// Redirect back to reader with success message
|
||||
return response.redirect(`${request.baseUrl}/channels`);
|
||||
}
|
||||
|
||||
// Handle error
|
||||
const errorBody = await micropubResponse.text();
|
||||
const statusText = micropubResponse.statusText || "Unknown error";
|
||||
console.error(
|
||||
`[Microsub] Micropub error: ${micropubResponse.status} ${errorBody}`,
|
||||
);
|
||||
|
||||
// Parse error message from response body if JSON
|
||||
let errorMessage = `Micropub error: ${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, use status text
|
||||
}
|
||||
|
||||
return response.status(micropubResponse.status).render("error", {
|
||||
title: "Error",
|
||||
content: errorMessage,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(`[Microsub] Micropub request failed: ${error.message}`);
|
||||
|
||||
return response.status(500).render("error", {
|
||||
title: "Error",
|
||||
content: `Failed to create post: ${error.message}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Search/discover feeds page
|
||||
* @param {object} request - Express request
|
||||
* @param {object} response - Express response
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
export async function searchPage(request, response) {
|
||||
const { application } = request.app.locals;
|
||||
const userId = getUserId(request);
|
||||
|
||||
const channelList = await getChannels(application, userId);
|
||||
|
||||
response.render("search", {
|
||||
title: request.__("microsub.search.title"),
|
||||
channels: channelList,
|
||||
baseUrl: request.baseUrl,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Search for feeds from URL
|
||||
* @param {object} request - Express request
|
||||
* @param {object} response - Express response
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
export async function searchFeeds(request, response) {
|
||||
const { application } = request.app.locals;
|
||||
const userId = getUserId(request);
|
||||
const { query } = request.body;
|
||||
|
||||
const channelList = await getChannels(application, userId);
|
||||
|
||||
let results = [];
|
||||
if (query) {
|
||||
try {
|
||||
results = await discoverFeedsFromUrl(query);
|
||||
} catch {
|
||||
// Ignore discovery errors
|
||||
}
|
||||
}
|
||||
|
||||
response.render("search", {
|
||||
title: request.__("microsub.search.title"),
|
||||
channels: channelList,
|
||||
query,
|
||||
results,
|
||||
searched: true,
|
||||
baseUrl: request.baseUrl,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to a feed from search results
|
||||
* @param {object} request - Express request
|
||||
* @param {object} response - Express response
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
export async function subscribe(request, response) {
|
||||
const { application } = request.app.locals;
|
||||
const userId = getUserId(request);
|
||||
const { url, channel: channelUid } = request.body;
|
||||
|
||||
const channelDocument = await getChannel(application, channelUid, userId);
|
||||
if (!channelDocument) {
|
||||
return response.status(404).render("404");
|
||||
}
|
||||
|
||||
// Create feed subscription
|
||||
const feed = await createFeed(application, {
|
||||
channelId: channelDocument._id,
|
||||
url,
|
||||
title: undefined,
|
||||
photo: undefined,
|
||||
});
|
||||
|
||||
// Trigger immediate fetch in background
|
||||
refreshFeedNow(application, feed._id).catch((error) => {
|
||||
console.error(`[Microsub] Error fetching new feed ${url}:`, error.message);
|
||||
});
|
||||
|
||||
response.redirect(`${request.baseUrl}/channels/${channelUid}/feeds`);
|
||||
}
|
||||
|
||||
export const readerController = {
|
||||
index,
|
||||
channels,
|
||||
newChannel,
|
||||
createChannel: createChannelAction,
|
||||
channel,
|
||||
settings,
|
||||
updateSettings,
|
||||
deleteChannel: deleteChannelAction,
|
||||
feeds,
|
||||
addFeed,
|
||||
removeFeed,
|
||||
item,
|
||||
compose,
|
||||
submitCompose,
|
||||
searchPage,
|
||||
searchFeeds,
|
||||
subscribe,
|
||||
};
|
||||
143
lib/controllers/search.js
Normal file
143
lib/controllers/search.js
Normal file
@@ -0,0 +1,143 @@
|
||||
/**
|
||||
* Search controller
|
||||
* @module controllers/search
|
||||
*/
|
||||
|
||||
import { IndiekitError } from "@indiekit/error";
|
||||
|
||||
import { discoverFeeds } from "../feeds/hfeed.js";
|
||||
import { searchWithFallback } from "../search/query.js";
|
||||
import { getChannel } from "../storage/channels.js";
|
||||
import { getUserId } from "../utils/auth.js";
|
||||
import { validateChannel, validateUrl } from "../utils/validation.js";
|
||||
|
||||
/**
|
||||
* Discover feeds from a URL
|
||||
* GET ?action=search&query=<url>
|
||||
* @param {object} request - Express request
|
||||
* @param {object} response - Express response
|
||||
*/
|
||||
export async function discover(request, response) {
|
||||
const { query } = request.query;
|
||||
|
||||
if (!query) {
|
||||
throw new IndiekitError("Missing required parameter: query", {
|
||||
status: 400,
|
||||
});
|
||||
}
|
||||
|
||||
// Check if query is a URL
|
||||
let url;
|
||||
try {
|
||||
url = new URL(query);
|
||||
} catch {
|
||||
// Not a URL, return empty results
|
||||
return response.json({ results: [] });
|
||||
}
|
||||
|
||||
try {
|
||||
// Fetch the URL content
|
||||
const fetchResponse = await fetch(url.href, {
|
||||
headers: {
|
||||
Accept: "text/html, application/xhtml+xml, */*",
|
||||
"User-Agent": "Indiekit Microsub/1.0 (+https://getindiekit.com)",
|
||||
},
|
||||
});
|
||||
|
||||
if (!fetchResponse.ok) {
|
||||
throw new IndiekitError(`Failed to fetch URL: ${fetchResponse.status}`, {
|
||||
status: 502,
|
||||
});
|
||||
}
|
||||
|
||||
const content = await fetchResponse.text();
|
||||
const feeds = await discoverFeeds(content, url.href);
|
||||
|
||||
// Transform to Microsub search result format
|
||||
const results = feeds.map((feed) => ({
|
||||
type: "feed",
|
||||
url: feed.url,
|
||||
}));
|
||||
|
||||
response.json({ results });
|
||||
} catch (error) {
|
||||
if (error instanceof IndiekitError) {
|
||||
throw error;
|
||||
}
|
||||
throw new IndiekitError(`Feed discovery failed: ${error.message}`, {
|
||||
status: 502,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Search feeds or items
|
||||
* POST ?action=search
|
||||
* @param {object} request - Express request
|
||||
* @param {object} response - Express response
|
||||
*/
|
||||
export async function search(request, response) {
|
||||
const { application } = request.app.locals;
|
||||
const userId = getUserId(request);
|
||||
const { query, channel } = request.body;
|
||||
|
||||
if (!query) {
|
||||
throw new IndiekitError("Missing required parameter: query", {
|
||||
status: 400,
|
||||
});
|
||||
}
|
||||
|
||||
// If channel is provided, search within channel items
|
||||
if (channel) {
|
||||
validateChannel(channel);
|
||||
|
||||
const channelDocument = await getChannel(application, channel, userId);
|
||||
if (!channelDocument) {
|
||||
throw new IndiekitError("Channel not found", { status: 404 });
|
||||
}
|
||||
|
||||
const items = await searchWithFallback(
|
||||
application,
|
||||
channelDocument._id,
|
||||
query,
|
||||
);
|
||||
return response.json({ items });
|
||||
}
|
||||
|
||||
// Check if query is a URL (feed discovery)
|
||||
try {
|
||||
validateUrl(query, "query");
|
||||
|
||||
// Use the discover function for URL queries
|
||||
const fetchResponse = await fetch(query, {
|
||||
headers: {
|
||||
Accept: "text/html, application/xhtml+xml, */*",
|
||||
"User-Agent": "Indiekit Microsub/1.0 (+https://getindiekit.com)",
|
||||
},
|
||||
});
|
||||
|
||||
if (!fetchResponse.ok) {
|
||||
throw new IndiekitError(`Failed to fetch URL: ${fetchResponse.status}`, {
|
||||
status: 502,
|
||||
});
|
||||
}
|
||||
|
||||
const content = await fetchResponse.text();
|
||||
const feeds = await discoverFeeds(content, query);
|
||||
|
||||
const results = feeds.map((feed) => ({
|
||||
type: "feed",
|
||||
url: feed.url,
|
||||
}));
|
||||
|
||||
return response.json({ results });
|
||||
} catch (error) {
|
||||
// Not a URL or fetch failed, return empty results
|
||||
if (error instanceof IndiekitError) {
|
||||
throw error;
|
||||
}
|
||||
return response.json({ results: [] });
|
||||
}
|
||||
}
|
||||
|
||||
export const searchController = { discover, search };
|
||||
@@ -5,6 +5,7 @@
|
||||
|
||||
import { IndiekitError } from "@indiekit/error";
|
||||
|
||||
import { proxyItemImages } from "../media/proxy.js";
|
||||
import { getChannel } from "../storage/channels.js";
|
||||
import {
|
||||
getTimelineItems,
|
||||
@@ -16,7 +17,7 @@ import { getUserId } from "../utils/auth.js";
|
||||
import {
|
||||
validateChannel,
|
||||
validateEntries,
|
||||
parseArrayParameter,
|
||||
parseArrayParameter as parseArrayParametereter,
|
||||
} from "../utils/validation.js";
|
||||
|
||||
/**
|
||||
@@ -47,6 +48,14 @@ export async function get(request, response) {
|
||||
userId,
|
||||
});
|
||||
|
||||
// Proxy images if application URL is available
|
||||
const baseUrl = application.url;
|
||||
if (baseUrl && timeline.items) {
|
||||
timeline.items = timeline.items.map((item) =>
|
||||
proxyItemImages(item, baseUrl),
|
||||
);
|
||||
}
|
||||
|
||||
response.json(timeline);
|
||||
}
|
||||
|
||||
@@ -55,7 +64,6 @@ export async function get(request, response) {
|
||||
* POST ?action=timeline
|
||||
* @param {object} request - Express request
|
||||
* @param {object} response - Express response
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
export async function action(request, response) {
|
||||
const { application } = request.app.locals;
|
||||
@@ -73,7 +81,7 @@ export async function action(request, response) {
|
||||
}
|
||||
|
||||
// Get entry IDs from request
|
||||
const entries = parseArrayParameter(request.body, "entry");
|
||||
const entries = parseArrayParametereter(request.body, "entry");
|
||||
|
||||
switch (method) {
|
||||
case "mark_read": {
|
||||
|
||||
61
lib/feeds/atom.js
Normal file
61
lib/feeds/atom.js
Normal file
@@ -0,0 +1,61 @@
|
||||
/**
|
||||
* Atom feed parser
|
||||
* @module feeds/atom
|
||||
*/
|
||||
|
||||
import { Readable } from "node:stream";
|
||||
|
||||
import FeedParser from "feedparser";
|
||||
|
||||
import { normalizeItem, normalizeFeedMeta } from "./normalizer.js";
|
||||
|
||||
/**
|
||||
* Parse Atom feed content
|
||||
* @param {string} content - Atom XML content
|
||||
* @param {string} feedUrl - URL of the feed
|
||||
* @returns {Promise<object>} Parsed feed with metadata and items
|
||||
*/
|
||||
export async function parseAtom(content, feedUrl) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const feedparser = new FeedParser({ feedurl: feedUrl });
|
||||
const items = [];
|
||||
let meta;
|
||||
|
||||
feedparser.on("error", (error) => {
|
||||
reject(new Error(`Atom parse error: ${error.message}`));
|
||||
});
|
||||
|
||||
feedparser.on("meta", (feedMeta) => {
|
||||
meta = feedMeta;
|
||||
});
|
||||
|
||||
feedparser.on("readable", function () {
|
||||
let item;
|
||||
while ((item = this.read())) {
|
||||
items.push(item);
|
||||
}
|
||||
});
|
||||
|
||||
feedparser.on("end", () => {
|
||||
try {
|
||||
const normalizedMeta = normalizeFeedMeta(meta, feedUrl);
|
||||
const normalizedItems = items.map((item) =>
|
||||
normalizeItem(item, feedUrl, "atom"),
|
||||
);
|
||||
|
||||
resolve({
|
||||
type: "feed",
|
||||
url: feedUrl,
|
||||
...normalizedMeta,
|
||||
items: normalizedItems,
|
||||
});
|
||||
} catch (error) {
|
||||
reject(error);
|
||||
}
|
||||
});
|
||||
|
||||
// Create readable stream from string and pipe to feedparser
|
||||
const stream = Readable.from([content]);
|
||||
stream.pipe(feedparser);
|
||||
});
|
||||
}
|
||||
316
lib/feeds/fetcher.js
Normal file
316
lib/feeds/fetcher.js
Normal file
@@ -0,0 +1,316 @@
|
||||
/**
|
||||
* Feed fetcher with HTTP caching
|
||||
* @module feeds/fetcher
|
||||
*/
|
||||
|
||||
import { getCache, setCache } from "../cache/redis.js";
|
||||
|
||||
const DEFAULT_TIMEOUT = 30_000; // 30 seconds
|
||||
const DEFAULT_USER_AGENT = "Indiekit Microsub/1.0 (+https://getindiekit.com)";
|
||||
|
||||
/**
|
||||
* Fetch feed content with caching
|
||||
* @param {string} url - Feed URL
|
||||
* @param {object} options - Fetch options
|
||||
* @param {string} [options.etag] - Previous ETag for conditional request
|
||||
* @param {string} [options.lastModified] - Previous Last-Modified for conditional request
|
||||
* @param {number} [options.timeout] - Request timeout in ms
|
||||
* @param {object} [options.redis] - Redis client for caching
|
||||
* @returns {Promise<object>} Fetch result with content and headers
|
||||
*/
|
||||
export async function fetchFeed(url, options = {}) {
|
||||
const { etag, lastModified, timeout = DEFAULT_TIMEOUT, redis } = options;
|
||||
|
||||
// Check cache first
|
||||
if (redis) {
|
||||
const cached = await getCache(redis, `feed:${url}`);
|
||||
if (cached) {
|
||||
return {
|
||||
content: cached.content,
|
||||
contentType: cached.contentType,
|
||||
etag: cached.etag,
|
||||
lastModified: cached.lastModified,
|
||||
fromCache: true,
|
||||
status: 200,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const headers = {
|
||||
Accept:
|
||||
"application/atom+xml, application/rss+xml, application/json, application/feed+json, text/xml, text/html;q=0.9, */*;q=0.8",
|
||||
"User-Agent": DEFAULT_USER_AGENT,
|
||||
};
|
||||
|
||||
// Add conditional request headers
|
||||
if (etag) {
|
||||
headers["If-None-Match"] = etag;
|
||||
}
|
||||
if (lastModified) {
|
||||
headers["If-Modified-Since"] = lastModified;
|
||||
}
|
||||
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), timeout);
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
headers,
|
||||
signal: controller.signal,
|
||||
redirect: "follow",
|
||||
});
|
||||
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
// Not modified - use cached version
|
||||
if (response.status === 304) {
|
||||
return {
|
||||
content: undefined,
|
||||
contentType: undefined,
|
||||
etag,
|
||||
lastModified,
|
||||
notModified: true,
|
||||
status: 304,
|
||||
};
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
||||
}
|
||||
|
||||
const content = await response.text();
|
||||
const responseEtag = response.headers.get("ETag");
|
||||
const responseLastModified = response.headers.get("Last-Modified");
|
||||
const contentType = response.headers.get("Content-Type") || "";
|
||||
|
||||
const result = {
|
||||
content,
|
||||
contentType,
|
||||
etag: responseEtag,
|
||||
lastModified: responseLastModified,
|
||||
fromCache: false,
|
||||
status: response.status,
|
||||
};
|
||||
|
||||
// Extract hub URL from Link header for WebSub
|
||||
const linkHeader = response.headers.get("Link");
|
||||
if (linkHeader) {
|
||||
result.hub = extractHubFromLinkHeader(linkHeader);
|
||||
result.self = extractSelfFromLinkHeader(linkHeader);
|
||||
}
|
||||
|
||||
// Cache the result
|
||||
if (redis) {
|
||||
const cacheData = {
|
||||
content,
|
||||
contentType,
|
||||
etag: responseEtag,
|
||||
lastModified: responseLastModified,
|
||||
};
|
||||
// Cache for 5 minutes by default
|
||||
await setCache(redis, `feed:${url}`, cacheData, 300);
|
||||
}
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
if (error.name === "AbortError") {
|
||||
throw new Error(`Request timeout after ${timeout}ms`);
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract hub URL from Link header
|
||||
* @param {string} linkHeader - Link header value
|
||||
* @returns {string|undefined} Hub URL
|
||||
*/
|
||||
function extractHubFromLinkHeader(linkHeader) {
|
||||
const hubMatch = linkHeader.match(/<([^>]+)>;\s*rel=["']?hub["']?/i);
|
||||
return hubMatch ? hubMatch[1] : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract self URL from Link header
|
||||
* @param {string} linkHeader - Link header value
|
||||
* @returns {string|undefined} Self URL
|
||||
*/
|
||||
function extractSelfFromLinkHeader(linkHeader) {
|
||||
const selfMatch = linkHeader.match(/<([^>]+)>;\s*rel=["']?self["']?/i);
|
||||
return selfMatch ? selfMatch[1] : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch feed and parse it
|
||||
* @param {string} url - Feed URL
|
||||
* @param {object} options - Options
|
||||
* @returns {Promise<object>} Parsed feed
|
||||
*/
|
||||
export async function fetchAndParseFeed(url, options = {}) {
|
||||
const { parseFeed, detectFeedType } = await import("./parser.js");
|
||||
|
||||
const result = await fetchFeed(url, options);
|
||||
|
||||
if (result.notModified) {
|
||||
return {
|
||||
...result,
|
||||
items: [],
|
||||
};
|
||||
}
|
||||
|
||||
// Check if we got a parseable feed
|
||||
const feedType = detectFeedType(result.content, result.contentType);
|
||||
|
||||
// If we got ActivityPub or unknown, try common feed paths
|
||||
if (feedType === "activitypub" || feedType === "unknown") {
|
||||
const fallbackFeed = await tryCommonFeedPaths(url, options);
|
||||
if (fallbackFeed) {
|
||||
// Fetch and parse the discovered feed
|
||||
const feedResult = await fetchFeed(fallbackFeed.url, options);
|
||||
if (!feedResult.notModified) {
|
||||
const parsed = await parseFeed(feedResult.content, fallbackFeed.url, {
|
||||
contentType: feedResult.contentType,
|
||||
});
|
||||
return {
|
||||
...feedResult,
|
||||
...parsed,
|
||||
hub: feedResult.hub || parsed._hub,
|
||||
discoveredFrom: url,
|
||||
};
|
||||
}
|
||||
}
|
||||
throw new Error(
|
||||
`Unable to find a feed at ${url}. Try the direct feed URL.`,
|
||||
);
|
||||
}
|
||||
|
||||
const parsed = await parseFeed(result.content, url, {
|
||||
contentType: result.contentType,
|
||||
});
|
||||
|
||||
return {
|
||||
...result,
|
||||
...parsed,
|
||||
hub: result.hub || parsed._hub,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Common feed paths to try when discovery fails
|
||||
*/
|
||||
const COMMON_FEED_PATHS = ["/feed/", "/feed", "/rss", "/rss.xml", "/atom.xml"];
|
||||
|
||||
/**
|
||||
* Try to fetch a feed from common paths
|
||||
* @param {string} baseUrl - Base URL of the site
|
||||
* @param {object} options - Fetch options
|
||||
* @returns {Promise<object|undefined>} Feed result or undefined
|
||||
*/
|
||||
async function tryCommonFeedPaths(baseUrl, options = {}) {
|
||||
const base = new URL(baseUrl);
|
||||
|
||||
for (const feedPath of COMMON_FEED_PATHS) {
|
||||
const feedUrl = new URL(feedPath, base).href;
|
||||
try {
|
||||
const result = await fetchFeed(feedUrl, { ...options, timeout: 10_000 });
|
||||
const contentType = result.contentType?.toLowerCase() || "";
|
||||
|
||||
// Check if we got a feed
|
||||
if (
|
||||
contentType.includes("xml") ||
|
||||
contentType.includes("rss") ||
|
||||
contentType.includes("atom") ||
|
||||
(contentType.includes("json") &&
|
||||
result.content?.includes("jsonfeed.org"))
|
||||
) {
|
||||
return {
|
||||
url: feedUrl,
|
||||
type: contentType.includes("json") ? "jsonfeed" : "xml",
|
||||
rel: "alternate",
|
||||
};
|
||||
}
|
||||
} catch {
|
||||
// Try next path
|
||||
}
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
/**
|
||||
* Discover feeds from a URL
|
||||
* @param {string} url - Page URL
|
||||
* @param {object} options - Options
|
||||
* @returns {Promise<Array>} Array of discovered feeds
|
||||
*/
|
||||
export async function discoverFeedsFromUrl(url, options = {}) {
|
||||
const result = await fetchFeed(url, options);
|
||||
const { discoverFeeds } = await import("./hfeed.js");
|
||||
|
||||
// If it's already a feed, return it
|
||||
const contentType = result.contentType?.toLowerCase() || "";
|
||||
if (
|
||||
contentType.includes("xml") ||
|
||||
contentType.includes("rss") ||
|
||||
contentType.includes("atom")
|
||||
) {
|
||||
return [
|
||||
{
|
||||
url,
|
||||
type: "xml",
|
||||
rel: "self",
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
// Check for JSON Feed specifically
|
||||
if (
|
||||
contentType.includes("json") &&
|
||||
result.content?.includes("jsonfeed.org")
|
||||
) {
|
||||
return [
|
||||
{
|
||||
url,
|
||||
type: "jsonfeed",
|
||||
rel: "self",
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
// Check if we got ActivityPub JSON or other non-feed JSON
|
||||
// This happens with WordPress sites using ActivityPub plugin
|
||||
if (
|
||||
contentType.includes("json") ||
|
||||
(result.content?.trim().startsWith("{") &&
|
||||
result.content?.includes("@context"))
|
||||
) {
|
||||
// Try common feed paths as fallback
|
||||
const fallbackFeed = await tryCommonFeedPaths(url, options);
|
||||
if (fallbackFeed) {
|
||||
return [fallbackFeed];
|
||||
}
|
||||
}
|
||||
|
||||
// If content looks like HTML, discover feeds from it
|
||||
if (
|
||||
contentType.includes("html") ||
|
||||
result.content?.includes("<!DOCTYPE html") ||
|
||||
result.content?.includes("<html")
|
||||
) {
|
||||
const feeds = await discoverFeeds(result.content, url);
|
||||
if (feeds.length > 0) {
|
||||
return feeds;
|
||||
}
|
||||
}
|
||||
|
||||
// Last resort: try common feed paths
|
||||
const fallbackFeed = await tryCommonFeedPaths(url, options);
|
||||
if (fallbackFeed) {
|
||||
return [fallbackFeed];
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
177
lib/feeds/hfeed.js
Normal file
177
lib/feeds/hfeed.js
Normal file
@@ -0,0 +1,177 @@
|
||||
/**
|
||||
* h-feed (Microformats2) parser
|
||||
* @module feeds/hfeed
|
||||
*/
|
||||
|
||||
import { mf2 } from "microformats-parser";
|
||||
|
||||
import { normalizeHfeedItem, normalizeHfeedMeta } from "./normalizer.js";
|
||||
|
||||
/**
|
||||
* Parse h-feed content from HTML
|
||||
* @param {string} content - HTML content with h-feed
|
||||
* @param {string} feedUrl - URL of the page
|
||||
* @returns {Promise<object>} Parsed feed with metadata and items
|
||||
*/
|
||||
export async function parseHfeed(content, feedUrl) {
|
||||
let parsed;
|
||||
|
||||
try {
|
||||
parsed = mf2(content, { baseUrl: feedUrl });
|
||||
} catch (error) {
|
||||
throw new Error(`h-feed parse error: ${error.message}`);
|
||||
}
|
||||
|
||||
// Find h-feed in the parsed microformats
|
||||
const hfeed = findHfeed(parsed);
|
||||
|
||||
if (!hfeed) {
|
||||
// If no h-feed, look for h-entry items at the root
|
||||
const entries = parsed.items.filter(
|
||||
(item) => item.type && item.type.includes("h-entry"),
|
||||
);
|
||||
|
||||
if (entries.length === 0) {
|
||||
throw new Error("No h-feed or h-entry found on page");
|
||||
}
|
||||
|
||||
// Create synthetic feed from entries
|
||||
return {
|
||||
type: "feed",
|
||||
url: feedUrl,
|
||||
name: parsed.rels?.canonical?.[0] || feedUrl,
|
||||
items: entries.map((entry) => normalizeHfeedItem(entry, feedUrl)),
|
||||
};
|
||||
}
|
||||
|
||||
const normalizedMeta = normalizeHfeedMeta(hfeed, feedUrl);
|
||||
|
||||
// Get children entries from h-feed
|
||||
const entries = hfeed.children || [];
|
||||
const normalizedItems = entries
|
||||
.filter((child) => child.type && child.type.includes("h-entry"))
|
||||
.map((entry) => normalizeHfeedItem(entry, feedUrl));
|
||||
|
||||
return {
|
||||
type: "feed",
|
||||
url: feedUrl,
|
||||
...normalizedMeta,
|
||||
items: normalizedItems,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Find h-feed in parsed microformats
|
||||
* @param {object} parsed - Parsed microformats object
|
||||
* @returns {object|undefined} h-feed object or undefined
|
||||
*/
|
||||
function findHfeed(parsed) {
|
||||
// Look for h-feed at top level
|
||||
for (const item of parsed.items) {
|
||||
if (item.type && item.type.includes("h-feed")) {
|
||||
return item;
|
||||
}
|
||||
|
||||
// Check nested children
|
||||
if (item.children) {
|
||||
for (const child of item.children) {
|
||||
if (child.type && child.type.includes("h-feed")) {
|
||||
return child;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
/**
|
||||
* Discover feeds from HTML page
|
||||
* @param {string} content - HTML content
|
||||
* @param {string} pageUrl - URL of the page
|
||||
* @returns {Promise<Array>} Array of discovered feed URLs with types
|
||||
*/
|
||||
export async function discoverFeeds(content, pageUrl) {
|
||||
const feeds = [];
|
||||
const parsed = mf2(content, { baseUrl: pageUrl });
|
||||
|
||||
// Check for rel="alternate" feed links
|
||||
const alternates = parsed.rels?.alternate || [];
|
||||
for (const url of alternates) {
|
||||
// Try to determine feed type from URL
|
||||
if (url.includes("feed") || url.endsWith(".xml") || url.endsWith(".json")) {
|
||||
feeds.push({
|
||||
url,
|
||||
type: "unknown",
|
||||
rel: "alternate",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Check for rel="feed" links (Microsub discovery)
|
||||
const feedLinks = parsed.rels?.feed || [];
|
||||
for (const url of feedLinks) {
|
||||
feeds.push({
|
||||
url,
|
||||
type: "hfeed",
|
||||
rel: "feed",
|
||||
});
|
||||
}
|
||||
|
||||
// Check if page itself has h-feed
|
||||
const hfeed = findHfeed(parsed);
|
||||
if (hfeed) {
|
||||
feeds.push({
|
||||
url: pageUrl,
|
||||
type: "hfeed",
|
||||
rel: "self",
|
||||
});
|
||||
}
|
||||
|
||||
// Parse <link> elements for feed discovery
|
||||
const linkFeeds = extractLinkFeeds(content, pageUrl);
|
||||
feeds.push(...linkFeeds);
|
||||
|
||||
return feeds;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract feed links from HTML <link> elements
|
||||
* @param {string} content - HTML content
|
||||
* @param {string} baseUrl - Base URL for resolving relative URLs
|
||||
* @returns {Array} Array of discovered feeds
|
||||
*/
|
||||
function extractLinkFeeds(content, baseUrl) {
|
||||
const feeds = [];
|
||||
const linkRegex = /<link[^>]+rel=["'](?:alternate|feed)["'][^>]*>/gi;
|
||||
const matches = content.match(linkRegex) || [];
|
||||
|
||||
for (const link of matches) {
|
||||
const hrefMatch = link.match(/href=["']([^"']+)["']/i);
|
||||
const typeMatch = link.match(/type=["']([^"']+)["']/i);
|
||||
|
||||
if (hrefMatch) {
|
||||
const href = hrefMatch[1];
|
||||
const type = typeMatch ? typeMatch[1] : "unknown";
|
||||
const url = new URL(href, baseUrl).href;
|
||||
|
||||
let feedType = "unknown";
|
||||
if (type.includes("rss")) {
|
||||
feedType = "rss";
|
||||
} else if (type.includes("atom")) {
|
||||
feedType = "atom";
|
||||
} else if (type.includes("json")) {
|
||||
feedType = "jsonfeed";
|
||||
}
|
||||
|
||||
feeds.push({
|
||||
url,
|
||||
type: feedType,
|
||||
contentType: type,
|
||||
rel: "link",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return feeds;
|
||||
}
|
||||
43
lib/feeds/jsonfeed.js
Normal file
43
lib/feeds/jsonfeed.js
Normal file
@@ -0,0 +1,43 @@
|
||||
/**
|
||||
* JSON Feed parser
|
||||
* @module feeds/jsonfeed
|
||||
*/
|
||||
|
||||
import { normalizeJsonFeedItem, normalizeJsonFeedMeta } from "./normalizer.js";
|
||||
|
||||
/**
|
||||
* Parse JSON Feed content
|
||||
* @param {string} content - JSON Feed content
|
||||
* @param {string} feedUrl - URL of the feed
|
||||
* @returns {Promise<object>} Parsed feed with metadata and items
|
||||
*/
|
||||
export async function parseJsonFeed(content, feedUrl) {
|
||||
let feed;
|
||||
|
||||
try {
|
||||
feed = typeof content === "string" ? JSON.parse(content) : content;
|
||||
} catch (error) {
|
||||
throw new Error(`JSON Feed parse error: ${error.message}`);
|
||||
}
|
||||
|
||||
// Validate JSON Feed structure
|
||||
if (!feed.version || !feed.version.includes("jsonfeed.org")) {
|
||||
throw new Error("Invalid JSON Feed: missing or invalid version");
|
||||
}
|
||||
|
||||
if (!Array.isArray(feed.items)) {
|
||||
throw new TypeError("Invalid JSON Feed: items must be an array");
|
||||
}
|
||||
|
||||
const normalizedMeta = normalizeJsonFeedMeta(feed, feedUrl);
|
||||
const normalizedItems = feed.items.map((item) =>
|
||||
normalizeJsonFeedItem(item, feedUrl),
|
||||
);
|
||||
|
||||
return {
|
||||
type: "feed",
|
||||
url: feedUrl,
|
||||
...normalizedMeta,
|
||||
items: normalizedItems,
|
||||
};
|
||||
}
|
||||
697
lib/feeds/normalizer.js
Normal file
697
lib/feeds/normalizer.js
Normal file
@@ -0,0 +1,697 @@
|
||||
/**
|
||||
* Feed normalizer - converts all feed formats to jf2
|
||||
* @module feeds/normalizer
|
||||
*/
|
||||
|
||||
import crypto from "node:crypto";
|
||||
|
||||
import sanitizeHtml from "sanitize-html";
|
||||
|
||||
/**
|
||||
* Parse a date string with fallback for non-standard formats
|
||||
* @param {string|Date} dateInput - Date string or Date object
|
||||
* @returns {Date|undefined} Parsed Date or undefined if invalid
|
||||
*/
|
||||
function parseDate(dateInput) {
|
||||
if (!dateInput) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Already a valid Date
|
||||
if (dateInput instanceof Date && !Number.isNaN(dateInput.getTime())) {
|
||||
return dateInput;
|
||||
}
|
||||
|
||||
const dateString = String(dateInput).trim();
|
||||
|
||||
// Try standard parsing first
|
||||
let date = new Date(dateString);
|
||||
if (!Number.isNaN(date.getTime())) {
|
||||
return date;
|
||||
}
|
||||
|
||||
// Handle "YYYY-MM-DD HH:MM" format (missing seconds and timezone)
|
||||
// e.g., "2026-01-28 08:40"
|
||||
const shortDateTime = dateString.match(
|
||||
/^(\d{4}-\d{2}-\d{2})\s+(\d{2}:\d{2})$/,
|
||||
);
|
||||
if (shortDateTime) {
|
||||
date = new Date(`${shortDateTime[1]}T${shortDateTime[2]}:00Z`);
|
||||
if (!Number.isNaN(date.getTime())) {
|
||||
return date;
|
||||
}
|
||||
}
|
||||
|
||||
// Handle "YYYY-MM-DD HH:MM:SS" without timezone
|
||||
const dateTimeNoTz = dateString.match(
|
||||
/^(\d{4}-\d{2}-\d{2})\s+(\d{2}:\d{2}:\d{2})$/,
|
||||
);
|
||||
if (dateTimeNoTz) {
|
||||
date = new Date(`${dateTimeNoTz[1]}T${dateTimeNoTz[2]}Z`);
|
||||
if (!Number.isNaN(date.getTime())) {
|
||||
return date;
|
||||
}
|
||||
}
|
||||
|
||||
// If all else fails, return undefined
|
||||
return;
|
||||
}
|
||||
|
||||
/**
|
||||
* Safely convert date to ISO string
|
||||
* @param {string|Date} dateInput - Date input
|
||||
* @returns {string|undefined} ISO string or undefined
|
||||
*/
|
||||
function toISOStringSafe(dateInput) {
|
||||
const date = parseDate(dateInput);
|
||||
return date ? date.toISOString() : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitize HTML options
|
||||
*/
|
||||
const SANITIZE_OPTIONS = {
|
||||
allowedTags: [
|
||||
"a",
|
||||
"abbr",
|
||||
"b",
|
||||
"blockquote",
|
||||
"br",
|
||||
"code",
|
||||
"em",
|
||||
"figcaption",
|
||||
"figure",
|
||||
"h1",
|
||||
"h2",
|
||||
"h3",
|
||||
"h4",
|
||||
"h5",
|
||||
"h6",
|
||||
"hr",
|
||||
"i",
|
||||
"img",
|
||||
"li",
|
||||
"ol",
|
||||
"p",
|
||||
"pre",
|
||||
"s",
|
||||
"span",
|
||||
"strike",
|
||||
"strong",
|
||||
"sub",
|
||||
"sup",
|
||||
"table",
|
||||
"tbody",
|
||||
"td",
|
||||
"th",
|
||||
"thead",
|
||||
"tr",
|
||||
"u",
|
||||
"ul",
|
||||
"video",
|
||||
"audio",
|
||||
"source",
|
||||
],
|
||||
allowedAttributes: {
|
||||
a: ["href", "title", "rel"],
|
||||
img: ["src", "alt", "title", "width", "height"],
|
||||
video: ["src", "poster", "controls", "width", "height"],
|
||||
audio: ["src", "controls"],
|
||||
source: ["src", "type"],
|
||||
"*": ["class"],
|
||||
},
|
||||
allowedSchemes: ["http", "https", "mailto"],
|
||||
};
|
||||
|
||||
/**
|
||||
* Generate unique ID for an item
|
||||
* @param {string} feedUrl - Feed URL
|
||||
* @param {string} itemId - Item identifier (URL or ID)
|
||||
* @returns {string} Unique ID hash
|
||||
*/
|
||||
export function generateItemUid(feedUrl, itemId) {
|
||||
const hash = crypto.createHash("sha256");
|
||||
hash.update(`${feedUrl}::${itemId}`);
|
||||
return hash.digest("hex").slice(0, 24);
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize RSS/Atom item from feedparser
|
||||
* @param {object} item - Feedparser item
|
||||
* @param {string} feedUrl - Feed URL
|
||||
* @param {string} feedType - 'rss' or 'atom'
|
||||
* @returns {object} Normalized jf2 item
|
||||
*/
|
||||
export function normalizeItem(item, feedUrl, feedType) {
|
||||
const url = item.link || item.origlink || item.guid;
|
||||
const uid = generateItemUid(feedUrl, item.guid || url || item.title);
|
||||
|
||||
const normalized = {
|
||||
type: "entry",
|
||||
uid,
|
||||
url,
|
||||
name: item.title || undefined,
|
||||
published: toISOStringSafe(item.pubdate),
|
||||
updated: toISOStringSafe(item.date),
|
||||
_source: {
|
||||
url: feedUrl,
|
||||
feedUrl,
|
||||
feedType,
|
||||
originalId: item.guid,
|
||||
},
|
||||
};
|
||||
|
||||
// Content
|
||||
if (item.description || item.summary) {
|
||||
const html = item.description || item.summary;
|
||||
normalized.content = {
|
||||
html: sanitizeHtml(html, SANITIZE_OPTIONS),
|
||||
text: sanitizeHtml(html, { allowedTags: [] }).trim(),
|
||||
};
|
||||
}
|
||||
|
||||
// Summary (prefer explicit summary over truncated content)
|
||||
if (item.summary && item.description && item.summary !== item.description) {
|
||||
normalized.summary = sanitizeHtml(item.summary, { allowedTags: [] }).trim();
|
||||
}
|
||||
|
||||
// Author
|
||||
if (item.author || item["dc:creator"]) {
|
||||
const authorName = item.author || item["dc:creator"];
|
||||
normalized.author = {
|
||||
type: "card",
|
||||
name: authorName,
|
||||
};
|
||||
}
|
||||
|
||||
// Categories/tags
|
||||
if (item.categories && item.categories.length > 0) {
|
||||
normalized.category = item.categories;
|
||||
}
|
||||
|
||||
// Enclosures (media)
|
||||
if (item.enclosures && item.enclosures.length > 0) {
|
||||
for (const enclosure of item.enclosures) {
|
||||
const mediaUrl = enclosure.url;
|
||||
const mediaType = enclosure.type || "";
|
||||
|
||||
if (mediaType.startsWith("image/")) {
|
||||
normalized.photo = normalized.photo || [];
|
||||
normalized.photo.push(mediaUrl);
|
||||
} else if (mediaType.startsWith("video/")) {
|
||||
normalized.video = normalized.video || [];
|
||||
normalized.video.push(mediaUrl);
|
||||
} else if (mediaType.startsWith("audio/")) {
|
||||
normalized.audio = normalized.audio || [];
|
||||
normalized.audio.push(mediaUrl);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Featured image from media content
|
||||
if (item["media:content"] && item["media:content"].url) {
|
||||
const mediaType = item["media:content"].type || "";
|
||||
if (
|
||||
mediaType.startsWith("image/") ||
|
||||
item["media:content"].medium === "image"
|
||||
) {
|
||||
normalized.photo = normalized.photo || [];
|
||||
if (!normalized.photo.includes(item["media:content"].url)) {
|
||||
normalized.photo.push(item["media:content"].url);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Image from item.image
|
||||
if (item.image && item.image.url) {
|
||||
normalized.photo = normalized.photo || [];
|
||||
if (!normalized.photo.includes(item.image.url)) {
|
||||
normalized.photo.push(item.image.url);
|
||||
}
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize feed metadata from feedparser
|
||||
* @param {object} meta - Feedparser meta object
|
||||
* @param {string} feedUrl - Feed URL
|
||||
* @returns {object} Normalized feed metadata
|
||||
*/
|
||||
export function normalizeFeedMeta(meta, feedUrl) {
|
||||
const normalized = {
|
||||
name: meta.title || feedUrl,
|
||||
};
|
||||
|
||||
if (meta.description) {
|
||||
normalized.summary = meta.description;
|
||||
}
|
||||
|
||||
if (meta.link) {
|
||||
normalized.url = meta.link;
|
||||
}
|
||||
|
||||
if (meta.image && meta.image.url) {
|
||||
normalized.photo = meta.image.url;
|
||||
}
|
||||
|
||||
if (meta.favicon) {
|
||||
normalized.photo = normalized.photo || meta.favicon;
|
||||
}
|
||||
|
||||
// Author/publisher
|
||||
if (meta.author) {
|
||||
normalized.author = {
|
||||
type: "card",
|
||||
name: meta.author,
|
||||
};
|
||||
}
|
||||
|
||||
// Hub for WebSub
|
||||
if (meta.cloud && meta.cloud.href) {
|
||||
normalized._hub = meta.cloud.href;
|
||||
}
|
||||
|
||||
// Look for hub in links
|
||||
if (meta.link && meta["atom:link"]) {
|
||||
const links = Array.isArray(meta["atom:link"])
|
||||
? meta["atom:link"]
|
||||
: [meta["atom:link"]];
|
||||
for (const link of links) {
|
||||
if (link["@"] && link["@"].rel === "hub") {
|
||||
normalized._hub = link["@"].href;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize JSON Feed item
|
||||
* @param {object} item - JSON Feed item
|
||||
* @param {string} feedUrl - Feed URL
|
||||
* @returns {object} Normalized jf2 item
|
||||
*/
|
||||
export function normalizeJsonFeedItem(item, feedUrl) {
|
||||
const url = item.url || item.external_url;
|
||||
const uid = generateItemUid(feedUrl, item.id || url);
|
||||
|
||||
const normalized = {
|
||||
type: "entry",
|
||||
uid,
|
||||
url,
|
||||
name: item.title || undefined,
|
||||
published: item.date_published
|
||||
? new Date(item.date_published).toISOString()
|
||||
: undefined,
|
||||
updated: item.date_modified
|
||||
? new Date(item.date_modified).toISOString()
|
||||
: undefined,
|
||||
_source: {
|
||||
url: feedUrl,
|
||||
feedUrl,
|
||||
feedType: "jsonfeed",
|
||||
originalId: item.id,
|
||||
},
|
||||
};
|
||||
|
||||
// Content
|
||||
if (item.content_html || item.content_text) {
|
||||
normalized.content = {};
|
||||
if (item.content_html) {
|
||||
normalized.content.html = sanitizeHtml(
|
||||
item.content_html,
|
||||
SANITIZE_OPTIONS,
|
||||
);
|
||||
normalized.content.text = sanitizeHtml(item.content_html, {
|
||||
allowedTags: [],
|
||||
}).trim();
|
||||
} else if (item.content_text) {
|
||||
normalized.content.text = item.content_text;
|
||||
}
|
||||
}
|
||||
|
||||
// Summary
|
||||
if (item.summary) {
|
||||
normalized.summary = item.summary;
|
||||
}
|
||||
|
||||
// Author
|
||||
if (item.author || item.authors) {
|
||||
const author = item.author || (item.authors && item.authors[0]);
|
||||
if (author) {
|
||||
normalized.author = {
|
||||
type: "card",
|
||||
name: author.name,
|
||||
url: author.url,
|
||||
photo: author.avatar,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Tags
|
||||
if (item.tags && item.tags.length > 0) {
|
||||
normalized.category = item.tags;
|
||||
}
|
||||
|
||||
// Featured image
|
||||
if (item.image) {
|
||||
normalized.photo = [item.image];
|
||||
}
|
||||
|
||||
if (item.banner_image && !normalized.photo) {
|
||||
normalized.photo = [item.banner_image];
|
||||
}
|
||||
|
||||
// Attachments
|
||||
if (item.attachments && item.attachments.length > 0) {
|
||||
for (const attachment of item.attachments) {
|
||||
const mediaType = attachment.mime_type || "";
|
||||
|
||||
if (mediaType.startsWith("image/")) {
|
||||
normalized.photo = normalized.photo || [];
|
||||
normalized.photo.push(attachment.url);
|
||||
} else if (mediaType.startsWith("video/")) {
|
||||
normalized.video = normalized.video || [];
|
||||
normalized.video.push(attachment.url);
|
||||
} else if (mediaType.startsWith("audio/")) {
|
||||
normalized.audio = normalized.audio || [];
|
||||
normalized.audio.push(attachment.url);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// External URL
|
||||
if (item.external_url && item.url !== item.external_url) {
|
||||
normalized["bookmark-of"] = [item.external_url];
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize JSON Feed metadata
|
||||
* @param {object} feed - JSON Feed object
|
||||
* @param {string} feedUrl - Feed URL
|
||||
* @returns {object} Normalized feed metadata
|
||||
*/
|
||||
export function normalizeJsonFeedMeta(feed, feedUrl) {
|
||||
const normalized = {
|
||||
name: feed.title || feedUrl,
|
||||
};
|
||||
|
||||
if (feed.description) {
|
||||
normalized.summary = feed.description;
|
||||
}
|
||||
|
||||
if (feed.home_page_url) {
|
||||
normalized.url = feed.home_page_url;
|
||||
}
|
||||
|
||||
if (feed.icon) {
|
||||
normalized.photo = feed.icon;
|
||||
} else if (feed.favicon) {
|
||||
normalized.photo = feed.favicon;
|
||||
}
|
||||
|
||||
if (feed.author || feed.authors) {
|
||||
const author = feed.author || (feed.authors && feed.authors[0]);
|
||||
if (author) {
|
||||
normalized.author = {
|
||||
type: "card",
|
||||
name: author.name,
|
||||
url: author.url,
|
||||
photo: author.avatar,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Hub for WebSub
|
||||
if (feed.hubs && feed.hubs.length > 0) {
|
||||
normalized._hub = feed.hubs[0].url;
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize h-feed entry
|
||||
* @param {object} entry - Microformats h-entry
|
||||
* @param {string} feedUrl - Feed URL
|
||||
* @returns {object} Normalized jf2 item
|
||||
*/
|
||||
export function normalizeHfeedItem(entry, feedUrl) {
|
||||
const properties = entry.properties || {};
|
||||
const url = getFirst(properties.url) || getFirst(properties.uid);
|
||||
const uid = generateItemUid(feedUrl, getFirst(properties.uid) || url);
|
||||
|
||||
const normalized = {
|
||||
type: "entry",
|
||||
uid,
|
||||
url,
|
||||
_source: {
|
||||
url: feedUrl,
|
||||
feedUrl,
|
||||
feedType: "hfeed",
|
||||
originalId: getFirst(properties.uid),
|
||||
},
|
||||
};
|
||||
|
||||
// Name/title
|
||||
if (properties.name) {
|
||||
const name = getFirst(properties.name);
|
||||
// Only include name if it's not just the content
|
||||
if (
|
||||
name &&
|
||||
(!properties.content || name !== getContentText(properties.content))
|
||||
) {
|
||||
normalized.name = name;
|
||||
}
|
||||
}
|
||||
|
||||
// Published
|
||||
if (properties.published) {
|
||||
const published = getFirst(properties.published);
|
||||
normalized.published = new Date(published).toISOString();
|
||||
}
|
||||
|
||||
// Updated
|
||||
if (properties.updated) {
|
||||
const updated = getFirst(properties.updated);
|
||||
normalized.updated = new Date(updated).toISOString();
|
||||
}
|
||||
|
||||
// Content
|
||||
if (properties.content) {
|
||||
const content = getFirst(properties.content);
|
||||
if (typeof content === "object") {
|
||||
normalized.content = {
|
||||
html: content.html
|
||||
? sanitizeHtml(content.html, SANITIZE_OPTIONS)
|
||||
: undefined,
|
||||
text: content.value || undefined,
|
||||
};
|
||||
} else if (typeof content === "string") {
|
||||
normalized.content = { text: content };
|
||||
}
|
||||
}
|
||||
|
||||
// Summary
|
||||
if (properties.summary) {
|
||||
normalized.summary = getFirst(properties.summary);
|
||||
}
|
||||
|
||||
// Author
|
||||
if (properties.author) {
|
||||
const author = getFirst(properties.author);
|
||||
normalized.author = normalizeHcard(author);
|
||||
}
|
||||
|
||||
// Categories
|
||||
if (properties.category) {
|
||||
normalized.category = properties.category;
|
||||
}
|
||||
|
||||
// Photos
|
||||
if (properties.photo) {
|
||||
normalized.photo = properties.photo.map((p) =>
|
||||
typeof p === "object" ? p.value || p.url : p,
|
||||
);
|
||||
}
|
||||
|
||||
// Videos
|
||||
if (properties.video) {
|
||||
normalized.video = properties.video.map((v) =>
|
||||
typeof v === "object" ? v.value || v.url : v,
|
||||
);
|
||||
}
|
||||
|
||||
// Audio
|
||||
if (properties.audio) {
|
||||
normalized.audio = properties.audio.map((a) =>
|
||||
typeof a === "object" ? a.value || a.url : a,
|
||||
);
|
||||
}
|
||||
|
||||
// Interaction types - normalize to string URLs
|
||||
if (properties["like-of"]) {
|
||||
normalized["like-of"] = normalizeUrlArray(properties["like-of"]);
|
||||
}
|
||||
if (properties["repost-of"]) {
|
||||
normalized["repost-of"] = normalizeUrlArray(properties["repost-of"]);
|
||||
}
|
||||
if (properties["bookmark-of"]) {
|
||||
normalized["bookmark-of"] = normalizeUrlArray(properties["bookmark-of"]);
|
||||
}
|
||||
if (properties["in-reply-to"]) {
|
||||
normalized["in-reply-to"] = normalizeUrlArray(properties["in-reply-to"]);
|
||||
}
|
||||
|
||||
// RSVP
|
||||
if (properties.rsvp) {
|
||||
normalized.rsvp = getFirst(properties.rsvp);
|
||||
}
|
||||
|
||||
// Syndication
|
||||
if (properties.syndication) {
|
||||
normalized.syndication = properties.syndication;
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize h-feed metadata
|
||||
* @param {object} hfeed - h-feed microformat object
|
||||
* @param {string} feedUrl - Feed URL
|
||||
* @returns {object} Normalized feed metadata
|
||||
*/
|
||||
export function normalizeHfeedMeta(hfeed, feedUrl) {
|
||||
const properties = hfeed.properties || {};
|
||||
|
||||
const normalized = {
|
||||
name: getFirst(properties.name) || feedUrl,
|
||||
};
|
||||
|
||||
if (properties.summary) {
|
||||
normalized.summary = getFirst(properties.summary);
|
||||
}
|
||||
|
||||
if (properties.url) {
|
||||
normalized.url = getFirst(properties.url);
|
||||
}
|
||||
|
||||
if (properties.photo) {
|
||||
normalized.photo = getFirst(properties.photo);
|
||||
if (typeof normalized.photo === "object") {
|
||||
normalized.photo = normalized.photo.value || normalized.photo.url;
|
||||
}
|
||||
}
|
||||
|
||||
if (properties.author) {
|
||||
const author = getFirst(properties.author);
|
||||
normalized.author = normalizeHcard(author);
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract URL string from a photo value
|
||||
* @param {object|string} photo - Photo value (can be string URL or object with value/url)
|
||||
* @returns {string|undefined} Photo URL string
|
||||
*/
|
||||
function extractPhotoUrl(photo) {
|
||||
if (!photo) {
|
||||
return;
|
||||
}
|
||||
if (typeof photo === "string") {
|
||||
return photo;
|
||||
}
|
||||
if (typeof photo === "object") {
|
||||
return photo.value || photo.url || photo.src;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract URL string from a value that may be string or object
|
||||
* @param {object|string} value - URL string or object with url/value property
|
||||
* @returns {string|undefined} URL string
|
||||
*/
|
||||
function extractUrl(value) {
|
||||
if (!value) {
|
||||
return;
|
||||
}
|
||||
if (typeof value === "string") {
|
||||
return value;
|
||||
}
|
||||
if (typeof value === "object") {
|
||||
return value.value || value.url || value.href;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize an array of URLs that may contain strings or objects
|
||||
* @param {Array} urls - Array of URL strings or objects
|
||||
* @returns {Array<string>} Array of URL strings
|
||||
*/
|
||||
function normalizeUrlArray(urls) {
|
||||
if (!urls || !Array.isArray(urls)) {
|
||||
return [];
|
||||
}
|
||||
return urls.map((u) => extractUrl(u)).filter(Boolean);
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize h-card author
|
||||
* @param {object|string} hcard - h-card or author name string
|
||||
* @returns {object} Normalized author object
|
||||
*/
|
||||
function normalizeHcard(hcard) {
|
||||
if (typeof hcard === "string") {
|
||||
return { type: "card", name: hcard };
|
||||
}
|
||||
|
||||
if (!hcard || !hcard.properties) {
|
||||
return;
|
||||
}
|
||||
|
||||
const properties = hcard.properties;
|
||||
|
||||
return {
|
||||
type: "card",
|
||||
name: getFirst(properties.name),
|
||||
url: getFirst(properties.url),
|
||||
photo: extractPhotoUrl(getFirst(properties.photo)),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get first item from array or return the value itself
|
||||
* @param {Array|*} value - Value or array of values
|
||||
* @returns {*} First value or the value itself
|
||||
*/
|
||||
function getFirst(value) {
|
||||
if (Array.isArray(value)) {
|
||||
return value[0];
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get text content from content property
|
||||
* @param {Array} content - Content property array
|
||||
* @returns {string} Text content
|
||||
*/
|
||||
function getContentText(content) {
|
||||
const first = getFirst(content);
|
||||
if (typeof first === "object") {
|
||||
return first.value || first.text || "";
|
||||
}
|
||||
return first || "";
|
||||
}
|
||||
135
lib/feeds/parser.js
Normal file
135
lib/feeds/parser.js
Normal file
@@ -0,0 +1,135 @@
|
||||
/**
|
||||
* Feed parser dispatcher
|
||||
* @module feeds/parser
|
||||
*/
|
||||
|
||||
import { parseAtom } from "./atom.js";
|
||||
import { parseHfeed } from "./hfeed.js";
|
||||
import { parseJsonFeed } from "./jsonfeed.js";
|
||||
import { parseRss } from "./rss.js";
|
||||
|
||||
/**
|
||||
* Detect feed type from content
|
||||
* @param {string} content - Feed content
|
||||
* @param {string} contentType - HTTP Content-Type header
|
||||
* @returns {string} Feed type: 'rss' | 'atom' | 'jsonfeed' | 'hfeed' | 'unknown'
|
||||
*/
|
||||
export function detectFeedType(content, contentType = "") {
|
||||
const ct = contentType.toLowerCase();
|
||||
|
||||
// Check Content-Type header first
|
||||
if (ct.includes("application/json") || ct.includes("application/feed+json")) {
|
||||
return "jsonfeed";
|
||||
}
|
||||
|
||||
if (ct.includes("application/atom+xml")) {
|
||||
return "atom";
|
||||
}
|
||||
|
||||
if (
|
||||
ct.includes("application/rss+xml") ||
|
||||
ct.includes("application/xml") ||
|
||||
ct.includes("text/xml")
|
||||
) {
|
||||
// Need to check content to distinguish RSS from Atom
|
||||
const trimmed = content.trim();
|
||||
if (
|
||||
trimmed.includes("<feed") &&
|
||||
trimmed.includes('xmlns="http://www.w3.org/2005/Atom"')
|
||||
) {
|
||||
return "atom";
|
||||
}
|
||||
if (trimmed.includes("<rss") || trimmed.includes("<rdf:RDF")) {
|
||||
return "rss";
|
||||
}
|
||||
}
|
||||
|
||||
if (ct.includes("text/html")) {
|
||||
return "hfeed";
|
||||
}
|
||||
|
||||
// Fall back to content inspection
|
||||
const trimmed = content.trim();
|
||||
|
||||
// JSON content
|
||||
if (trimmed.startsWith("{")) {
|
||||
try {
|
||||
const json = JSON.parse(trimmed);
|
||||
// JSON Feed
|
||||
if (json.version && json.version.includes("jsonfeed.org")) {
|
||||
return "jsonfeed";
|
||||
}
|
||||
// ActivityPub - return special type to indicate we need feed discovery
|
||||
if (json["@context"] || json.type === "Group" || json.inbox) {
|
||||
return "activitypub";
|
||||
}
|
||||
} catch {
|
||||
// Not JSON
|
||||
}
|
||||
}
|
||||
|
||||
// XML feeds
|
||||
if (trimmed.startsWith("<?xml") || trimmed.startsWith("<")) {
|
||||
if (
|
||||
trimmed.includes("<feed") &&
|
||||
trimmed.includes('xmlns="http://www.w3.org/2005/Atom"')
|
||||
) {
|
||||
return "atom";
|
||||
}
|
||||
if (trimmed.includes("<rss") || trimmed.includes("<rdf:RDF")) {
|
||||
return "rss";
|
||||
}
|
||||
}
|
||||
|
||||
// HTML with potential h-feed
|
||||
if (trimmed.includes("<!DOCTYPE html") || trimmed.includes("<html")) {
|
||||
return "hfeed";
|
||||
}
|
||||
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse feed content into normalized items
|
||||
* @param {string} content - Feed content
|
||||
* @param {string} feedUrl - URL of the feed
|
||||
* @param {object} options - Parse options
|
||||
* @param {string} [options.contentType] - HTTP Content-Type header
|
||||
* @returns {Promise<object>} Parsed feed with metadata and items
|
||||
*/
|
||||
export async function parseFeed(content, feedUrl, options = {}) {
|
||||
const feedType = detectFeedType(content, options.contentType);
|
||||
|
||||
switch (feedType) {
|
||||
case "rss": {
|
||||
return parseRss(content, feedUrl);
|
||||
}
|
||||
|
||||
case "atom": {
|
||||
return parseAtom(content, feedUrl);
|
||||
}
|
||||
|
||||
case "jsonfeed": {
|
||||
return parseJsonFeed(content, feedUrl);
|
||||
}
|
||||
|
||||
case "hfeed": {
|
||||
return parseHfeed(content, feedUrl);
|
||||
}
|
||||
|
||||
case "activitypub": {
|
||||
throw new Error(
|
||||
`URL returns ActivityPub JSON instead of a feed. Try the direct feed URL (e.g., ${feedUrl}feed/)`,
|
||||
);
|
||||
}
|
||||
|
||||
default: {
|
||||
throw new Error(`Unable to detect feed type for ${feedUrl}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export { parseAtom } from "./atom.js";
|
||||
export { parseHfeed } from "./hfeed.js";
|
||||
export { parseJsonFeed } from "./jsonfeed.js";
|
||||
export { parseRss } from "./rss.js";
|
||||
61
lib/feeds/rss.js
Normal file
61
lib/feeds/rss.js
Normal file
@@ -0,0 +1,61 @@
|
||||
/**
|
||||
* RSS 1.0/2.0 feed parser
|
||||
* @module feeds/rss
|
||||
*/
|
||||
|
||||
import { Readable } from "node:stream";
|
||||
|
||||
import FeedParser from "feedparser";
|
||||
|
||||
import { normalizeItem, normalizeFeedMeta } from "./normalizer.js";
|
||||
|
||||
/**
|
||||
* Parse RSS feed content
|
||||
* @param {string} content - RSS XML content
|
||||
* @param {string} feedUrl - URL of the feed
|
||||
* @returns {Promise<object>} Parsed feed with metadata and items
|
||||
*/
|
||||
export async function parseRss(content, feedUrl) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const feedparser = new FeedParser({ feedurl: feedUrl });
|
||||
const items = [];
|
||||
let meta;
|
||||
|
||||
feedparser.on("error", (error) => {
|
||||
reject(new Error(`RSS parse error: ${error.message}`));
|
||||
});
|
||||
|
||||
feedparser.on("meta", (feedMeta) => {
|
||||
meta = feedMeta;
|
||||
});
|
||||
|
||||
feedparser.on("readable", function () {
|
||||
let item;
|
||||
while ((item = this.read())) {
|
||||
items.push(item);
|
||||
}
|
||||
});
|
||||
|
||||
feedparser.on("end", () => {
|
||||
try {
|
||||
const normalizedMeta = normalizeFeedMeta(meta, feedUrl);
|
||||
const normalizedItems = items.map((item) =>
|
||||
normalizeItem(item, feedUrl, "rss"),
|
||||
);
|
||||
|
||||
resolve({
|
||||
type: "feed",
|
||||
url: feedUrl,
|
||||
...normalizedMeta,
|
||||
items: normalizedItems,
|
||||
});
|
||||
} catch (error) {
|
||||
reject(error);
|
||||
}
|
||||
});
|
||||
|
||||
// Create readable stream from string and pipe to feedparser
|
||||
const stream = Readable.from([content]);
|
||||
stream.pipe(feedparser);
|
||||
});
|
||||
}
|
||||
219
lib/media/proxy.js
Normal file
219
lib/media/proxy.js
Normal file
@@ -0,0 +1,219 @@
|
||||
/**
|
||||
* Media proxy with caching
|
||||
* @module media/proxy
|
||||
*/
|
||||
|
||||
import crypto from "node:crypto";
|
||||
|
||||
import { getCache, setCache } from "../cache/redis.js";
|
||||
|
||||
const MAX_SIZE = 2 * 1024 * 1024; // 2MB max image size
|
||||
const CACHE_TTL = 4 * 60 * 60; // 4 hours
|
||||
const ALLOWED_TYPES = new Set([
|
||||
"image/jpeg",
|
||||
"image/png",
|
||||
"image/gif",
|
||||
"image/webp",
|
||||
"image/svg+xml",
|
||||
]);
|
||||
|
||||
/**
|
||||
* Generate a hash for a URL to use as cache key
|
||||
* @param {string} url - Original image URL
|
||||
* @returns {string} URL-safe hash
|
||||
*/
|
||||
export function hashUrl(url) {
|
||||
return crypto.createHash("sha256").update(url).digest("hex").slice(0, 16);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the proxied URL for an image
|
||||
* @param {string} baseUrl - Base URL of the Microsub endpoint
|
||||
* @param {string} originalUrl - Original image URL
|
||||
* @returns {string} Proxied URL
|
||||
*/
|
||||
export function getProxiedUrl(baseUrl, originalUrl) {
|
||||
if (!originalUrl || !baseUrl) {
|
||||
return originalUrl;
|
||||
}
|
||||
|
||||
// Skip data URLs
|
||||
if (originalUrl.startsWith("data:")) {
|
||||
return originalUrl;
|
||||
}
|
||||
|
||||
// Skip already-proxied URLs
|
||||
if (originalUrl.includes("/microsub/media/")) {
|
||||
return originalUrl;
|
||||
}
|
||||
|
||||
const hash = hashUrl(originalUrl);
|
||||
return `${baseUrl}/microsub/media/${hash}?url=${encodeURIComponent(originalUrl)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Rewrite image URLs in an item to use the proxy
|
||||
* @param {object} item - JF2 item
|
||||
* @param {string} baseUrl - Base URL for proxy
|
||||
* @returns {object} Item with proxied URLs
|
||||
*/
|
||||
export function proxyItemImages(item, baseUrl) {
|
||||
if (!baseUrl || !item) {
|
||||
return item;
|
||||
}
|
||||
|
||||
const proxied = { ...item };
|
||||
|
||||
// Proxy photo URLs
|
||||
if (proxied.photo) {
|
||||
if (Array.isArray(proxied.photo)) {
|
||||
proxied.photo = proxied.photo.map((p) => {
|
||||
if (typeof p === "string") {
|
||||
return getProxiedUrl(baseUrl, p);
|
||||
}
|
||||
if (p?.value) {
|
||||
return { ...p, value: getProxiedUrl(baseUrl, p.value) };
|
||||
}
|
||||
return p;
|
||||
});
|
||||
} else if (typeof proxied.photo === "string") {
|
||||
proxied.photo = getProxiedUrl(baseUrl, proxied.photo);
|
||||
}
|
||||
}
|
||||
|
||||
// Proxy author photo
|
||||
if (proxied.author?.photo) {
|
||||
proxied.author = {
|
||||
...proxied.author,
|
||||
photo: getProxiedUrl(baseUrl, proxied.author.photo),
|
||||
};
|
||||
}
|
||||
|
||||
return proxied;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch and cache an image
|
||||
* @param {object} redis - Redis client
|
||||
* @param {string} url - Image URL to fetch
|
||||
* @returns {Promise<object|null>} Cached image data or null
|
||||
*/
|
||||
export async function fetchImage(redis, url) {
|
||||
const cacheKey = `media:${hashUrl(url)}`;
|
||||
|
||||
// Try cache first
|
||||
if (redis) {
|
||||
const cached = await getCache(redis, cacheKey);
|
||||
if (cached) {
|
||||
return cached;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
// Fetch the image
|
||||
const response = await fetch(url, {
|
||||
headers: {
|
||||
"User-Agent": "Indiekit Microsub/1.0 (+https://getindiekit.com)",
|
||||
Accept: "image/*",
|
||||
},
|
||||
signal: AbortSignal.timeout(10_000), // 10 second timeout
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
console.error(
|
||||
`[Microsub] Media proxy fetch failed: ${response.status} for ${url}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Check content type
|
||||
const contentType = response.headers.get("content-type")?.split(";")[0];
|
||||
if (!ALLOWED_TYPES.has(contentType)) {
|
||||
console.error(
|
||||
`[Microsub] Media proxy rejected type: ${contentType} for ${url}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Check content length
|
||||
const contentLength = Number.parseInt(
|
||||
response.headers.get("content-length") || "0",
|
||||
10,
|
||||
);
|
||||
if (contentLength > MAX_SIZE) {
|
||||
console.error(
|
||||
`[Microsub] Media proxy rejected size: ${contentLength} for ${url}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Read the body
|
||||
const buffer = await response.arrayBuffer();
|
||||
if (buffer.byteLength > MAX_SIZE) {
|
||||
return;
|
||||
}
|
||||
|
||||
const imageData = {
|
||||
contentType,
|
||||
data: Buffer.from(buffer).toString("base64"),
|
||||
size: buffer.byteLength,
|
||||
};
|
||||
|
||||
// Cache in Redis
|
||||
if (redis) {
|
||||
await setCache(redis, cacheKey, imageData, CACHE_TTL);
|
||||
}
|
||||
|
||||
return imageData;
|
||||
} catch (error) {
|
||||
console.error(`[Microsub] Media proxy error: ${error.message} for ${url}`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Express route handler for media proxy
|
||||
* @param {object} request - Express request
|
||||
* @param {object} response - Express response
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
export async function handleMediaProxy(request, response) {
|
||||
const { url } = request.query;
|
||||
|
||||
if (!url) {
|
||||
return response.status(400).send("Missing url parameter");
|
||||
}
|
||||
|
||||
// Validate URL
|
||||
try {
|
||||
const parsed = new URL(url);
|
||||
if (!["http:", "https:"].includes(parsed.protocol)) {
|
||||
return response.status(400).send("Invalid URL protocol");
|
||||
}
|
||||
} catch {
|
||||
return response.status(400).send("Invalid URL");
|
||||
}
|
||||
|
||||
// Get Redis client from application
|
||||
const { application } = request.app.locals;
|
||||
const redis = application.redis;
|
||||
|
||||
// Fetch or get from cache
|
||||
const imageData = await fetchImage(redis, url);
|
||||
|
||||
if (!imageData) {
|
||||
// Redirect to original URL as fallback
|
||||
return response.redirect(url);
|
||||
}
|
||||
|
||||
// Set cache headers
|
||||
response.set({
|
||||
"Content-Type": imageData.contentType,
|
||||
"Content-Length": imageData.size,
|
||||
"Cache-Control": "public, max-age=14400", // 4 hours
|
||||
"X-Proxied-From": url,
|
||||
});
|
||||
|
||||
// Send the image
|
||||
response.send(Buffer.from(imageData.data, "base64"));
|
||||
}
|
||||
234
lib/polling/processor.js
Normal file
234
lib/polling/processor.js
Normal file
@@ -0,0 +1,234 @@
|
||||
/**
|
||||
* Feed processing pipeline
|
||||
* @module polling/processor
|
||||
*/
|
||||
|
||||
import { getRedisClient, publishEvent } from "../cache/redis.js";
|
||||
import { fetchAndParseFeed } from "../feeds/fetcher.js";
|
||||
import { getChannel } from "../storage/channels.js";
|
||||
import { updateFeedAfterFetch, updateFeedWebsub } from "../storage/feeds.js";
|
||||
import { passesRegexFilter, passesTypeFilter } from "../storage/filters.js";
|
||||
import { addItem } from "../storage/items.js";
|
||||
import {
|
||||
subscribe as websubSubscribe,
|
||||
getCallbackUrl,
|
||||
} from "../websub/subscriber.js";
|
||||
|
||||
import { calculateNewTier } from "./tier.js";
|
||||
|
||||
/**
|
||||
* Process a single feed
|
||||
* @param {object} application - Indiekit application
|
||||
* @param {object} feed - Feed document from database
|
||||
* @returns {Promise<object>} Processing result
|
||||
*/
|
||||
export async function processFeed(application, feed) {
|
||||
const startTime = Date.now();
|
||||
const result = {
|
||||
feedId: feed._id,
|
||||
url: feed.url,
|
||||
success: false,
|
||||
itemsAdded: 0,
|
||||
error: undefined,
|
||||
};
|
||||
|
||||
try {
|
||||
// Get Redis client for caching
|
||||
const redis = getRedisClient(application);
|
||||
|
||||
// Fetch and parse the feed
|
||||
const parsed = await fetchAndParseFeed(feed.url, {
|
||||
etag: feed.etag,
|
||||
lastModified: feed.lastModified,
|
||||
redis,
|
||||
});
|
||||
|
||||
// Handle 304 Not Modified
|
||||
if (parsed.notModified) {
|
||||
const tierResult = calculateNewTier({
|
||||
currentTier: feed.tier,
|
||||
hasNewItems: false,
|
||||
consecutiveUnchanged: feed.unmodified || 0,
|
||||
});
|
||||
|
||||
await updateFeedAfterFetch(application, feed._id, false, {
|
||||
tier: tierResult.tier,
|
||||
unmodified: tierResult.consecutiveUnchanged,
|
||||
nextFetchAt: tierResult.nextFetchAt,
|
||||
});
|
||||
|
||||
result.success = true;
|
||||
result.notModified = true;
|
||||
return result;
|
||||
}
|
||||
|
||||
// Get channel for filtering
|
||||
const channel = await getChannel(application, feed.channelId);
|
||||
|
||||
// Process items
|
||||
let newItemCount = 0;
|
||||
for (const item of parsed.items) {
|
||||
// Apply channel filters
|
||||
if (channel?.settings && !passesFilters(item, channel.settings)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Enrich item source with feed metadata
|
||||
if (item._source) {
|
||||
item._source.name = feed.title || parsed.name;
|
||||
}
|
||||
|
||||
// Store the item
|
||||
const stored = await addItem(application, {
|
||||
channelId: feed.channelId,
|
||||
feedId: feed._id,
|
||||
uid: item.uid,
|
||||
item,
|
||||
});
|
||||
if (stored) {
|
||||
newItemCount++;
|
||||
|
||||
// Publish real-time event
|
||||
if (redis) {
|
||||
await publishEvent(redis, `microsub:${feed.channelId}`, {
|
||||
type: "new-item",
|
||||
channelId: feed.channelId.toString(),
|
||||
item: stored,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
result.itemsAdded = newItemCount;
|
||||
|
||||
// Update tier based on whether we found new items
|
||||
const tierResult = calculateNewTier({
|
||||
currentTier: feed.tier,
|
||||
hasNewItems: newItemCount > 0,
|
||||
consecutiveUnchanged: newItemCount > 0 ? 0 : feed.unmodified || 0,
|
||||
});
|
||||
|
||||
// Update feed metadata
|
||||
const updateData = {
|
||||
tier: tierResult.tier,
|
||||
unmodified: tierResult.consecutiveUnchanged,
|
||||
nextFetchAt: tierResult.nextFetchAt,
|
||||
etag: parsed.etag,
|
||||
lastModified: parsed.lastModified,
|
||||
};
|
||||
|
||||
// Update feed title/photo if discovered
|
||||
if (parsed.name && !feed.title) {
|
||||
updateData.title = parsed.name;
|
||||
}
|
||||
if (parsed.photo && !feed.photo) {
|
||||
updateData.photo = parsed.photo;
|
||||
}
|
||||
|
||||
await updateFeedAfterFetch(
|
||||
application,
|
||||
feed._id,
|
||||
newItemCount > 0,
|
||||
updateData,
|
||||
);
|
||||
|
||||
// Handle WebSub hub discovery and auto-subscription
|
||||
if (parsed.hub && (!feed.websub || feed.websub.hub !== parsed.hub)) {
|
||||
await updateFeedWebsub(application, feed._id, {
|
||||
hub: parsed.hub,
|
||||
topic: parsed.self || feed.url,
|
||||
});
|
||||
|
||||
// Auto-subscribe to WebSub hub if we have a callback URL
|
||||
const baseUrl = application.url;
|
||||
if (baseUrl) {
|
||||
const callbackUrl = getCallbackUrl(baseUrl, feed._id.toString());
|
||||
const updatedFeed = {
|
||||
...feed,
|
||||
websub: { hub: parsed.hub, topic: parsed.self || feed.url },
|
||||
};
|
||||
|
||||
websubSubscribe(application, updatedFeed, callbackUrl)
|
||||
.then((subscribed) => {
|
||||
if (subscribed) {
|
||||
console.info(
|
||||
`[Microsub] WebSub subscription initiated for ${feed.url}`,
|
||||
);
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error(
|
||||
`[Microsub] WebSub subscription error for ${feed.url}:`,
|
||||
error.message,
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
result.success = true;
|
||||
result.tier = tierResult.tier;
|
||||
} catch (error) {
|
||||
result.error = error.message;
|
||||
|
||||
// Still update the feed to prevent retry storms
|
||||
try {
|
||||
const tierResult = calculateNewTier({
|
||||
currentTier: feed.tier,
|
||||
hasNewItems: false,
|
||||
consecutiveUnchanged: (feed.unmodified || 0) + 1,
|
||||
});
|
||||
|
||||
await updateFeedAfterFetch(application, feed._id, false, {
|
||||
tier: Math.min(tierResult.tier + 1, 10), // Increase tier on error
|
||||
unmodified: tierResult.consecutiveUnchanged,
|
||||
nextFetchAt: tierResult.nextFetchAt,
|
||||
lastError: error.message,
|
||||
lastErrorAt: new Date(),
|
||||
});
|
||||
} catch {
|
||||
// Ignore update errors
|
||||
}
|
||||
}
|
||||
|
||||
result.duration = Date.now() - startTime;
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an item passes channel filters
|
||||
* @param {object} item - Feed item
|
||||
* @param {object} settings - Channel settings
|
||||
* @returns {boolean} Whether the item passes filters
|
||||
*/
|
||||
function passesFilters(item, settings) {
|
||||
return passesTypeFilter(item, settings) && passesRegexFilter(item, settings);
|
||||
}
|
||||
|
||||
/**
|
||||
* Process multiple feeds in batch
|
||||
* @param {object} application - Indiekit application
|
||||
* @param {Array} feeds - Array of feed documents
|
||||
* @param {object} options - Processing options
|
||||
* @returns {Promise<object>} Batch processing result
|
||||
*/
|
||||
export async function processFeedBatch(application, feeds, options = {}) {
|
||||
const { concurrency = 5 } = options;
|
||||
const results = [];
|
||||
|
||||
// Process in batches with limited concurrency
|
||||
for (let index = 0; index < feeds.length; index += concurrency) {
|
||||
const batch = feeds.slice(index, index + concurrency);
|
||||
const batchResults = await Promise.all(
|
||||
batch.map((feed) => processFeed(application, feed)),
|
||||
);
|
||||
results.push(...batchResults);
|
||||
}
|
||||
|
||||
return {
|
||||
total: feeds.length,
|
||||
successful: results.filter((r) => r.success).length,
|
||||
failed: results.filter((r) => !r.success).length,
|
||||
itemsAdded: results.reduce((sum, r) => sum + r.itemsAdded, 0),
|
||||
results,
|
||||
};
|
||||
}
|
||||
128
lib/polling/scheduler.js
Normal file
128
lib/polling/scheduler.js
Normal file
@@ -0,0 +1,128 @@
|
||||
/**
|
||||
* Feed polling scheduler
|
||||
* @module polling/scheduler
|
||||
*/
|
||||
|
||||
import { getFeedsToFetch } from "../storage/feeds.js";
|
||||
|
||||
import { processFeedBatch } from "./processor.js";
|
||||
|
||||
let schedulerInterval;
|
||||
let indiekitInstance;
|
||||
let isRunning = false;
|
||||
|
||||
const POLL_INTERVAL = 60 * 1000; // Run scheduler every minute
|
||||
const BATCH_CONCURRENCY = 5; // Process 5 feeds at a time
|
||||
|
||||
/**
|
||||
* Start the feed polling scheduler
|
||||
* @param {object} indiekit - Indiekit instance
|
||||
*/
|
||||
export function startScheduler(indiekit) {
|
||||
if (schedulerInterval) {
|
||||
return; // Already running
|
||||
}
|
||||
|
||||
indiekitInstance = indiekit;
|
||||
|
||||
// Run every minute
|
||||
schedulerInterval = setInterval(async () => {
|
||||
await runSchedulerCycle();
|
||||
}, POLL_INTERVAL);
|
||||
|
||||
// Run immediately on start
|
||||
runSchedulerCycle();
|
||||
|
||||
console.log("[Microsub] Feed polling scheduler started");
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop the feed polling scheduler
|
||||
*/
|
||||
export function stopScheduler() {
|
||||
if (schedulerInterval) {
|
||||
clearInterval(schedulerInterval);
|
||||
schedulerInterval = undefined;
|
||||
}
|
||||
indiekitInstance = undefined;
|
||||
console.log("[Microsub] Feed polling scheduler stopped");
|
||||
}
|
||||
|
||||
/**
|
||||
* Run a single scheduler cycle
|
||||
*/
|
||||
async function runSchedulerCycle() {
|
||||
if (!indiekitInstance) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Prevent overlapping runs
|
||||
if (isRunning) {
|
||||
return;
|
||||
}
|
||||
|
||||
isRunning = true;
|
||||
|
||||
try {
|
||||
const application = indiekitInstance;
|
||||
const feeds = await getFeedsToFetch(application);
|
||||
|
||||
if (feeds.length === 0) {
|
||||
isRunning = false;
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`[Microsub] Processing ${feeds.length} feeds due for refresh`);
|
||||
|
||||
const result = await processFeedBatch(application, feeds, {
|
||||
concurrency: BATCH_CONCURRENCY,
|
||||
});
|
||||
|
||||
console.log(
|
||||
`[Microsub] Processed ${result.total} feeds: ${result.successful} successful, ` +
|
||||
`${result.failed} failed, ${result.itemsAdded} new items`,
|
||||
);
|
||||
|
||||
// Log any errors
|
||||
for (const feedResult of result.results) {
|
||||
if (feedResult.error) {
|
||||
console.error(
|
||||
`[Microsub] Error processing ${feedResult.url}: ${feedResult.error}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("[Microsub] Error in scheduler cycle:", error.message);
|
||||
} finally {
|
||||
isRunning = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Manually trigger a feed refresh
|
||||
* @param {object} application - Indiekit application
|
||||
* @param {string} feedId - Feed ID to refresh
|
||||
* @returns {Promise<object>} Processing result
|
||||
*/
|
||||
export async function refreshFeedNow(application, feedId) {
|
||||
const { getFeedById } = await import("../storage/feeds.js");
|
||||
const { processFeed } = await import("./processor.js");
|
||||
|
||||
const feed = await getFeedById(application, feedId);
|
||||
if (!feed) {
|
||||
throw new Error("Feed not found");
|
||||
}
|
||||
|
||||
return processFeed(application, feed);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get scheduler status
|
||||
* @returns {object} Scheduler status
|
||||
*/
|
||||
export function getSchedulerStatus() {
|
||||
return {
|
||||
running: !!schedulerInterval,
|
||||
processing: isRunning,
|
||||
};
|
||||
}
|
||||
139
lib/polling/tier.js
Normal file
139
lib/polling/tier.js
Normal file
@@ -0,0 +1,139 @@
|
||||
/**
|
||||
* Adaptive tier-based polling algorithm
|
||||
* Based on Ekster's approach: https://github.com/pstuifzand/ekster
|
||||
*
|
||||
* Tier determines poll interval: interval = 2^tier minutes
|
||||
* - Tier 0: Every minute (active/new feeds)
|
||||
* - Tier 1: Every 2 minutes
|
||||
* - Tier 2: Every 4 minutes
|
||||
* - Tier 3: Every 8 minutes
|
||||
* - Tier 4: Every 16 minutes
|
||||
* - Tier 5: Every 32 minutes
|
||||
* - Tier 6: Every 64 minutes (~1 hour)
|
||||
* - Tier 7: Every 128 minutes (~2 hours)
|
||||
* - Tier 8: Every 256 minutes (~4 hours)
|
||||
* - Tier 9: Every 512 minutes (~8 hours)
|
||||
* - Tier 10: Every 1024 minutes (~17 hours)
|
||||
*
|
||||
* @module polling/tier
|
||||
*/
|
||||
|
||||
const MIN_TIER = 0;
|
||||
const MAX_TIER = 10;
|
||||
const DEFAULT_TIER = 1;
|
||||
|
||||
/**
|
||||
* Get polling interval for a tier in milliseconds
|
||||
* @param {number} tier - Polling tier (0-10)
|
||||
* @returns {number} Interval in milliseconds
|
||||
*/
|
||||
export function getIntervalForTier(tier) {
|
||||
const clampedTier = Math.max(MIN_TIER, Math.min(MAX_TIER, tier));
|
||||
const minutes = Math.pow(2, clampedTier);
|
||||
return minutes * 60 * 1000;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get next fetch time based on tier
|
||||
* @param {number} tier - Polling tier
|
||||
* @returns {Date} Next fetch time
|
||||
*/
|
||||
export function getNextFetchTime(tier) {
|
||||
const interval = getIntervalForTier(tier);
|
||||
return new Date(Date.now() + interval);
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate new tier after a fetch
|
||||
* @param {object} options - Options
|
||||
* @param {number} options.currentTier - Current tier
|
||||
* @param {boolean} options.hasNewItems - Whether new items were found
|
||||
* @param {number} options.consecutiveUnchanged - Consecutive fetches with no changes
|
||||
* @returns {object} New tier and metadata
|
||||
*/
|
||||
export function calculateNewTier(options) {
|
||||
const {
|
||||
currentTier = DEFAULT_TIER,
|
||||
hasNewItems,
|
||||
consecutiveUnchanged = 0,
|
||||
} = options;
|
||||
|
||||
let newTier = currentTier;
|
||||
let newConsecutiveUnchanged = consecutiveUnchanged;
|
||||
|
||||
if (hasNewItems) {
|
||||
// Reset unchanged counter
|
||||
newConsecutiveUnchanged = 0;
|
||||
|
||||
// Decrease tier (more frequent) if we found new items
|
||||
if (currentTier > MIN_TIER) {
|
||||
newTier = currentTier - 1;
|
||||
}
|
||||
} else {
|
||||
// Increment unchanged counter
|
||||
newConsecutiveUnchanged = consecutiveUnchanged + 1;
|
||||
|
||||
// Increase tier (less frequent) after consecutive unchanged fetches
|
||||
// The threshold increases with tier to prevent thrashing
|
||||
const threshold = Math.max(2, currentTier);
|
||||
if (newConsecutiveUnchanged >= threshold && currentTier < MAX_TIER) {
|
||||
newTier = currentTier + 1;
|
||||
// Reset counter after tier change
|
||||
newConsecutiveUnchanged = 0;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
tier: newTier,
|
||||
consecutiveUnchanged: newConsecutiveUnchanged,
|
||||
nextFetchAt: getNextFetchTime(newTier),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get initial tier for a new feed subscription
|
||||
* @returns {object} Initial tier settings
|
||||
*/
|
||||
export function getInitialTier() {
|
||||
return {
|
||||
tier: MIN_TIER, // Start at tier 0 for immediate first fetch
|
||||
consecutiveUnchanged: 0,
|
||||
nextFetchAt: new Date(), // Fetch immediately
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine if a feed is due for fetching
|
||||
* @param {object} feed - Feed document
|
||||
* @returns {boolean} Whether the feed should be fetched
|
||||
*/
|
||||
export function isDueForFetch(feed) {
|
||||
if (!feed.nextFetchAt) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return new Date(feed.nextFetchAt) <= new Date();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get human-readable description of polling interval
|
||||
* @param {number} tier - Polling tier
|
||||
* @returns {string} Description
|
||||
*/
|
||||
export function getTierDescription(tier) {
|
||||
const minutes = Math.pow(2, tier);
|
||||
|
||||
if (minutes < 60) {
|
||||
return `every ${minutes} minute${minutes === 1 ? "" : "s"}`;
|
||||
}
|
||||
|
||||
const hours = minutes / 60;
|
||||
if (hours < 24) {
|
||||
return `every ${hours.toFixed(1)} hour${hours === 1 ? "" : "s"}`;
|
||||
}
|
||||
|
||||
const days = hours / 24;
|
||||
return `every ${days.toFixed(1)} day${days === 1 ? "" : "s"}`;
|
||||
}
|
||||
|
||||
export { MIN_TIER, MAX_TIER, DEFAULT_TIER };
|
||||
241
lib/realtime/broker.js
Normal file
241
lib/realtime/broker.js
Normal file
@@ -0,0 +1,241 @@
|
||||
/**
|
||||
* Server-Sent Events broker
|
||||
* Manages SSE connections and event distribution
|
||||
* @module realtime/broker
|
||||
*/
|
||||
|
||||
import { subscribeToChannel } from "../cache/redis.js";
|
||||
|
||||
/**
|
||||
* SSE Client connection
|
||||
* @typedef {object} SseClient
|
||||
* @property {object} response - Express response object
|
||||
* @property {string} userId - User ID
|
||||
* @property {Set<string>} channels - Subscribed channel IDs
|
||||
*/
|
||||
|
||||
/** @type {Map<object, SseClient>} */
|
||||
const clients = new Map();
|
||||
|
||||
/** @type {Map<string, object>} Map of userId to Redis subscriber */
|
||||
const userSubscribers = new Map();
|
||||
|
||||
const PING_INTERVAL = 10_000; // 10 seconds
|
||||
|
||||
/**
|
||||
* Add a client to the broker
|
||||
* @param {object} response - Express response object
|
||||
* @param {string} userId - User ID
|
||||
* @param {object} application - Indiekit application
|
||||
* @returns {object} Client object
|
||||
*/
|
||||
export function addClient(response, userId, application) {
|
||||
const client = {
|
||||
response,
|
||||
userId,
|
||||
channels: new Set(),
|
||||
pingInterval: setInterval(() => {
|
||||
sendEvent(response, "ping", { timestamp: new Date().toISOString() });
|
||||
}, PING_INTERVAL),
|
||||
};
|
||||
|
||||
clients.set(response, client);
|
||||
|
||||
// Set up Redis subscription for this user if not already done
|
||||
setupUserSubscription(userId, application);
|
||||
|
||||
return client;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a client from the broker
|
||||
* @param {object} response - Express response object
|
||||
*/
|
||||
export function removeClient(response) {
|
||||
const client = clients.get(response);
|
||||
if (client) {
|
||||
clearInterval(client.pingInterval);
|
||||
clients.delete(response);
|
||||
|
||||
// Check if any other clients for this user
|
||||
const hasOtherClients = [...clients.values()].some(
|
||||
(c) => c.userId === client.userId,
|
||||
);
|
||||
if (!hasOtherClients) {
|
||||
// Could clean up Redis subscription here if needed
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe a client to a channel
|
||||
* @param {object} response - Express response object
|
||||
* @param {string} channelId - Channel ID
|
||||
*/
|
||||
export function subscribeClient(response, channelId) {
|
||||
const client = clients.get(response);
|
||||
if (client) {
|
||||
client.channels.add(channelId);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Unsubscribe a client from a channel
|
||||
* @param {object} response - Express response object
|
||||
* @param {string} channelId - Channel ID
|
||||
*/
|
||||
export function unsubscribeClient(response, channelId) {
|
||||
const client = clients.get(response);
|
||||
if (client) {
|
||||
client.channels.delete(channelId);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send an event to a specific client
|
||||
* @param {object} response - Express response object
|
||||
* @param {string} event - Event name
|
||||
* @param {object} data - Event data
|
||||
*/
|
||||
export function sendEvent(response, event, data) {
|
||||
try {
|
||||
response.write(`event: ${event}\n`);
|
||||
response.write(`data: ${JSON.stringify(data)}\n\n`);
|
||||
} catch {
|
||||
// Client disconnected
|
||||
removeClient(response);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Broadcast an event to all clients subscribed to a channel
|
||||
* @param {string} channelId - Channel ID
|
||||
* @param {string} event - Event name
|
||||
* @param {object} data - Event data
|
||||
*/
|
||||
export function broadcastToChannel(channelId, event, data) {
|
||||
for (const client of clients.values()) {
|
||||
if (client.channels.has(channelId)) {
|
||||
sendEvent(client.response, event, data);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Broadcast an event to all clients for a user
|
||||
* @param {string} userId - User ID
|
||||
* @param {string} event - Event name
|
||||
* @param {object} data - Event data
|
||||
*/
|
||||
export function broadcastToUser(userId, event, data) {
|
||||
for (const client of clients.values()) {
|
||||
if (client.userId === userId) {
|
||||
sendEvent(client.response, event, data);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Broadcast an event to all connected clients
|
||||
* @param {string} event - Event name
|
||||
* @param {object} data - Event data
|
||||
*/
|
||||
export function broadcastToAll(event, data) {
|
||||
for (const client of clients.values()) {
|
||||
sendEvent(client.response, event, data);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set up Redis subscription for a user
|
||||
* @param {string} userId - User ID
|
||||
* @param {object} application - Indiekit application
|
||||
*/
|
||||
async function setupUserSubscription(userId, application) {
|
||||
if (userSubscribers.has(userId)) {
|
||||
return; // Already subscribed
|
||||
}
|
||||
|
||||
const redis = application.redis;
|
||||
if (!redis) {
|
||||
return; // No Redis, skip real-time
|
||||
}
|
||||
|
||||
// Create a duplicate connection for pub/sub
|
||||
const subscriber = redis.duplicate();
|
||||
userSubscribers.set(userId, subscriber);
|
||||
|
||||
try {
|
||||
await subscribeToChannel(subscriber, `microsub:user:${userId}`, (data) => {
|
||||
handleRedisEvent(userId, data);
|
||||
});
|
||||
} catch {
|
||||
// Subscription failed, remove from map
|
||||
userSubscribers.delete(userId);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle event received from Redis
|
||||
* @param {string} userId - User ID
|
||||
* @param {object} data - Event data
|
||||
*/
|
||||
function handleRedisEvent(userId, data) {
|
||||
const { type, channelId, ...eventData } = data;
|
||||
|
||||
switch (type) {
|
||||
case "new-item": {
|
||||
broadcastToUser(userId, "new-item", { channelId, ...eventData });
|
||||
break;
|
||||
}
|
||||
case "channel-update": {
|
||||
broadcastToUser(userId, "channel-update", { channelId, ...eventData });
|
||||
break;
|
||||
}
|
||||
case "unread-count": {
|
||||
broadcastToUser(userId, "unread-count", { channelId, ...eventData });
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
// Unknown event type, broadcast as generic event
|
||||
broadcastToUser(userId, type, data);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get broker statistics
|
||||
* @returns {object} Statistics
|
||||
*/
|
||||
export function getStats() {
|
||||
const userCounts = new Map();
|
||||
for (const client of clients.values()) {
|
||||
const count = userCounts.get(client.userId) || 0;
|
||||
userCounts.set(client.userId, count + 1);
|
||||
}
|
||||
|
||||
return {
|
||||
totalClients: clients.size,
|
||||
uniqueUsers: userCounts.size,
|
||||
userSubscribers: userSubscribers.size,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up all connections
|
||||
*/
|
||||
export function cleanup() {
|
||||
for (const client of clients.values()) {
|
||||
clearInterval(client.pingInterval);
|
||||
}
|
||||
clients.clear();
|
||||
|
||||
for (const subscriber of userSubscribers.values()) {
|
||||
try {
|
||||
subscriber.quit();
|
||||
} catch {
|
||||
// Ignore cleanup errors
|
||||
}
|
||||
}
|
||||
userSubscribers.clear();
|
||||
}
|
||||
90
lib/search/indexer.js
Normal file
90
lib/search/indexer.js
Normal file
@@ -0,0 +1,90 @@
|
||||
/**
|
||||
* Search indexer for MongoDB text search
|
||||
* @module search/indexer
|
||||
*/
|
||||
|
||||
/**
|
||||
* Create text indexes for microsub items
|
||||
* @param {object} application - Indiekit application
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
export async function createSearchIndexes(application) {
|
||||
const itemsCollection = application.collections.get("microsub_items");
|
||||
|
||||
// Create compound text index for full-text search
|
||||
await itemsCollection.createIndex(
|
||||
{
|
||||
name: "text",
|
||||
"content.text": "text",
|
||||
"content.html": "text",
|
||||
summary: "text",
|
||||
"author.name": "text",
|
||||
},
|
||||
{
|
||||
name: "text_search",
|
||||
weights: {
|
||||
name: 10,
|
||||
"content.text": 5,
|
||||
summary: 3,
|
||||
"author.name": 2,
|
||||
},
|
||||
default_language: "english",
|
||||
background: true,
|
||||
},
|
||||
);
|
||||
|
||||
// Create index for channel + published for efficient timeline queries
|
||||
await itemsCollection.createIndex(
|
||||
{ channelId: 1, published: -1 },
|
||||
{ name: "channel_timeline" },
|
||||
);
|
||||
|
||||
// Create index for deduplication
|
||||
await itemsCollection.createIndex(
|
||||
{ channelId: 1, uid: 1 },
|
||||
{ name: "channel_uid", unique: true },
|
||||
);
|
||||
|
||||
// Create index for feed-based queries
|
||||
await itemsCollection.createIndex({ feedId: 1 }, { name: "feed_items" });
|
||||
}
|
||||
|
||||
/**
|
||||
* Rebuild search indexes (drops and recreates)
|
||||
* @param {object} application - Indiekit application
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
export async function rebuildSearchIndexes(application) {
|
||||
const itemsCollection = application.collections.get("microsub_items");
|
||||
|
||||
// Drop existing text index
|
||||
try {
|
||||
await itemsCollection.dropIndex("text_search");
|
||||
} catch {
|
||||
// Index may not exist
|
||||
}
|
||||
|
||||
// Recreate indexes
|
||||
await createSearchIndexes(application);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get search index stats
|
||||
* @param {object} application - Indiekit application
|
||||
* @returns {Promise<object>} Index statistics
|
||||
*/
|
||||
export async function getSearchIndexStats(application) {
|
||||
const itemsCollection = application.collections.get("microsub_items");
|
||||
|
||||
const indexes = await itemsCollection.indexes();
|
||||
const stats = await itemsCollection.stats();
|
||||
|
||||
return {
|
||||
indexes: indexes.map((index) => ({
|
||||
name: index.name,
|
||||
key: index.key,
|
||||
})),
|
||||
totalDocuments: stats.count,
|
||||
size: stats.size,
|
||||
};
|
||||
}
|
||||
198
lib/search/query.js
Normal file
198
lib/search/query.js
Normal file
@@ -0,0 +1,198 @@
|
||||
/**
|
||||
* Search query module for full-text search
|
||||
* @module search/query
|
||||
*/
|
||||
|
||||
import { ObjectId } from "mongodb";
|
||||
|
||||
/**
|
||||
* Search items using MongoDB text search
|
||||
* @param {object} application - Indiekit application
|
||||
* @param {ObjectId|string} channelId - Channel ObjectId
|
||||
* @param {string} query - Search query string
|
||||
* @param {object} options - Search options
|
||||
* @param {number} [options.limit] - Max results (default 20)
|
||||
* @param {number} [options.skip] - Skip results for pagination
|
||||
* @param {boolean} [options.sortByScore] - Sort by relevance (default true)
|
||||
* @returns {Promise<Array>} Array of matching items
|
||||
*/
|
||||
export async function searchItemsFullText(
|
||||
application,
|
||||
channelId,
|
||||
query,
|
||||
options = {},
|
||||
) {
|
||||
const collection = application.collections.get("microsub_items");
|
||||
const { limit = 20, skip = 0, sortByScore = true } = options;
|
||||
|
||||
const channelObjectId =
|
||||
typeof channelId === "string" ? new ObjectId(channelId) : channelId;
|
||||
|
||||
// Build the search query
|
||||
const searchQuery = {
|
||||
channelId: channelObjectId,
|
||||
$text: { $search: query },
|
||||
};
|
||||
|
||||
// Build aggregation pipeline for scoring
|
||||
const pipeline = [
|
||||
{ $match: searchQuery },
|
||||
{ $addFields: { score: { $meta: "textScore" } } },
|
||||
];
|
||||
|
||||
if (sortByScore) {
|
||||
pipeline.push(
|
||||
{ $sort: { score: -1, published: -1 } },
|
||||
{ $skip: skip },
|
||||
{ $limit: limit },
|
||||
);
|
||||
} else {
|
||||
pipeline.push(
|
||||
{ $sort: { published: -1 } },
|
||||
{ $skip: skip },
|
||||
{ $limit: limit },
|
||||
);
|
||||
}
|
||||
|
||||
const items = await collection.aggregate(pipeline).toArray();
|
||||
|
||||
return items.map((item) => transformToSearchResult(item));
|
||||
}
|
||||
|
||||
/**
|
||||
* Search items using regex fallback (for partial matching)
|
||||
* @param {object} application - Indiekit application
|
||||
* @param {ObjectId|string} channelId - Channel ObjectId
|
||||
* @param {string} query - Search query string
|
||||
* @param {object} options - Search options
|
||||
* @returns {Promise<Array>} Array of matching items
|
||||
*/
|
||||
export async function searchItemsRegex(
|
||||
application,
|
||||
channelId,
|
||||
query,
|
||||
options = {},
|
||||
) {
|
||||
const collection = application.collections.get("microsub_items");
|
||||
const { limit = 20 } = options;
|
||||
|
||||
const channelObjectId =
|
||||
typeof channelId === "string" ? new ObjectId(channelId) : channelId;
|
||||
|
||||
// Escape regex special characters
|
||||
const escapedQuery = query.replaceAll(/[$()*+.?[\\\]^{|}]/g, String.raw`\$&`);
|
||||
const regex = new RegExp(escapedQuery, "i");
|
||||
|
||||
const items = await collection
|
||||
.find({
|
||||
channelId: channelObjectId,
|
||||
$or: [
|
||||
{ name: regex },
|
||||
{ "content.text": regex },
|
||||
{ "content.html": regex },
|
||||
{ summary: regex },
|
||||
{ "author.name": regex },
|
||||
],
|
||||
})
|
||||
// eslint-disable-next-line unicorn/no-array-sort -- MongoDB cursor method, not Array#sort
|
||||
.sort({ published: -1 })
|
||||
.limit(limit)
|
||||
.toArray();
|
||||
|
||||
return items.map((item) => transformToSearchResult(item));
|
||||
}
|
||||
|
||||
/**
|
||||
* Search with automatic fallback
|
||||
* Uses full-text search first, falls back to regex if no results
|
||||
* @param {object} application - Indiekit application
|
||||
* @param {ObjectId|string} channelId - Channel ObjectId
|
||||
* @param {string} query - Search query string
|
||||
* @param {object} options - Search options
|
||||
* @returns {Promise<Array>} Array of matching items
|
||||
*/
|
||||
export async function searchWithFallback(
|
||||
application,
|
||||
channelId,
|
||||
query,
|
||||
options = {},
|
||||
) {
|
||||
// Try full-text search first
|
||||
try {
|
||||
const results = await searchItemsFullText(
|
||||
application,
|
||||
channelId,
|
||||
query,
|
||||
options,
|
||||
);
|
||||
if (results.length > 0) {
|
||||
return results;
|
||||
}
|
||||
} catch {
|
||||
// Text index might not exist, fall through to regex
|
||||
}
|
||||
|
||||
// Fall back to regex search
|
||||
return searchItemsRegex(application, channelId, query, options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Transform database item to search result format
|
||||
* @param {object} item - Database item
|
||||
* @returns {object} Search result
|
||||
*/
|
||||
function transformToSearchResult(item) {
|
||||
const result = {
|
||||
type: item.type || "entry",
|
||||
uid: item.uid,
|
||||
url: item.url,
|
||||
published: item.published?.toISOString(),
|
||||
_id: item._id.toString(),
|
||||
};
|
||||
|
||||
if (item.name) result.name = item.name;
|
||||
if (item.content) result.content = item.content;
|
||||
if (item.summary) result.summary = item.summary;
|
||||
if (item.author) result.author = item.author;
|
||||
if (item.photo?.length > 0) result.photo = item.photo;
|
||||
if (item.score) result._score = item.score;
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get search suggestions (autocomplete)
|
||||
* @param {object} application - Indiekit application
|
||||
* @param {ObjectId|string} channelId - Channel ObjectId
|
||||
* @param {string} prefix - Search prefix
|
||||
* @param {number} limit - Max suggestions
|
||||
* @returns {Promise<Array>} Array of suggestions
|
||||
*/
|
||||
export async function getSearchSuggestions(
|
||||
application,
|
||||
channelId,
|
||||
prefix,
|
||||
limit = 5,
|
||||
) {
|
||||
const collection = application.collections.get("microsub_items");
|
||||
|
||||
const channelObjectId =
|
||||
typeof channelId === "string" ? new ObjectId(channelId) : channelId;
|
||||
|
||||
const escapedPrefix = prefix.replaceAll(
|
||||
/[$()*+.?[\\\]^{|}]/g,
|
||||
String.raw`\$&`,
|
||||
);
|
||||
const regex = new RegExp(`^${escapedPrefix}`, "i");
|
||||
|
||||
// Get unique names/titles that match prefix
|
||||
const results = await collection
|
||||
.aggregate([
|
||||
{ $match: { channelId: channelObjectId, name: regex } },
|
||||
{ $group: { _id: "$name" } },
|
||||
{ $limit: limit },
|
||||
])
|
||||
.toArray();
|
||||
|
||||
return results.map((r) => r._id).filter(Boolean);
|
||||
}
|
||||
@@ -3,7 +3,12 @@
|
||||
* @module storage/channels
|
||||
*/
|
||||
|
||||
import { generateChannelUid } from "../utils/uid.js";
|
||||
import { ObjectId } from "mongodb";
|
||||
|
||||
import { generateChannelUid } from "../utils/jf2.js";
|
||||
|
||||
import { deleteFeedsForChannel } from "./feeds.js";
|
||||
import { deleteItemsForChannel } from "./items.js";
|
||||
|
||||
/**
|
||||
* Get channels collection from application
|
||||
@@ -53,7 +58,7 @@ export async function createChannel(application, { name, userId }) {
|
||||
// Get max order for user
|
||||
const maxOrderResult = await collection
|
||||
.find({ userId })
|
||||
// eslint-disable-next-line unicorn/no-array-sort -- MongoDB cursor method
|
||||
// eslint-disable-next-line unicorn/no-array-sort -- MongoDB cursor method, not Array#sort
|
||||
.sort({ order: -1 })
|
||||
.limit(1)
|
||||
.toArray();
|
||||
@@ -65,6 +70,10 @@ export async function createChannel(application, { name, userId }) {
|
||||
name,
|
||||
userId,
|
||||
order,
|
||||
settings: {
|
||||
excludeTypes: [],
|
||||
excludeRegex: undefined,
|
||||
},
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
@@ -85,8 +94,12 @@ export async function getChannels(application, userId) {
|
||||
const itemsCollection = getItemsCollection(application);
|
||||
|
||||
const filter = userId ? { userId } : {};
|
||||
// eslint-disable-next-line unicorn/no-array-callback-reference, unicorn/no-array-sort -- MongoDB methods
|
||||
const channels = await collection.find(filter).sort({ order: 1 }).toArray();
|
||||
const channels = await collection
|
||||
// eslint-disable-next-line unicorn/no-array-callback-reference -- filter is MongoDB query object
|
||||
.find(filter)
|
||||
// eslint-disable-next-line unicorn/no-array-sort -- MongoDB cursor method, not Array#sort
|
||||
.sort({ order: 1 })
|
||||
.toArray();
|
||||
|
||||
// Get unread counts for each channel
|
||||
const channelsWithCounts = await Promise.all(
|
||||
@@ -134,6 +147,18 @@ export async function getChannel(application, uid, userId) {
|
||||
return collection.findOne(query);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get channel by MongoDB ObjectId
|
||||
* @param {object} application - Indiekit application
|
||||
* @param {ObjectId|string} id - Channel ObjectId
|
||||
* @returns {Promise<object|null>} Channel or null
|
||||
*/
|
||||
export async function getChannelById(application, id) {
|
||||
const collection = getCollection(application);
|
||||
const objectId = typeof id === "string" ? new ObjectId(id) : id;
|
||||
return collection.findOne({ _id: objectId });
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a channel
|
||||
* @param {object} application - Indiekit application
|
||||
@@ -162,7 +187,7 @@ export async function updateChannel(application, uid, updates, userId) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a channel and all its items
|
||||
* Delete a channel and all its feeds and items
|
||||
* @param {object} application - Indiekit application
|
||||
* @param {string} uid - Channel UID
|
||||
* @param {string} [userId] - User ID
|
||||
@@ -170,7 +195,6 @@ export async function updateChannel(application, uid, updates, userId) {
|
||||
*/
|
||||
export async function deleteChannel(application, uid, userId) {
|
||||
const collection = getCollection(application);
|
||||
const itemsCollection = getItemsCollection(application);
|
||||
const query = { uid };
|
||||
if (userId) query.userId = userId;
|
||||
|
||||
@@ -185,12 +209,11 @@ export async function deleteChannel(application, uid, userId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Delete all items in channel
|
||||
const itemsDeleted = await itemsCollection.deleteMany({
|
||||
channelId: channel._id,
|
||||
});
|
||||
// Cascade delete: items first, then feeds, then channel
|
||||
const itemsDeleted = await deleteItemsForChannel(application, channel._id);
|
||||
const feedsDeleted = await deleteFeedsForChannel(application, channel._id);
|
||||
console.info(
|
||||
`[Microsub] Deleted channel ${uid}: ${itemsDeleted.deletedCount} items`,
|
||||
`[Microsub] Deleted channel ${uid}: ${feedsDeleted} feeds, ${itemsDeleted} items`,
|
||||
);
|
||||
|
||||
const result = await collection.deleteOne({ _id: channel._id });
|
||||
@@ -220,6 +243,25 @@ export async function reorderChannels(application, channelUids, userId) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update channel settings
|
||||
* @param {object} application - Indiekit application
|
||||
* @param {string} uid - Channel UID
|
||||
* @param {object} settings - Settings to update
|
||||
* @param {Array} [settings.excludeTypes] - Types to exclude
|
||||
* @param {string} [settings.excludeRegex] - Regex pattern to exclude
|
||||
* @param {string} [userId] - User ID
|
||||
* @returns {Promise<object|null>} Updated channel
|
||||
*/
|
||||
export async function updateChannelSettings(
|
||||
application,
|
||||
uid,
|
||||
settings,
|
||||
userId,
|
||||
) {
|
||||
return updateChannel(application, uid, { settings }, userId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure notifications channel exists
|
||||
* @param {object} application - Indiekit application
|
||||
@@ -244,6 +286,10 @@ export async function ensureNotificationsChannel(application, userId) {
|
||||
name: "Notifications",
|
||||
userId,
|
||||
order: -1, // Always first
|
||||
settings: {
|
||||
excludeTypes: [],
|
||||
excludeRegex: undefined,
|
||||
},
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
|
||||
299
lib/storage/feeds.js
Normal file
299
lib/storage/feeds.js
Normal file
@@ -0,0 +1,299 @@
|
||||
/**
|
||||
* Feed subscription storage operations
|
||||
* @module storage/feeds
|
||||
*/
|
||||
|
||||
import { ObjectId } from "mongodb";
|
||||
|
||||
import { deleteItemsForFeed } from "./items.js";
|
||||
|
||||
/**
|
||||
* Get feeds collection from application
|
||||
* @param {object} application - Indiekit application
|
||||
* @returns {object} MongoDB collection
|
||||
*/
|
||||
function getCollection(application) {
|
||||
return application.collections.get("microsub_feeds");
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new feed subscription
|
||||
* @param {object} application - Indiekit application
|
||||
* @param {object} data - Feed data
|
||||
* @param {ObjectId} data.channelId - Channel ObjectId
|
||||
* @param {string} data.url - Feed URL
|
||||
* @param {string} [data.title] - Feed title
|
||||
* @param {string} [data.photo] - Feed icon URL
|
||||
* @returns {Promise<object>} Created feed
|
||||
*/
|
||||
export async function createFeed(
|
||||
application,
|
||||
{ channelId, url, title, photo },
|
||||
) {
|
||||
const collection = getCollection(application);
|
||||
|
||||
// Check if feed already exists in channel
|
||||
const existing = await collection.findOne({ channelId, url });
|
||||
if (existing) {
|
||||
return existing;
|
||||
}
|
||||
|
||||
const feed = {
|
||||
channelId,
|
||||
url,
|
||||
title: title || undefined,
|
||||
photo: photo || undefined,
|
||||
tier: 1, // Start at tier 1 (2 minutes)
|
||||
unmodified: 0,
|
||||
nextFetchAt: new Date(), // Fetch immediately
|
||||
lastFetchedAt: undefined,
|
||||
websub: undefined, // Will be populated if hub is discovered
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
|
||||
await collection.insertOne(feed);
|
||||
return feed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all feeds for a channel
|
||||
* @param {object} application - Indiekit application
|
||||
* @param {ObjectId|string} channelId - Channel ObjectId
|
||||
* @returns {Promise<Array>} Array of feeds
|
||||
*/
|
||||
export async function getFeedsForChannel(application, channelId) {
|
||||
const collection = getCollection(application);
|
||||
const objectId =
|
||||
typeof channelId === "string" ? new ObjectId(channelId) : channelId;
|
||||
|
||||
return collection.find({ channelId: objectId }).toArray();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a feed by URL and channel
|
||||
* @param {object} application - Indiekit application
|
||||
* @param {ObjectId|string} channelId - Channel ObjectId
|
||||
* @param {string} url - Feed URL
|
||||
* @returns {Promise<object|null>} Feed or null
|
||||
*/
|
||||
export async function getFeedByUrl(application, channelId, url) {
|
||||
const collection = getCollection(application);
|
||||
const objectId =
|
||||
typeof channelId === "string" ? new ObjectId(channelId) : channelId;
|
||||
|
||||
return collection.findOne({ channelId: objectId, url });
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a feed by ID
|
||||
* @param {object} application - Indiekit application
|
||||
* @param {ObjectId|string} id - Feed ObjectId
|
||||
* @returns {Promise<object|null>} Feed or null
|
||||
*/
|
||||
export async function getFeedById(application, id) {
|
||||
const collection = getCollection(application);
|
||||
const objectId = typeof id === "string" ? new ObjectId(id) : id;
|
||||
|
||||
return collection.findOne({ _id: objectId });
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a feed
|
||||
* @param {object} application - Indiekit application
|
||||
* @param {ObjectId|string} id - Feed ObjectId
|
||||
* @param {object} updates - Fields to update
|
||||
* @returns {Promise<object|null>} Updated feed
|
||||
*/
|
||||
export async function updateFeed(application, id, updates) {
|
||||
const collection = getCollection(application);
|
||||
const objectId = typeof id === "string" ? new ObjectId(id) : id;
|
||||
|
||||
const result = await collection.findOneAndUpdate(
|
||||
{ _id: objectId },
|
||||
{
|
||||
$set: {
|
||||
...updates,
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
},
|
||||
{ returnDocument: "after" },
|
||||
);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a feed subscription and all its items
|
||||
* @param {object} application - Indiekit application
|
||||
* @param {ObjectId|string} channelId - Channel ObjectId
|
||||
* @param {string} url - Feed URL
|
||||
* @returns {Promise<boolean>} True if deleted
|
||||
*/
|
||||
export async function deleteFeed(application, channelId, url) {
|
||||
const collection = getCollection(application);
|
||||
const objectId =
|
||||
typeof channelId === "string" ? new ObjectId(channelId) : channelId;
|
||||
|
||||
// Find the feed first to get its ID for cascade delete
|
||||
const feed = await collection.findOne({ channelId: objectId, url });
|
||||
if (!feed) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Delete all items from this feed
|
||||
const itemsDeleted = await deleteItemsForFeed(application, feed._id);
|
||||
console.info(`[Microsub] Deleted ${itemsDeleted} items from feed ${url}`);
|
||||
|
||||
// Delete the feed itself
|
||||
const result = await collection.deleteOne({ _id: feed._id });
|
||||
return result.deletedCount > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete all feeds for a channel
|
||||
* @param {object} application - Indiekit application
|
||||
* @param {ObjectId|string} channelId - Channel ObjectId
|
||||
* @returns {Promise<number>} Number of deleted feeds
|
||||
*/
|
||||
export async function deleteFeedsForChannel(application, channelId) {
|
||||
const collection = getCollection(application);
|
||||
const objectId =
|
||||
typeof channelId === "string" ? new ObjectId(channelId) : channelId;
|
||||
|
||||
const result = await collection.deleteMany({ channelId: objectId });
|
||||
return result.deletedCount;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get feeds ready for polling
|
||||
* @param {object} application - Indiekit application
|
||||
* @returns {Promise<Array>} Array of feeds to fetch
|
||||
*/
|
||||
export async function getFeedsToFetch(application) {
|
||||
const collection = getCollection(application);
|
||||
const now = new Date();
|
||||
|
||||
return collection
|
||||
.find({
|
||||
$or: [{ nextFetchAt: undefined }, { nextFetchAt: { $lte: now } }],
|
||||
})
|
||||
.toArray();
|
||||
}
|
||||
|
||||
/**
|
||||
* Update feed after fetch
|
||||
* @param {object} application - Indiekit application
|
||||
* @param {ObjectId|string} id - Feed ObjectId
|
||||
* @param {boolean} changed - Whether content changed
|
||||
* @param {object} [extra] - Additional fields to update
|
||||
* @returns {Promise<object|null>} Updated feed
|
||||
*/
|
||||
export async function updateFeedAfterFetch(
|
||||
application,
|
||||
id,
|
||||
changed,
|
||||
extra = {},
|
||||
) {
|
||||
const collection = getCollection(application);
|
||||
const objectId = typeof id === "string" ? new ObjectId(id) : id;
|
||||
|
||||
// If extra contains tier info, use that (from processor)
|
||||
// Otherwise calculate locally (legacy behavior)
|
||||
let updateData;
|
||||
|
||||
if (extra.tier === undefined) {
|
||||
// Get current feed state for legacy calculation
|
||||
const feed = await collection.findOne({ _id: objectId });
|
||||
if (!feed) return;
|
||||
|
||||
let tier = feed.tier;
|
||||
let unmodified = feed.unmodified;
|
||||
|
||||
if (changed) {
|
||||
tier = Math.max(0, tier - 1);
|
||||
unmodified = 0;
|
||||
} else {
|
||||
unmodified++;
|
||||
if (unmodified >= 2) {
|
||||
tier = Math.min(10, tier + 1);
|
||||
unmodified = 0;
|
||||
}
|
||||
}
|
||||
|
||||
const minutes = Math.ceil(Math.pow(2, tier));
|
||||
const nextFetchAt = new Date(Date.now() + minutes * 60 * 1000);
|
||||
|
||||
updateData = {
|
||||
tier,
|
||||
unmodified,
|
||||
nextFetchAt,
|
||||
lastFetchedAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
} else {
|
||||
updateData = {
|
||||
...extra,
|
||||
lastFetchedAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
}
|
||||
|
||||
return collection.findOneAndUpdate(
|
||||
{ _id: objectId },
|
||||
{ $set: updateData },
|
||||
{ returnDocument: "after" },
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update feed WebSub subscription
|
||||
* @param {object} application - Indiekit application
|
||||
* @param {ObjectId|string} id - Feed ObjectId
|
||||
* @param {object} websub - WebSub data
|
||||
* @param {string} websub.hub - Hub URL
|
||||
* @param {string} [websub.topic] - Feed topic URL
|
||||
* @param {string} [websub.secret] - Subscription secret
|
||||
* @param {number} [websub.leaseSeconds] - Lease duration
|
||||
* @returns {Promise<object|null>} Updated feed
|
||||
*/
|
||||
export async function updateFeedWebsub(application, id, websub) {
|
||||
const collection = getCollection(application);
|
||||
const objectId = typeof id === "string" ? new ObjectId(id) : id;
|
||||
|
||||
const websubData = {
|
||||
hub: websub.hub,
|
||||
topic: websub.topic,
|
||||
};
|
||||
|
||||
// Only set these if provided (subscription confirmed)
|
||||
if (websub.secret) {
|
||||
websubData.secret = websub.secret;
|
||||
}
|
||||
if (websub.leaseSeconds) {
|
||||
websubData.leaseSeconds = websub.leaseSeconds;
|
||||
websubData.expiresAt = new Date(Date.now() + websub.leaseSeconds * 1000);
|
||||
}
|
||||
|
||||
return collection.findOneAndUpdate(
|
||||
{ _id: objectId },
|
||||
{
|
||||
$set: {
|
||||
websub: websubData,
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
},
|
||||
{ returnDocument: "after" },
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get feed by WebSub subscription ID
|
||||
* Used for WebSub callback handling
|
||||
* @param {object} application - Indiekit application
|
||||
* @param {string} subscriptionId - Subscription ID (feed ObjectId as string)
|
||||
* @returns {Promise<object|null>} Feed or null
|
||||
*/
|
||||
export async function getFeedBySubscriptionId(application, subscriptionId) {
|
||||
return getFeedById(application, subscriptionId);
|
||||
}
|
||||
265
lib/storage/filters.js
Normal file
265
lib/storage/filters.js
Normal file
@@ -0,0 +1,265 @@
|
||||
/**
|
||||
* Filter storage operations (mute, block, channel filters)
|
||||
* @module storage/filters
|
||||
*/
|
||||
|
||||
import { ObjectId } from "mongodb";
|
||||
|
||||
/**
|
||||
* Get muted collection
|
||||
* @param {object} application - Indiekit application
|
||||
* @returns {object} MongoDB collection
|
||||
*/
|
||||
function getMutedCollection(application) {
|
||||
return application.collections.get("microsub_muted");
|
||||
}
|
||||
|
||||
/**
|
||||
* Get blocked collection
|
||||
* @param {object} application - Indiekit application
|
||||
* @returns {object} MongoDB collection
|
||||
*/
|
||||
function getBlockedCollection(application) {
|
||||
return application.collections.get("microsub_blocked");
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a URL is muted for a user/channel
|
||||
* @param {object} application - Indiekit application
|
||||
* @param {string} userId - User ID
|
||||
* @param {ObjectId|string} channelId - Channel ObjectId
|
||||
* @param {string} url - URL to check
|
||||
* @returns {Promise<boolean>} Whether the URL is muted
|
||||
*/
|
||||
export async function isMuted(application, userId, channelId, url) {
|
||||
const collection = getMutedCollection(application);
|
||||
const channelObjectId =
|
||||
typeof channelId === "string" ? new ObjectId(channelId) : channelId;
|
||||
|
||||
// Check for channel-specific mute
|
||||
const channelMute = await collection.findOne({
|
||||
userId,
|
||||
channelId: channelObjectId,
|
||||
url,
|
||||
});
|
||||
if (channelMute) return true;
|
||||
|
||||
// Check for global mute (no channelId)
|
||||
const globalMute = await collection.findOne({
|
||||
userId,
|
||||
channelId: { $exists: false },
|
||||
url,
|
||||
});
|
||||
return !!globalMute;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a URL is blocked for a user
|
||||
* @param {object} application - Indiekit application
|
||||
* @param {string} userId - User ID
|
||||
* @param {string} url - URL to check
|
||||
* @returns {Promise<boolean>} Whether the URL is blocked
|
||||
*/
|
||||
export async function isBlocked(application, userId, url) {
|
||||
const collection = getBlockedCollection(application);
|
||||
const blocked = await collection.findOne({ userId, url });
|
||||
return !!blocked;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an item passes all filters
|
||||
* @param {object} application - Indiekit application
|
||||
* @param {string} userId - User ID
|
||||
* @param {object} channel - Channel document with settings
|
||||
* @param {object} item - Feed item to check
|
||||
* @returns {Promise<boolean>} Whether the item passes all filters
|
||||
*/
|
||||
export async function passesAllFilters(application, userId, channel, item) {
|
||||
// Check if author URL is blocked
|
||||
if (
|
||||
item.author?.url &&
|
||||
(await isBlocked(application, userId, item.author.url))
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check if source URL is muted
|
||||
if (
|
||||
item._source?.url &&
|
||||
(await isMuted(application, userId, channel._id, item._source.url))
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check channel settings filters
|
||||
if (channel?.settings) {
|
||||
// Check excludeTypes
|
||||
if (!passesTypeFilter(item, channel.settings)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check excludeRegex
|
||||
if (!passesRegexFilter(item, channel.settings)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an item passes the excludeTypes filter
|
||||
* @param {object} item - Feed item
|
||||
* @param {object} settings - Channel settings
|
||||
* @returns {boolean} Whether the item passes
|
||||
*/
|
||||
export function passesTypeFilter(item, settings) {
|
||||
if (!settings.excludeTypes || settings.excludeTypes.length === 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const itemType = detectInteractionType(item);
|
||||
return !settings.excludeTypes.includes(itemType);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an item passes the excludeRegex filter
|
||||
* @param {object} item - Feed item
|
||||
* @param {object} settings - Channel settings
|
||||
* @returns {boolean} Whether the item passes
|
||||
*/
|
||||
export function passesRegexFilter(item, settings) {
|
||||
if (!settings.excludeRegex) {
|
||||
return true;
|
||||
}
|
||||
|
||||
try {
|
||||
const regex = new RegExp(settings.excludeRegex, "i");
|
||||
const searchText = [
|
||||
item.name,
|
||||
item.summary,
|
||||
item.content?.text,
|
||||
item.content?.html,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ");
|
||||
|
||||
return !regex.test(searchText);
|
||||
} catch {
|
||||
// Invalid regex, skip filter
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect the interaction type of an item
|
||||
* @param {object} item - Feed item
|
||||
* @returns {string} Interaction type
|
||||
*/
|
||||
export function detectInteractionType(item) {
|
||||
if (item["like-of"] && item["like-of"].length > 0) {
|
||||
return "like";
|
||||
}
|
||||
if (item["repost-of"] && item["repost-of"].length > 0) {
|
||||
return "repost";
|
||||
}
|
||||
if (item["bookmark-of"] && item["bookmark-of"].length > 0) {
|
||||
return "bookmark";
|
||||
}
|
||||
if (item["in-reply-to"] && item["in-reply-to"].length > 0) {
|
||||
return "reply";
|
||||
}
|
||||
if (item.rsvp) {
|
||||
return "rsvp";
|
||||
}
|
||||
if (item.checkin) {
|
||||
return "checkin";
|
||||
}
|
||||
|
||||
return "post";
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all muted URLs for a user/channel
|
||||
* @param {object} application - Indiekit application
|
||||
* @param {string} userId - User ID
|
||||
* @param {ObjectId|string} [channelId] - Channel ObjectId (optional, for channel-specific)
|
||||
* @returns {Promise<Array>} Array of muted URLs
|
||||
*/
|
||||
export async function getMutedUrls(application, userId, channelId) {
|
||||
const collection = getMutedCollection(application);
|
||||
const filter = { userId };
|
||||
|
||||
if (channelId) {
|
||||
const channelObjectId =
|
||||
typeof channelId === "string" ? new ObjectId(channelId) : channelId;
|
||||
filter.channelId = channelObjectId;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line unicorn/no-array-callback-reference -- filter is MongoDB query object
|
||||
const muted = await collection.find(filter).toArray();
|
||||
return muted.map((m) => m.url);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all blocked URLs for a user
|
||||
* @param {object} application - Indiekit application
|
||||
* @param {string} userId - User ID
|
||||
* @returns {Promise<Array>} Array of blocked URLs
|
||||
*/
|
||||
export async function getBlockedUrls(application, userId) {
|
||||
const collection = getBlockedCollection(application);
|
||||
const blocked = await collection.find({ userId }).toArray();
|
||||
return blocked.map((b) => b.url);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update channel filter settings
|
||||
* @param {object} application - Indiekit application
|
||||
* @param {ObjectId|string} channelId - Channel ObjectId
|
||||
* @param {object} filters - Filter settings to update
|
||||
* @param {Array} [filters.excludeTypes] - Post types to exclude
|
||||
* @param {string} [filters.excludeRegex] - Regex pattern to exclude
|
||||
* @returns {Promise<object>} Updated channel
|
||||
*/
|
||||
export async function updateChannelFilters(application, channelId, filters) {
|
||||
const collection = application.collections.get("microsub_channels");
|
||||
const channelObjectId =
|
||||
typeof channelId === "string" ? new ObjectId(channelId) : channelId;
|
||||
|
||||
const updateFields = {};
|
||||
|
||||
if (filters.excludeTypes !== undefined) {
|
||||
updateFields["settings.excludeTypes"] = filters.excludeTypes;
|
||||
}
|
||||
|
||||
if (filters.excludeRegex !== undefined) {
|
||||
updateFields["settings.excludeRegex"] = filters.excludeRegex;
|
||||
}
|
||||
|
||||
const result = await collection.findOneAndUpdate(
|
||||
{ _id: channelObjectId },
|
||||
{ $set: updateFields },
|
||||
{ returnDocument: "after" },
|
||||
);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create indexes for filter collections
|
||||
* @param {object} application - Indiekit application
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
export async function createFilterIndexes(application) {
|
||||
const mutedCollection = getMutedCollection(application);
|
||||
const blockedCollection = getBlockedCollection(application);
|
||||
|
||||
// Muted collection indexes
|
||||
await mutedCollection.createIndex({ userId: 1, channelId: 1, url: 1 });
|
||||
await mutedCollection.createIndex({ userId: 1 });
|
||||
|
||||
// Blocked collection indexes
|
||||
await blockedCollection.createIndex({ userId: 1, url: 1 }, { unique: true });
|
||||
await blockedCollection.createIndex({ userId: 1 });
|
||||
}
|
||||
@@ -21,6 +21,54 @@ function getCollection(application) {
|
||||
return application.collections.get("microsub_items");
|
||||
}
|
||||
|
||||
/**
|
||||
* Add an item to a channel
|
||||
* @param {object} application - Indiekit application
|
||||
* @param {object} data - Item data
|
||||
* @param {ObjectId} data.channelId - Channel ObjectId
|
||||
* @param {ObjectId} data.feedId - Feed ObjectId
|
||||
* @param {string} data.uid - Unique item identifier
|
||||
* @param {object} data.item - jf2 item data
|
||||
* @returns {Promise<object|null>} Created item or null if duplicate
|
||||
*/
|
||||
export async function addItem(application, { channelId, feedId, uid, item }) {
|
||||
const collection = getCollection(application);
|
||||
|
||||
// Check for duplicate
|
||||
const existing = await collection.findOne({ channelId, uid });
|
||||
if (existing) {
|
||||
return; // Duplicate, don't add
|
||||
}
|
||||
|
||||
const document = {
|
||||
channelId,
|
||||
feedId,
|
||||
uid,
|
||||
type: item.type || "entry",
|
||||
url: item.url,
|
||||
name: item.name || undefined,
|
||||
content: item.content || undefined,
|
||||
summary: item.summary || undefined,
|
||||
published: item.published ? new Date(item.published) : new Date(),
|
||||
updated: item.updated ? new Date(item.updated) : undefined,
|
||||
author: item.author || undefined,
|
||||
category: item.category || [],
|
||||
photo: item.photo || [],
|
||||
video: item.video || [],
|
||||
audio: item.audio || [],
|
||||
likeOf: item["like-of"] || item.likeOf || [],
|
||||
repostOf: item["repost-of"] || item.repostOf || [],
|
||||
bookmarkOf: item["bookmark-of"] || item.bookmarkOf || [],
|
||||
inReplyTo: item["in-reply-to"] || item.inReplyTo || [],
|
||||
source: item._source || undefined,
|
||||
readBy: [], // Array of user IDs who have read this item
|
||||
createdAt: new Date(),
|
||||
};
|
||||
|
||||
await collection.insertOne(document);
|
||||
return document;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get timeline items for a channel
|
||||
* @param {object} application - Indiekit application
|
||||
@@ -39,7 +87,6 @@ export async function getTimelineItems(application, channelId, options = {}) {
|
||||
const limit = parseLimit(options.limit);
|
||||
|
||||
const baseQuery = { channelId: objectId };
|
||||
|
||||
const query = buildPaginationQuery({
|
||||
before: options.before,
|
||||
after: options.after,
|
||||
@@ -50,9 +97,9 @@ export async function getTimelineItems(application, channelId, options = {}) {
|
||||
|
||||
// Fetch one extra to check if there are more
|
||||
const items = await collection
|
||||
// eslint-disable-next-line unicorn/no-array-callback-reference -- MongoDB query object
|
||||
// eslint-disable-next-line unicorn/no-array-callback-reference -- query is MongoDB query object
|
||||
.find(query)
|
||||
// eslint-disable-next-line unicorn/no-array-sort -- MongoDB cursor method
|
||||
// eslint-disable-next-line unicorn/no-array-sort -- MongoDB cursor method, not Array#sort
|
||||
.sort(sort)
|
||||
.limit(limit + 1)
|
||||
.toArray();
|
||||
@@ -74,6 +121,50 @@ export async function getTimelineItems(application, channelId, options = {}) {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract URL string from a media value
|
||||
* @param {object|string} media - Media value (can be string URL or object)
|
||||
* @returns {string|undefined} URL string
|
||||
*/
|
||||
function extractMediaUrl(media) {
|
||||
if (!media) {
|
||||
return;
|
||||
}
|
||||
if (typeof media === "string") {
|
||||
return media;
|
||||
}
|
||||
if (typeof media === "object") {
|
||||
return media.value || media.url || media.src;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize media array to URL strings
|
||||
* @param {Array} mediaArray - Array of media items
|
||||
* @returns {Array} Array of URL strings
|
||||
*/
|
||||
function normalizeMediaArray(mediaArray) {
|
||||
if (!mediaArray || !Array.isArray(mediaArray)) {
|
||||
return [];
|
||||
}
|
||||
return mediaArray.map((media) => extractMediaUrl(media)).filter(Boolean);
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize author object to ensure photo is a URL string
|
||||
* @param {object} author - Author object
|
||||
* @returns {object} Normalized author
|
||||
*/
|
||||
function normalizeAuthor(author) {
|
||||
if (!author) {
|
||||
return;
|
||||
}
|
||||
return {
|
||||
...author,
|
||||
photo: extractMediaUrl(author.photo),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Transform database item to jf2 format
|
||||
* @param {object} item - Database item
|
||||
@@ -95,11 +186,17 @@ function transformToJf2(item, userId) {
|
||||
if (item.content) jf2.content = item.content;
|
||||
if (item.summary) jf2.summary = item.summary;
|
||||
if (item.updated) jf2.updated = item.updated.toISOString();
|
||||
if (item.author) jf2.author = item.author;
|
||||
if (item.author) jf2.author = normalizeAuthor(item.author);
|
||||
if (item.category?.length > 0) jf2.category = item.category;
|
||||
if (item.photo?.length > 0) jf2.photo = item.photo;
|
||||
if (item.video?.length > 0) jf2.video = item.video;
|
||||
if (item.audio?.length > 0) jf2.audio = item.audio;
|
||||
|
||||
// Normalize media arrays to ensure they contain URL strings
|
||||
const photos = normalizeMediaArray(item.photo);
|
||||
const videos = normalizeMediaArray(item.video);
|
||||
const audios = normalizeMediaArray(item.audio);
|
||||
|
||||
if (photos.length > 0) jf2.photo = photos;
|
||||
if (videos.length > 0) jf2.video = videos;
|
||||
if (audios.length > 0) jf2.audio = audios;
|
||||
|
||||
// Interaction types
|
||||
if (item.likeOf?.length > 0) jf2["like-of"] = item.likeOf;
|
||||
@@ -113,11 +210,57 @@ function transformToJf2(item, userId) {
|
||||
return jf2;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get an item by ID (MongoDB _id or uid)
|
||||
* @param {object} application - Indiekit application
|
||||
* @param {ObjectId|string} id - Item ObjectId or uid string
|
||||
* @param {string} [userId] - User ID for read state
|
||||
* @returns {Promise<object|undefined>} jf2 item or undefined
|
||||
*/
|
||||
export async function getItemById(application, id, userId) {
|
||||
const collection = getCollection(application);
|
||||
|
||||
let item;
|
||||
|
||||
// Try MongoDB ObjectId first
|
||||
try {
|
||||
const objectId = typeof id === "string" ? new ObjectId(id) : id;
|
||||
item = await collection.findOne({ _id: objectId });
|
||||
} catch {
|
||||
// Invalid ObjectId format, will try uid lookup
|
||||
}
|
||||
|
||||
// If not found by _id, try uid
|
||||
if (!item) {
|
||||
item = await collection.findOne({ uid: id });
|
||||
}
|
||||
|
||||
if (!item) {
|
||||
return;
|
||||
}
|
||||
|
||||
return transformToJf2(item, userId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get items by UIDs
|
||||
* @param {object} application - Indiekit application
|
||||
* @param {Array} uids - Array of item UIDs
|
||||
* @param {string} [userId] - User ID for read state
|
||||
* @returns {Promise<Array>} Array of jf2 items
|
||||
*/
|
||||
export async function getItemsByUids(application, uids, userId) {
|
||||
const collection = getCollection(application);
|
||||
|
||||
const items = await collection.find({ uid: { $in: uids } }).toArray();
|
||||
return items.map((item) => transformToJf2(item, userId));
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark items as read
|
||||
* @param {object} application - Indiekit application
|
||||
* @param {ObjectId|string} channelId - Channel ObjectId
|
||||
* @param {Array} entryIds - Array of entry IDs to mark as read
|
||||
* @param {Array} entryIds - Array of entry IDs to mark as read (can be ObjectId, uid, or URL)
|
||||
* @param {string} userId - User ID
|
||||
* @returns {Promise<number>} Number of items updated
|
||||
*/
|
||||
@@ -126,12 +269,22 @@ export async function markItemsRead(application, channelId, entryIds, userId) {
|
||||
const channelObjectId =
|
||||
typeof channelId === "string" ? new ObjectId(channelId) : channelId;
|
||||
|
||||
console.info(
|
||||
`[Microsub] markItemsRead called for channel ${channelId}, entries:`,
|
||||
entryIds,
|
||||
`userId: ${userId}`,
|
||||
);
|
||||
|
||||
// Handle "last-read-entry" special value
|
||||
if (entryIds.includes("last-read-entry")) {
|
||||
// Mark all items in channel as read
|
||||
const result = await collection.updateMany(
|
||||
{ channelId: channelObjectId },
|
||||
{ $addToSet: { readBy: userId } },
|
||||
);
|
||||
console.info(
|
||||
`[Microsub] Marked all items as read: ${result.modifiedCount} updated`,
|
||||
);
|
||||
return result.modifiedCount;
|
||||
}
|
||||
|
||||
@@ -146,7 +299,7 @@ export async function markItemsRead(application, channelId, entryIds, userId) {
|
||||
})
|
||||
.filter(Boolean);
|
||||
|
||||
// Match by _id, uid, or url
|
||||
// Build query to match by _id, uid, or url (Microsub spec uses URLs as entry identifiers)
|
||||
const result = await collection.updateMany(
|
||||
{
|
||||
channelId: channelObjectId,
|
||||
@@ -159,6 +312,9 @@ export async function markItemsRead(application, channelId, entryIds, userId) {
|
||||
{ $addToSet: { readBy: userId } },
|
||||
);
|
||||
|
||||
console.info(
|
||||
`[Microsub] markItemsRead result: ${result.modifiedCount} items updated`,
|
||||
);
|
||||
return result.modifiedCount;
|
||||
}
|
||||
|
||||
@@ -166,7 +322,7 @@ export async function markItemsRead(application, channelId, entryIds, userId) {
|
||||
* Mark items as unread
|
||||
* @param {object} application - Indiekit application
|
||||
* @param {ObjectId|string} channelId - Channel ObjectId
|
||||
* @param {Array} entryIds - Array of entry IDs to mark as unread
|
||||
* @param {Array} entryIds - Array of entry IDs to mark as unread (can be ObjectId, uid, or URL)
|
||||
* @param {string} userId - User ID
|
||||
* @returns {Promise<number>} Number of items updated
|
||||
*/
|
||||
@@ -211,7 +367,7 @@ export async function markItemsUnread(
|
||||
* Remove items from channel
|
||||
* @param {object} application - Indiekit application
|
||||
* @param {ObjectId|string} channelId - Channel ObjectId
|
||||
* @param {Array} entryIds - Array of entry IDs to remove
|
||||
* @param {Array} entryIds - Array of entry IDs to remove (can be ObjectId, uid, or URL)
|
||||
* @returns {Promise<number>} Number of items removed
|
||||
*/
|
||||
export async function removeItems(application, channelId, entryIds) {
|
||||
@@ -243,6 +399,110 @@ export async function removeItems(application, channelId, entryIds) {
|
||||
return result.deletedCount;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete all items for a channel
|
||||
* @param {object} application - Indiekit application
|
||||
* @param {ObjectId|string} channelId - Channel ObjectId
|
||||
* @returns {Promise<number>} Number of deleted items
|
||||
*/
|
||||
export async function deleteItemsForChannel(application, channelId) {
|
||||
const collection = getCollection(application);
|
||||
const objectId =
|
||||
typeof channelId === "string" ? new ObjectId(channelId) : channelId;
|
||||
|
||||
const result = await collection.deleteMany({ channelId: objectId });
|
||||
return result.deletedCount;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete items for a specific feed
|
||||
* @param {object} application - Indiekit application
|
||||
* @param {ObjectId|string} feedId - Feed ObjectId
|
||||
* @returns {Promise<number>} Number of deleted items
|
||||
*/
|
||||
export async function deleteItemsForFeed(application, feedId) {
|
||||
const collection = getCollection(application);
|
||||
const objectId = typeof feedId === "string" ? new ObjectId(feedId) : feedId;
|
||||
|
||||
const result = await collection.deleteMany({ feedId: objectId });
|
||||
return result.deletedCount;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get unread count for a channel
|
||||
* @param {object} application - Indiekit application
|
||||
* @param {ObjectId|string} channelId - Channel ObjectId
|
||||
* @param {string} userId - User ID
|
||||
* @returns {Promise<number>} Unread count
|
||||
*/
|
||||
export async function getUnreadCount(application, channelId, userId) {
|
||||
const collection = getCollection(application);
|
||||
const objectId =
|
||||
typeof channelId === "string" ? new ObjectId(channelId) : channelId;
|
||||
|
||||
return collection.countDocuments({
|
||||
channelId: objectId,
|
||||
readBy: { $ne: userId },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Search items by text
|
||||
* @param {object} application - Indiekit application
|
||||
* @param {ObjectId|string} channelId - Channel ObjectId
|
||||
* @param {string} query - Search query
|
||||
* @param {number} [limit] - Max results
|
||||
* @returns {Promise<Array>} Array of matching items
|
||||
*/
|
||||
export async function searchItems(application, channelId, query, limit = 20) {
|
||||
const collection = getCollection(application);
|
||||
const objectId =
|
||||
typeof channelId === "string" ? new ObjectId(channelId) : channelId;
|
||||
|
||||
// Use regex search (consider adding text index for better performance)
|
||||
const regex = new RegExp(query, "i");
|
||||
const items = await collection
|
||||
.find({
|
||||
channelId: objectId,
|
||||
$or: [
|
||||
{ name: regex },
|
||||
{ "content.text": regex },
|
||||
{ "content.html": regex },
|
||||
{ summary: regex },
|
||||
],
|
||||
})
|
||||
// eslint-disable-next-line unicorn/no-array-sort -- MongoDB cursor method, not Array#sort
|
||||
.sort({ published: -1 })
|
||||
.limit(limit)
|
||||
.toArray();
|
||||
|
||||
return items.map((item) => transformToJf2(item));
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete items by author URL (for blocking)
|
||||
* @param {object} application - Indiekit application
|
||||
* @param {string} userId - User ID (for filtering user's channels)
|
||||
* @param {string} authorUrl - Author URL to delete items from
|
||||
* @returns {Promise<number>} Number of deleted items
|
||||
*/
|
||||
export async function deleteItemsByAuthorUrl(application, userId, authorUrl) {
|
||||
const collection = getCollection(application);
|
||||
const channelsCollection = application.collections.get("microsub_channels");
|
||||
|
||||
// Get all channel IDs for this user
|
||||
const userChannels = await channelsCollection.find({ userId }).toArray();
|
||||
const channelIds = userChannels.map((c) => c._id);
|
||||
|
||||
// Delete all items from blocked author in user's channels
|
||||
const result = await collection.deleteMany({
|
||||
channelId: { $in: channelIds },
|
||||
"author.url": authorUrl,
|
||||
});
|
||||
|
||||
return result.deletedCount;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create indexes for efficient queries
|
||||
* @param {object} application - Indiekit application
|
||||
@@ -254,7 +514,31 @@ export async function createIndexes(application) {
|
||||
// Primary query indexes
|
||||
await collection.createIndex({ channelId: 1, published: -1 });
|
||||
await collection.createIndex({ channelId: 1, uid: 1 }, { unique: true });
|
||||
await collection.createIndex({ feedId: 1 });
|
||||
|
||||
// URL matching index for mark_read operations
|
||||
await collection.createIndex({ channelId: 1, url: 1 });
|
||||
|
||||
// Full-text search index with weights
|
||||
// Higher weight = more importance in relevance scoring
|
||||
await collection.createIndex(
|
||||
{
|
||||
name: "text",
|
||||
"content.text": "text",
|
||||
"content.html": "text",
|
||||
summary: "text",
|
||||
"author.name": "text",
|
||||
},
|
||||
{
|
||||
name: "text_search",
|
||||
weights: {
|
||||
name: 10, // Titles most important
|
||||
summary: 5, // Summaries second
|
||||
"content.text": 3, // Content third
|
||||
"content.html": 2, // HTML content lower
|
||||
"author.name": 1, // Author names lowest
|
||||
},
|
||||
default_language: "english",
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
109
lib/storage/read-state.js
Normal file
109
lib/storage/read-state.js
Normal file
@@ -0,0 +1,109 @@
|
||||
/**
|
||||
* Read state tracking utilities
|
||||
* @module storage/read-state
|
||||
*/
|
||||
|
||||
import { markItemsRead, markItemsUnread, getUnreadCount } from "./items.js";
|
||||
|
||||
/**
|
||||
* Mark entries as read for a user
|
||||
* @param {object} application - Indiekit application
|
||||
* @param {string} channelUid - Channel UID
|
||||
* @param {Array} entries - Entry IDs to mark as read
|
||||
* @param {string} userId - User ID
|
||||
* @returns {Promise<number>} Number of entries marked
|
||||
*/
|
||||
export async function markRead(application, channelUid, entries, userId) {
|
||||
const channelsCollection = application.collections.get("microsub_channels");
|
||||
const channel = await channelsCollection.findOne({ uid: channelUid });
|
||||
|
||||
if (!channel) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return markItemsRead(application, channel._id, entries, userId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark entries as unread for a user
|
||||
* @param {object} application - Indiekit application
|
||||
* @param {string} channelUid - Channel UID
|
||||
* @param {Array} entries - Entry IDs to mark as unread
|
||||
* @param {string} userId - User ID
|
||||
* @returns {Promise<number>} Number of entries marked
|
||||
*/
|
||||
export async function markUnread(application, channelUid, entries, userId) {
|
||||
const channelsCollection = application.collections.get("microsub_channels");
|
||||
const channel = await channelsCollection.findOne({ uid: channelUid });
|
||||
|
||||
if (!channel) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return markItemsUnread(application, channel._id, entries, userId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get unread count for a channel
|
||||
* @param {object} application - Indiekit application
|
||||
* @param {string} channelUid - Channel UID
|
||||
* @param {string} userId - User ID
|
||||
* @returns {Promise<number>} Unread count
|
||||
*/
|
||||
export async function getChannelUnreadCount(application, channelUid, userId) {
|
||||
const channelsCollection = application.collections.get("microsub_channels");
|
||||
const channel = await channelsCollection.findOne({ uid: channelUid });
|
||||
|
||||
if (!channel) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return getUnreadCount(application, channel._id, userId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get unread counts for all channels
|
||||
* @param {object} application - Indiekit application
|
||||
* @param {string} userId - User ID
|
||||
* @returns {Promise<Map>} Map of channel UID to unread count
|
||||
*/
|
||||
export async function getAllUnreadCounts(application, userId) {
|
||||
const channelsCollection = application.collections.get("microsub_channels");
|
||||
const itemsCollection = application.collections.get("microsub_items");
|
||||
|
||||
// Aggregate unread counts per channel
|
||||
const pipeline = [
|
||||
{
|
||||
$match: {
|
||||
readBy: { $ne: userId },
|
||||
},
|
||||
},
|
||||
{
|
||||
$group: {
|
||||
_id: "$channelId",
|
||||
count: { $sum: 1 },
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const results = await itemsCollection.aggregate(pipeline).toArray();
|
||||
|
||||
// Get channel UIDs
|
||||
const channelIds = results.map((r) => r._id);
|
||||
const channels = await channelsCollection
|
||||
.find({ _id: { $in: channelIds } })
|
||||
.toArray();
|
||||
|
||||
const channelMap = new Map(channels.map((c) => [c._id.toString(), c.uid]));
|
||||
|
||||
// Build result map
|
||||
const unreadCounts = new Map();
|
||||
for (const result of results) {
|
||||
const uid = channelMap.get(result._id.toString());
|
||||
if (uid) {
|
||||
unreadCounts.set(uid, result.count);
|
||||
}
|
||||
}
|
||||
|
||||
return unreadCounts;
|
||||
}
|
||||
@@ -11,7 +11,7 @@
|
||||
* 2. request.session.me (from token introspection)
|
||||
* 3. application.publication.me (single-user fallback)
|
||||
* @param {object} request - Express request
|
||||
* @returns {string} User ID
|
||||
* @returns {string|undefined} User ID
|
||||
*/
|
||||
export function getUserId(request) {
|
||||
// Check session for explicit userId
|
||||
@@ -31,5 +31,6 @@ export function getUserId(request) {
|
||||
}
|
||||
|
||||
// Final fallback: use "default" as user ID for single-user instances
|
||||
// This ensures read state is tracked even without explicit user identity
|
||||
return "default";
|
||||
}
|
||||
|
||||
170
lib/utils/jf2.js
Normal file
170
lib/utils/jf2.js
Normal file
@@ -0,0 +1,170 @@
|
||||
/**
|
||||
* jf2 utility functions for Microsub
|
||||
* @module utils/jf2
|
||||
*/
|
||||
|
||||
import { createHash } from "node:crypto";
|
||||
|
||||
/**
|
||||
* Generate a unique ID for an item based on feed URL and item identifier
|
||||
* @param {string} feedUrl - Feed URL
|
||||
* @param {string} itemId - Item ID or URL
|
||||
* @returns {string} Unique item ID
|
||||
*/
|
||||
export function generateItemUid(feedUrl, itemId) {
|
||||
const input = `${feedUrl}:${itemId}`;
|
||||
return createHash("sha256").update(input).digest("hex").slice(0, 24);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a random channel UID
|
||||
* @returns {string} 24-character random string
|
||||
*/
|
||||
export function generateChannelUid() {
|
||||
const chars = "abcdefghijklmnopqrstuvwxyz0123456789";
|
||||
let result = "";
|
||||
for (let index = 0; index < 24; index++) {
|
||||
result += chars.charAt(Math.floor(Math.random() * chars.length));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a jf2 Item from normalized feed data
|
||||
* @param {object} data - Normalized item data
|
||||
* @param {object} source - Feed source metadata
|
||||
* @returns {object} jf2 Item object
|
||||
*/
|
||||
export function createJf2Item(data, source) {
|
||||
return {
|
||||
type: "entry",
|
||||
uid: data.uid,
|
||||
url: data.url,
|
||||
name: data.name || undefined,
|
||||
content: data.content || undefined,
|
||||
summary: data.summary || undefined,
|
||||
published: data.published,
|
||||
updated: data.updated || undefined,
|
||||
author: data.author || undefined,
|
||||
category: data.category || [],
|
||||
photo: data.photo || [],
|
||||
video: data.video || [],
|
||||
audio: data.audio || [],
|
||||
// Interaction types
|
||||
"like-of": data.likeOf || [],
|
||||
"repost-of": data.repostOf || [],
|
||||
"bookmark-of": data.bookmarkOf || [],
|
||||
"in-reply-to": data.inReplyTo || [],
|
||||
// Internal properties (prefixed with _)
|
||||
_id: data._id,
|
||||
_is_read: data._is_read || false,
|
||||
_source: source,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a jf2 Card (author/person)
|
||||
* @param {object} data - Author data
|
||||
* @returns {object} jf2 Card object
|
||||
*/
|
||||
export function createJf2Card(data) {
|
||||
if (!data) return;
|
||||
|
||||
return {
|
||||
type: "card",
|
||||
name: data.name || undefined,
|
||||
url: data.url || undefined,
|
||||
photo: data.photo || undefined,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a jf2 Content object
|
||||
* @param {string} text - Plain text content
|
||||
* @param {string} html - HTML content
|
||||
* @returns {object|undefined} jf2 Content object
|
||||
*/
|
||||
export function createJf2Content(text, html) {
|
||||
if (!text && !html) return;
|
||||
|
||||
return {
|
||||
text: text || stripHtml(html),
|
||||
html: html || undefined,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Strip HTML tags from string
|
||||
* @param {string} html - HTML string
|
||||
* @returns {string} Plain text
|
||||
*/
|
||||
export function stripHtml(html) {
|
||||
if (!html) return "";
|
||||
return html.replaceAll(/<[^>]*>/g, "").trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a jf2 Feed response
|
||||
* @param {object} options - Feed options
|
||||
* @param {Array} options.items - Array of jf2 items
|
||||
* @param {object} options.paging - Pagination cursors
|
||||
* @returns {object} jf2 Feed object
|
||||
*/
|
||||
export function createJf2Feed({ items, paging }) {
|
||||
const feed = {
|
||||
items: items || [],
|
||||
};
|
||||
|
||||
if (paging) {
|
||||
feed.paging = {};
|
||||
if (paging.before) feed.paging.before = paging.before;
|
||||
if (paging.after) feed.paging.after = paging.after;
|
||||
}
|
||||
|
||||
return feed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a Channel response object
|
||||
* @param {object} channel - Channel data
|
||||
* @param {number} unreadCount - Number of unread items
|
||||
* @returns {object} Channel object for API response
|
||||
*/
|
||||
export function createChannelResponse(channel, unreadCount = 0) {
|
||||
return {
|
||||
uid: channel.uid,
|
||||
name: channel.name,
|
||||
unread: unreadCount > 0 ? unreadCount : false,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a Feed response object
|
||||
* @param {object} feed - Feed data
|
||||
* @returns {object} Feed object for API response
|
||||
*/
|
||||
export function createFeedResponse(feed) {
|
||||
return {
|
||||
type: "feed",
|
||||
url: feed.url,
|
||||
name: feed.title || undefined,
|
||||
photo: feed.photo || undefined,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect interaction type from item properties
|
||||
* @param {object} item - jf2 item
|
||||
* @returns {string|undefined} Interaction type
|
||||
*/
|
||||
export function detectInteractionType(item) {
|
||||
if (item["like-of"]?.length > 0 || item.likeOf?.length > 0) return "like";
|
||||
if (item["repost-of"]?.length > 0 || item.repostOf?.length > 0)
|
||||
return "repost";
|
||||
if (item["bookmark-of"]?.length > 0 || item.bookmarkOf?.length > 0)
|
||||
return "bookmark";
|
||||
if (item["in-reply-to"]?.length > 0 || item.inReplyTo?.length > 0)
|
||||
return "reply";
|
||||
if (item.checkin) return "checkin";
|
||||
return;
|
||||
}
|
||||
@@ -5,16 +5,6 @@
|
||||
|
||||
import { ObjectId } from "mongodb";
|
||||
|
||||
/**
|
||||
* Default pagination limit
|
||||
*/
|
||||
export const DEFAULT_LIMIT = 20;
|
||||
|
||||
/**
|
||||
* Maximum pagination limit
|
||||
*/
|
||||
export const MAX_LIMIT = 100;
|
||||
|
||||
/**
|
||||
* Encode a cursor from timestamp and ID
|
||||
* @param {Date} timestamp - Item timestamp
|
||||
@@ -32,7 +22,7 @@ export function encodeCursor(timestamp, id) {
|
||||
/**
|
||||
* Decode a cursor string
|
||||
* @param {string} cursor - Base64-encoded cursor
|
||||
* @returns {object|undefined} Decoded cursor with timestamp and id
|
||||
* @returns {object|null} Decoded cursor with timestamp and id
|
||||
*/
|
||||
export function decodeCursor(cursor) {
|
||||
if (!cursor) return;
|
||||
@@ -95,6 +85,8 @@ export function buildPaginationQuery({ before, after, baseQuery = {} }) {
|
||||
* @returns {object} MongoDB sort object
|
||||
*/
|
||||
export function buildPaginationSort(before) {
|
||||
// When using 'before', we fetch newer items, so sort ascending then reverse
|
||||
// Otherwise, sort descending (newest first)
|
||||
if (before) {
|
||||
return { published: 1, _id: 1 };
|
||||
}
|
||||
@@ -116,16 +108,23 @@ export function generatePagingCursors(items, limit, hasMore, before) {
|
||||
|
||||
const paging = {};
|
||||
|
||||
// If we fetched with 'before', results are in ascending order
|
||||
// Reverse them and set cursors accordingly
|
||||
if (before) {
|
||||
items.reverse();
|
||||
// There are older items (the direction we came from)
|
||||
paging.after = encodeCursor(items.at(-1).published, items.at(-1)._id);
|
||||
if (hasMore) {
|
||||
// There are newer items ahead
|
||||
paging.before = encodeCursor(items[0].published, items[0]._id);
|
||||
}
|
||||
} else {
|
||||
// Normal descending order
|
||||
if (hasMore) {
|
||||
// There are older items
|
||||
paging.after = encodeCursor(items.at(-1).published, items.at(-1)._id);
|
||||
}
|
||||
// If we have items, there might be newer ones
|
||||
if (items.length > 0) {
|
||||
paging.before = encodeCursor(items[0].published, items[0]._id);
|
||||
}
|
||||
@@ -134,6 +133,16 @@ export function generatePagingCursors(items, limit, hasMore, before) {
|
||||
return paging;
|
||||
}
|
||||
|
||||
/**
|
||||
* Default pagination limit
|
||||
*/
|
||||
export const DEFAULT_LIMIT = 20;
|
||||
|
||||
/**
|
||||
* Maximum pagination limit
|
||||
*/
|
||||
export const MAX_LIMIT = 100;
|
||||
|
||||
/**
|
||||
* Parse and validate limit parameter
|
||||
* @param {string|number} limit - Requested limit
|
||||
|
||||
@@ -1,17 +0,0 @@
|
||||
/**
|
||||
* UID generation utilities for Microsub
|
||||
* @module utils/uid
|
||||
*/
|
||||
|
||||
/**
|
||||
* Generate a random channel UID
|
||||
* @returns {string} 24-character random string
|
||||
*/
|
||||
export function generateChannelUid() {
|
||||
const chars = "abcdefghijklmnopqrstuvwxyz0123456789";
|
||||
let result = "";
|
||||
for (let index = 0; index < 24; index++) {
|
||||
result += chars.charAt(Math.floor(Math.random() * chars.length));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
@@ -6,9 +6,42 @@
|
||||
import { IndiekitError } from "@indiekit/error";
|
||||
|
||||
/**
|
||||
* Valid Microsub actions (PR 1: channels and timeline only)
|
||||
* Valid Microsub actions
|
||||
*/
|
||||
export const VALID_ACTIONS = ["channels", "timeline"];
|
||||
export const VALID_ACTIONS = [
|
||||
"channels",
|
||||
"timeline",
|
||||
"follow",
|
||||
"unfollow",
|
||||
"search",
|
||||
"preview",
|
||||
"mute",
|
||||
"unmute",
|
||||
"block",
|
||||
"unblock",
|
||||
"events",
|
||||
];
|
||||
|
||||
/**
|
||||
* Valid channel methods
|
||||
*/
|
||||
export const VALID_CHANNEL_METHODS = ["delete", "order"];
|
||||
|
||||
/**
|
||||
* Valid timeline methods
|
||||
*/
|
||||
export const VALID_TIMELINE_METHODS = ["mark_read", "mark_unread", "remove"];
|
||||
|
||||
/**
|
||||
* Valid exclude types for channel filtering
|
||||
*/
|
||||
export const VALID_EXCLUDE_TYPES = [
|
||||
"like",
|
||||
"repost",
|
||||
"bookmark",
|
||||
"reply",
|
||||
"checkin",
|
||||
];
|
||||
|
||||
/**
|
||||
* Validate action parameter
|
||||
@@ -49,6 +82,29 @@ export function validateChannel(channel, required = true) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate URL parameter
|
||||
* @param {string} url - URL to validate
|
||||
* @param {string} [paramName] - Parameter name for error message
|
||||
* @param parameterName
|
||||
* @throws {IndiekitError} If URL is invalid
|
||||
*/
|
||||
export function validateUrl(url, parameterName = "url") {
|
||||
if (!url) {
|
||||
throw new IndiekitError(`Missing required parameter: ${parameterName}`, {
|
||||
status: 400,
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
new URL(url);
|
||||
} catch {
|
||||
throw new IndiekitError(`Invalid URL: ${url}`, {
|
||||
status: 400,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate entry/entries parameter
|
||||
* @param {string|Array} entry - Entry ID(s) to validate
|
||||
@@ -93,11 +149,43 @@ export function validateChannelName(name) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate exclude types array
|
||||
* @param {Array} types - Array of exclude types
|
||||
* @returns {Array} Validated exclude types
|
||||
*/
|
||||
export function validateExcludeTypes(types) {
|
||||
if (!types || !Array.isArray(types)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return types.filter((type) => VALID_EXCLUDE_TYPES.includes(type));
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate regex pattern
|
||||
* @param {string} pattern - Regex pattern to validate
|
||||
* @returns {string|null} Valid pattern or null
|
||||
*/
|
||||
export function validateExcludeRegex(pattern) {
|
||||
if (!pattern || typeof pattern !== "string") {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
new RegExp(pattern);
|
||||
return pattern;
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse array parameter from request
|
||||
* Handles both array[] and array[0], array[1] formats
|
||||
* @param {object} body - Request body
|
||||
* @param {string} parameterName - Parameter name
|
||||
* @param {string} paramName - Parameter name
|
||||
* @param parameterName
|
||||
* @returns {Array} Parsed array
|
||||
*/
|
||||
export function parseArrayParameter(body, parameterName) {
|
||||
|
||||
214
lib/webmention/processor.js
Normal file
214
lib/webmention/processor.js
Normal file
@@ -0,0 +1,214 @@
|
||||
/**
|
||||
* Webmention processor
|
||||
* @module webmention/processor
|
||||
*/
|
||||
|
||||
import { getRedisClient, publishEvent } from "../cache/redis.js";
|
||||
import { ensureNotificationsChannel } from "../storage/channels.js";
|
||||
|
||||
import { verifyWebmention } from "./verifier.js";
|
||||
|
||||
/**
|
||||
* Get notifications collection
|
||||
* @param {object} application - Indiekit application
|
||||
* @returns {object} MongoDB collection
|
||||
*/
|
||||
function getCollection(application) {
|
||||
return application.collections.get("microsub_notifications");
|
||||
}
|
||||
|
||||
/**
|
||||
* Process a webmention
|
||||
* @param {object} application - Indiekit application
|
||||
* @param {string} source - Source URL
|
||||
* @param {string} target - Target URL
|
||||
* @param {string} [userId] - User ID (for user-specific notifications)
|
||||
* @returns {Promise<object>} Processing result
|
||||
*/
|
||||
export async function processWebmention(application, source, target, userId) {
|
||||
// Verify the webmention
|
||||
const verification = await verifyWebmention(source, target);
|
||||
|
||||
if (!verification.verified) {
|
||||
console.log(
|
||||
`[Microsub] Webmention verification failed: ${verification.error}`,
|
||||
);
|
||||
return {
|
||||
success: false,
|
||||
error: verification.error,
|
||||
};
|
||||
}
|
||||
|
||||
// Ensure notifications channel exists
|
||||
const channel = await ensureNotificationsChannel(application, userId);
|
||||
|
||||
// Check for existing notification (update if exists)
|
||||
const collection = getCollection(application);
|
||||
const existing = await collection.findOne({
|
||||
source,
|
||||
target,
|
||||
...(userId && { userId }),
|
||||
});
|
||||
|
||||
const notification = {
|
||||
source,
|
||||
target,
|
||||
userId,
|
||||
channelId: channel._id,
|
||||
type: verification.type,
|
||||
author: verification.author,
|
||||
content: verification.content,
|
||||
url: verification.url,
|
||||
published: verification.published
|
||||
? new Date(verification.published)
|
||||
: new Date(),
|
||||
verified: true,
|
||||
readBy: [],
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
|
||||
if (existing) {
|
||||
// Update existing notification
|
||||
await collection.updateOne({ _id: existing._id }, { $set: notification });
|
||||
notification._id = existing._id;
|
||||
} else {
|
||||
// Insert new notification
|
||||
notification.createdAt = new Date();
|
||||
await collection.insertOne(notification);
|
||||
}
|
||||
|
||||
// Publish real-time event
|
||||
const redis = getRedisClient(application);
|
||||
if (redis && userId) {
|
||||
await publishEvent(redis, `microsub:user:${userId}`, {
|
||||
type: "new-notification",
|
||||
channelId: channel._id.toString(),
|
||||
notification: transformNotification(notification),
|
||||
});
|
||||
}
|
||||
|
||||
console.log(
|
||||
`[Microsub] Webmention processed: ${verification.type} from ${source}`,
|
||||
);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
type: verification.type,
|
||||
id: notification._id?.toString(),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a webmention (when source no longer links to target)
|
||||
* @param {object} application - Indiekit application
|
||||
* @param {string} source - Source URL
|
||||
* @param {string} target - Target URL
|
||||
* @returns {Promise<boolean>} Whether deletion was successful
|
||||
*/
|
||||
export async function deleteWebmention(application, source, target) {
|
||||
const collection = getCollection(application);
|
||||
const result = await collection.deleteOne({ source, target });
|
||||
return result.deletedCount > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get notifications for a user
|
||||
* @param {object} application - Indiekit application
|
||||
* @param {string} userId - User ID
|
||||
* @param {object} options - Query options
|
||||
* @returns {Promise<Array>} Array of notifications
|
||||
*/
|
||||
export async function getNotifications(application, userId, options = {}) {
|
||||
const collection = getCollection(application);
|
||||
const { limit = 20, unreadOnly = false } = options;
|
||||
|
||||
const query = { userId };
|
||||
if (unreadOnly) {
|
||||
query.readBy = { $ne: userId };
|
||||
}
|
||||
|
||||
/* eslint-disable unicorn/no-array-callback-reference, unicorn/no-array-sort -- MongoDB cursor methods */
|
||||
const notifications = await collection
|
||||
.find(query)
|
||||
.sort({ published: -1 })
|
||||
.limit(limit)
|
||||
.toArray();
|
||||
/* eslint-enable unicorn/no-array-callback-reference, unicorn/no-array-sort */
|
||||
|
||||
return notifications.map((n) => transformNotification(n, userId));
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark notifications as read
|
||||
* @param {object} application - Indiekit application
|
||||
* @param {string} userId - User ID
|
||||
* @param {Array} ids - Notification IDs to mark as read
|
||||
* @returns {Promise<number>} Number of notifications updated
|
||||
*/
|
||||
export async function markNotificationsRead(application, userId, ids) {
|
||||
const collection = getCollection(application);
|
||||
const { ObjectId } = await import("mongodb");
|
||||
|
||||
const objectIds = ids.map((id) => {
|
||||
try {
|
||||
return new ObjectId(id);
|
||||
} catch {
|
||||
return id;
|
||||
}
|
||||
});
|
||||
|
||||
const result = await collection.updateMany(
|
||||
{ _id: { $in: objectIds } },
|
||||
{ $addToSet: { readBy: userId } },
|
||||
);
|
||||
|
||||
return result.modifiedCount;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get unread notification count
|
||||
* @param {object} application - Indiekit application
|
||||
* @param {string} userId - User ID
|
||||
* @returns {Promise<number>} Unread count
|
||||
*/
|
||||
export async function getUnreadNotificationCount(application, userId) {
|
||||
const collection = getCollection(application);
|
||||
return collection.countDocuments({
|
||||
userId,
|
||||
readBy: { $ne: userId },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Transform notification to API format
|
||||
* @param {object} notification - Database notification
|
||||
* @param {string} [userId] - User ID for read state
|
||||
* @returns {object} Transformed notification
|
||||
*/
|
||||
function transformNotification(notification, userId) {
|
||||
return {
|
||||
type: "entry",
|
||||
uid: notification._id?.toString(),
|
||||
url: notification.url || notification.source,
|
||||
published: notification.published?.toISOString(),
|
||||
author: notification.author,
|
||||
content: notification.content,
|
||||
_source: notification.source,
|
||||
_target: notification.target,
|
||||
_type: notification.type, // like, reply, repost, bookmark, mention
|
||||
_is_read: userId ? notification.readBy?.includes(userId) : false,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create indexes for notifications
|
||||
* @param {object} application - Indiekit application
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
export async function createNotificationIndexes(application) {
|
||||
const collection = getCollection(application);
|
||||
|
||||
await collection.createIndex({ userId: 1, published: -1 });
|
||||
await collection.createIndex({ source: 1, target: 1 });
|
||||
await collection.createIndex({ userId: 1, readBy: 1 });
|
||||
}
|
||||
56
lib/webmention/receiver.js
Normal file
56
lib/webmention/receiver.js
Normal file
@@ -0,0 +1,56 @@
|
||||
/**
|
||||
* Webmention receiver
|
||||
* @module webmention/receiver
|
||||
*/
|
||||
|
||||
import { getUserId } from "../utils/auth.js";
|
||||
|
||||
import { processWebmention } from "./processor.js";
|
||||
|
||||
/**
|
||||
* Receive a webmention
|
||||
* POST /microsub/webmention
|
||||
* @param {object} request - Express request
|
||||
* @param {object} response - Express response
|
||||
*/
|
||||
export async function receive(request, response) {
|
||||
const { source, target } = request.body;
|
||||
|
||||
if (!source || !target) {
|
||||
return response.status(400).json({
|
||||
error: "invalid_request",
|
||||
error_description: "Missing source or target parameter",
|
||||
});
|
||||
}
|
||||
|
||||
// Validate URLs
|
||||
try {
|
||||
new URL(source);
|
||||
new URL(target);
|
||||
} catch {
|
||||
return response.status(400).json({
|
||||
error: "invalid_request",
|
||||
error_description: "Invalid source or target URL",
|
||||
});
|
||||
}
|
||||
|
||||
const { application } = request.app.locals;
|
||||
const userId = getUserId(request);
|
||||
|
||||
// Return 202 Accepted immediately (processing asynchronously)
|
||||
response.status(202).json({
|
||||
status: "accepted",
|
||||
message: "Webmention queued for processing",
|
||||
});
|
||||
|
||||
// Process webmention in background
|
||||
setImmediate(async () => {
|
||||
try {
|
||||
await processWebmention(application, source, target, userId);
|
||||
} catch (error) {
|
||||
console.error(`[Microsub] Error processing webmention: ${error.message}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export const webmentionReceiver = { receive };
|
||||
308
lib/webmention/verifier.js
Normal file
308
lib/webmention/verifier.js
Normal file
@@ -0,0 +1,308 @@
|
||||
/**
|
||||
* Webmention verification
|
||||
* @module webmention/verifier
|
||||
*/
|
||||
|
||||
import { mf2 } from "microformats-parser";
|
||||
|
||||
/**
|
||||
* Verify a webmention
|
||||
* @param {string} source - Source URL
|
||||
* @param {string} target - Target URL
|
||||
* @returns {Promise<object>} Verification result
|
||||
*/
|
||||
export async function verifyWebmention(source, target) {
|
||||
try {
|
||||
// Fetch the source URL
|
||||
const response = await fetch(source, {
|
||||
headers: {
|
||||
Accept: "text/html, application/xhtml+xml",
|
||||
"User-Agent": "Indiekit Microsub/1.0 (+https://getindiekit.com)",
|
||||
},
|
||||
redirect: "follow",
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
return {
|
||||
verified: false,
|
||||
error: `Source returned ${response.status}`,
|
||||
};
|
||||
}
|
||||
|
||||
const content = await response.text();
|
||||
const finalUrl = response.url;
|
||||
|
||||
// Check if source links to target
|
||||
if (!containsLink(content, target)) {
|
||||
return {
|
||||
verified: false,
|
||||
error: "Source does not link to target",
|
||||
};
|
||||
}
|
||||
|
||||
// Parse microformats
|
||||
const parsed = mf2(content, { baseUrl: finalUrl });
|
||||
const entry = findEntry(parsed, target);
|
||||
|
||||
if (!entry) {
|
||||
// Still valid, just no h-entry context
|
||||
return {
|
||||
verified: true,
|
||||
type: "mention",
|
||||
author: undefined,
|
||||
content: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
// Determine webmention type
|
||||
const mentionType = detectMentionType(entry, target);
|
||||
|
||||
// Extract author
|
||||
const author = extractAuthor(entry, parsed);
|
||||
|
||||
// Extract content
|
||||
const webmentionContent = extractContent(entry);
|
||||
|
||||
return {
|
||||
verified: true,
|
||||
type: mentionType,
|
||||
author,
|
||||
content: webmentionContent,
|
||||
url: getFirst(entry.properties.url) || source,
|
||||
published: getFirst(entry.properties.published),
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
verified: false,
|
||||
error: `Verification failed: ${error.message}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if content contains a link to target
|
||||
* @param {string} content - HTML content
|
||||
* @param {string} target - Target URL to find
|
||||
* @returns {boolean} Whether the link exists
|
||||
*/
|
||||
function containsLink(content, target) {
|
||||
// Normalize target URL for matching
|
||||
const normalizedTarget = target.replace(/\/$/, "");
|
||||
|
||||
// Check for href attribute containing target
|
||||
const hrefPattern = new RegExp(
|
||||
`href=["']${escapeRegex(normalizedTarget)}/?["']`,
|
||||
"i",
|
||||
);
|
||||
if (hrefPattern.test(content)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Also check without quotes (some edge cases)
|
||||
return content.includes(target) || content.includes(normalizedTarget);
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the h-entry that references the target
|
||||
* @param {object} parsed - Parsed microformats
|
||||
* @param {string} target - Target URL
|
||||
* @returns {object|undefined} The h-entry or undefined
|
||||
*/
|
||||
function findEntry(parsed, target) {
|
||||
const normalizedTarget = target.replace(/\/$/, "");
|
||||
|
||||
for (const item of parsed.items) {
|
||||
// Check if this entry references the target
|
||||
if (
|
||||
item.type?.includes("h-entry") &&
|
||||
entryReferencesTarget(item, normalizedTarget)
|
||||
) {
|
||||
return item;
|
||||
}
|
||||
|
||||
// Check children
|
||||
if (item.children) {
|
||||
for (const child of item.children) {
|
||||
if (
|
||||
child.type?.includes("h-entry") &&
|
||||
entryReferencesTarget(child, normalizedTarget)
|
||||
) {
|
||||
return child;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Return first h-entry as fallback
|
||||
for (const item of parsed.items) {
|
||||
if (item.type?.includes("h-entry")) {
|
||||
return item;
|
||||
}
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an entry references the target URL
|
||||
* @param {object} entry - h-entry object
|
||||
* @param {string} target - Normalized target URL
|
||||
* @returns {boolean} Whether the entry references the target
|
||||
*/
|
||||
function entryReferencesTarget(entry, target) {
|
||||
const properties = entry.properties || {};
|
||||
|
||||
// Check interaction properties
|
||||
const interactionProperties = [
|
||||
"in-reply-to",
|
||||
"like-of",
|
||||
"repost-of",
|
||||
"bookmark-of",
|
||||
];
|
||||
|
||||
for (const property of interactionProperties) {
|
||||
const values = properties[property] || [];
|
||||
for (const value of values) {
|
||||
const url =
|
||||
typeof value === "string" ? value : value?.properties?.url?.[0];
|
||||
if (url && normalizeUrl(url) === target) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect the type of webmention
|
||||
* @param {object} entry - h-entry object
|
||||
* @param {string} target - Target URL
|
||||
* @returns {string} Mention type
|
||||
*/
|
||||
function detectMentionType(entry, target) {
|
||||
const properties = entry.properties || {};
|
||||
const normalizedTarget = target.replace(/\/$/, "");
|
||||
|
||||
// Check for specific interaction types
|
||||
if (matchesTarget(properties["like-of"], normalizedTarget)) {
|
||||
return "like";
|
||||
}
|
||||
if (matchesTarget(properties["repost-of"], normalizedTarget)) {
|
||||
return "repost";
|
||||
}
|
||||
if (matchesTarget(properties["bookmark-of"], normalizedTarget)) {
|
||||
return "bookmark";
|
||||
}
|
||||
if (matchesTarget(properties["in-reply-to"], normalizedTarget)) {
|
||||
return "reply";
|
||||
}
|
||||
|
||||
return "mention";
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if any value in array matches target
|
||||
* @param {Array} values - Array of values
|
||||
* @param {string} target - Target URL to match
|
||||
* @returns {boolean} Whether any value matches
|
||||
*/
|
||||
function matchesTarget(values, target) {
|
||||
if (!values || values.length === 0) return false;
|
||||
|
||||
for (const value of values) {
|
||||
const url = typeof value === "string" ? value : value?.properties?.url?.[0];
|
||||
if (url && normalizeUrl(url) === target) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract author from entry or page
|
||||
* @param {object} entry - h-entry object
|
||||
* @param {object} parsed - Full parsed microformats
|
||||
* @returns {object|undefined} Author object
|
||||
*/
|
||||
function extractAuthor(entry, parsed) {
|
||||
const author = getFirst(entry.properties?.author);
|
||||
|
||||
if (typeof author === "string") {
|
||||
return { name: author };
|
||||
}
|
||||
|
||||
if (author?.type?.includes("h-card")) {
|
||||
return {
|
||||
type: "card",
|
||||
name: getFirst(author.properties?.name),
|
||||
url: getFirst(author.properties?.url),
|
||||
photo: getFirst(author.properties?.photo),
|
||||
};
|
||||
}
|
||||
|
||||
// Try to find author from page's h-card
|
||||
const hcard = parsed.items.find((item) => item.type?.includes("h-card"));
|
||||
if (hcard) {
|
||||
return {
|
||||
type: "card",
|
||||
name: getFirst(hcard.properties?.name),
|
||||
url: getFirst(hcard.properties?.url),
|
||||
photo: getFirst(hcard.properties?.photo),
|
||||
};
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract content from entry
|
||||
* @param {object} entry - h-entry object
|
||||
* @returns {object|undefined} Content object
|
||||
*/
|
||||
function extractContent(entry) {
|
||||
const content = getFirst(entry.properties?.content);
|
||||
|
||||
if (!content) {
|
||||
const summary = getFirst(entry.properties?.summary);
|
||||
const name = getFirst(entry.properties?.name);
|
||||
return summary || name ? { text: summary || name } : undefined;
|
||||
}
|
||||
|
||||
if (typeof content === "string") {
|
||||
return { text: content };
|
||||
}
|
||||
|
||||
return {
|
||||
text: content.value,
|
||||
html: content.html,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get first item from array
|
||||
* @param {Array|*} value - Value or array
|
||||
* @returns {*} First value
|
||||
*/
|
||||
function getFirst(value) {
|
||||
return Array.isArray(value) ? value[0] : value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize URL for comparison
|
||||
* @param {string} url - URL to normalize
|
||||
* @returns {string} Normalized URL
|
||||
*/
|
||||
function normalizeUrl(url) {
|
||||
return url.replace(/\/$/, "");
|
||||
}
|
||||
|
||||
/**
|
||||
* Escape special regex characters
|
||||
* @param {string} string - String to escape
|
||||
* @returns {string} Escaped string
|
||||
*/
|
||||
function escapeRegex(string) {
|
||||
return string.replaceAll(/[$()*+.?[\\\]^{|}]/g, String.raw`\$&`);
|
||||
}
|
||||
129
lib/websub/discovery.js
Normal file
129
lib/websub/discovery.js
Normal file
@@ -0,0 +1,129 @@
|
||||
/**
|
||||
* WebSub hub discovery
|
||||
* @module websub/discovery
|
||||
*/
|
||||
|
||||
/**
|
||||
* Discover WebSub hub from HTTP response headers and content
|
||||
* @param {object} response - Fetch response object
|
||||
* @param {string} content - Response body content
|
||||
* @returns {object|undefined} WebSub info { hub, self }
|
||||
*/
|
||||
export function discoverWebsub(response, content) {
|
||||
// Try to find hub and self URLs from Link headers first
|
||||
const linkHeader = response.headers.get("link");
|
||||
const fromHeaders = linkHeader ? parseLinkHeader(linkHeader) : {};
|
||||
|
||||
// Fall back to content parsing
|
||||
const fromContent = parseContentForLinks(content);
|
||||
|
||||
const hub = fromHeaders.hub || fromContent.hub;
|
||||
const self = fromHeaders.self || fromContent.self;
|
||||
|
||||
if (hub) {
|
||||
return { hub, self };
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse Link header for hub and self URLs
|
||||
* @param {string} linkHeader - Link header value
|
||||
* @returns {object} { hub, self }
|
||||
*/
|
||||
function parseLinkHeader(linkHeader) {
|
||||
const result = {};
|
||||
const links = linkHeader.split(",");
|
||||
|
||||
for (const link of links) {
|
||||
const parts = link.trim().split(";");
|
||||
if (parts.length < 2) continue;
|
||||
|
||||
const urlMatch = parts[0].match(/<([^>]+)>/);
|
||||
if (!urlMatch) continue;
|
||||
|
||||
const url = urlMatch[1];
|
||||
const relationship = parts
|
||||
.slice(1)
|
||||
.find((p) => p.trim().startsWith("rel="))
|
||||
?.match(/rel=["']?([^"'\s;]+)["']?/)?.[1];
|
||||
|
||||
if (relationship === "hub") {
|
||||
result.hub = url;
|
||||
} else if (relationship === "self") {
|
||||
result.self = url;
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse content for hub and self URLs (Atom, RSS, HTML)
|
||||
* @param {string} content - Response body
|
||||
* @returns {object} { hub, self }
|
||||
*/
|
||||
function parseContentForLinks(content) {
|
||||
const result = {};
|
||||
|
||||
// Try HTML <link> elements
|
||||
const htmlHubMatch = content.match(
|
||||
/<link[^>]+rel=["']?hub["']?[^>]+href=["']([^"']+)["']/i,
|
||||
);
|
||||
if (htmlHubMatch) {
|
||||
result.hub = htmlHubMatch[1];
|
||||
}
|
||||
|
||||
const htmlSelfMatch = content.match(
|
||||
/<link[^>]+rel=["']?self["']?[^>]+href=["']([^"']+)["']/i,
|
||||
);
|
||||
if (htmlSelfMatch) {
|
||||
result.self = htmlSelfMatch[1];
|
||||
}
|
||||
|
||||
// Also try the reverse order (href before rel)
|
||||
if (!result.hub) {
|
||||
const htmlHubMatch2 = content.match(
|
||||
/<link[^>]+href=["']([^"']+)["'][^>]+rel=["']?hub["']?/i,
|
||||
);
|
||||
if (htmlHubMatch2) {
|
||||
result.hub = htmlHubMatch2[1];
|
||||
}
|
||||
}
|
||||
|
||||
if (!result.self) {
|
||||
const htmlSelfMatch2 = content.match(
|
||||
/<link[^>]+href=["']([^"']+)["'][^>]+rel=["']?self["']?/i,
|
||||
);
|
||||
if (htmlSelfMatch2) {
|
||||
result.self = htmlSelfMatch2[1];
|
||||
}
|
||||
}
|
||||
|
||||
// Try Atom <link> elements
|
||||
if (!result.hub) {
|
||||
const atomHubMatch = content.match(
|
||||
/<atom:link[^>]+rel=["']?hub["']?[^>]+href=["']([^"']+)["']/i,
|
||||
);
|
||||
if (atomHubMatch) {
|
||||
result.hub = atomHubMatch[1];
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a hub URL is valid
|
||||
* @param {string} hubUrl - Hub URL to validate
|
||||
* @returns {boolean} Whether the URL is valid
|
||||
*/
|
||||
export function isValidHubUrl(hubUrl) {
|
||||
try {
|
||||
const url = new URL(hubUrl);
|
||||
return url.protocol === "https:" || url.protocol === "http:";
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
163
lib/websub/handler.js
Normal file
163
lib/websub/handler.js
Normal file
@@ -0,0 +1,163 @@
|
||||
/**
|
||||
* WebSub callback handler
|
||||
* @module websub/handler
|
||||
*/
|
||||
|
||||
import { parseFeed } from "../feeds/parser.js";
|
||||
import { processFeed } from "../polling/processor.js";
|
||||
import { getFeedBySubscriptionId, updateFeedWebsub } from "../storage/feeds.js";
|
||||
|
||||
import { verifySignature } from "./subscriber.js";
|
||||
|
||||
/**
|
||||
* Verify WebSub subscription
|
||||
* GET /microsub/websub/:id
|
||||
* @param {object} request - Express request
|
||||
* @param {object} response - Express response
|
||||
*/
|
||||
export async function verify(request, response) {
|
||||
const { id } = request.params;
|
||||
const {
|
||||
"hub.topic": topic,
|
||||
"hub.challenge": challenge,
|
||||
"hub.lease_seconds": leaseSeconds,
|
||||
} = request.query;
|
||||
|
||||
if (!challenge) {
|
||||
return response.status(400).send("Missing hub.challenge");
|
||||
}
|
||||
|
||||
const { application } = request.app.locals;
|
||||
const feed = await getFeedBySubscriptionId(application, id);
|
||||
|
||||
if (!feed) {
|
||||
return response.status(404).send("Subscription not found");
|
||||
}
|
||||
|
||||
// Verify topic matches (allow both feed URL and topic URL)
|
||||
const expectedTopic = feed.websub?.topic || feed.url;
|
||||
if (topic !== feed.url && topic !== expectedTopic) {
|
||||
return response.status(400).send("Topic mismatch");
|
||||
}
|
||||
|
||||
// Update lease seconds if provided
|
||||
if (leaseSeconds) {
|
||||
const seconds = Number.parseInt(leaseSeconds, 10);
|
||||
if (seconds > 0) {
|
||||
await updateFeedWebsub(application, id, {
|
||||
hub: feed.websub?.hub,
|
||||
topic: expectedTopic,
|
||||
leaseSeconds: seconds,
|
||||
secret: feed.websub?.secret,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Mark subscription as active (not pending)
|
||||
if (feed.websub?.pending) {
|
||||
await updateFeedWebsub(application, id, {
|
||||
hub: feed.websub?.hub,
|
||||
topic: expectedTopic,
|
||||
secret: feed.websub?.secret,
|
||||
leaseSeconds: feed.websub?.leaseSeconds,
|
||||
pending: false,
|
||||
});
|
||||
}
|
||||
|
||||
console.log(`[Microsub] WebSub subscription verified for ${feed.url}`);
|
||||
|
||||
// Return challenge to verify subscription
|
||||
response.type("text/plain").send(challenge);
|
||||
}
|
||||
|
||||
/**
|
||||
* Receive WebSub notification
|
||||
* POST /microsub/websub/:id
|
||||
* @param {object} request - Express request
|
||||
* @param {object} response - Express response
|
||||
*/
|
||||
export async function receive(request, response) {
|
||||
const { id } = request.params;
|
||||
const { application } = request.app.locals;
|
||||
|
||||
const feed = await getFeedBySubscriptionId(application, id);
|
||||
if (!feed) {
|
||||
return response.status(404).send("Subscription not found");
|
||||
}
|
||||
|
||||
// Verify X-Hub-Signature if we have a secret
|
||||
if (feed.websub?.secret) {
|
||||
const signature =
|
||||
request.headers["x-hub-signature-256"] ||
|
||||
request.headers["x-hub-signature"];
|
||||
|
||||
if (!signature) {
|
||||
return response.status(401).send("Missing signature");
|
||||
}
|
||||
|
||||
// Get raw body for signature verification
|
||||
const rawBody =
|
||||
typeof request.body === "string"
|
||||
? request.body
|
||||
: JSON.stringify(request.body);
|
||||
|
||||
if (!verifySignature(signature, rawBody, feed.websub.secret)) {
|
||||
console.warn(`[Microsub] Invalid WebSub signature for ${feed.url}`);
|
||||
return response.status(401).send("Invalid signature");
|
||||
}
|
||||
}
|
||||
|
||||
// Acknowledge receipt immediately
|
||||
response.status(200).send("OK");
|
||||
|
||||
// Process pushed content in background
|
||||
setImmediate(async () => {
|
||||
try {
|
||||
await processWebsubContent(
|
||||
application,
|
||||
feed,
|
||||
request.headers["content-type"],
|
||||
request.body,
|
||||
);
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`[Microsub] Error processing WebSub content for ${feed.url}: ${error.message}`,
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Process WebSub pushed content
|
||||
* @param {object} application - Indiekit application
|
||||
* @param {object} feed - Feed document
|
||||
* @param {string} contentType - Content-Type header
|
||||
* @param {string|object} body - Request body
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async function processWebsubContent(application, feed, contentType, body) {
|
||||
// Convert body to string if needed
|
||||
const content = typeof body === "string" ? body : JSON.stringify(body);
|
||||
|
||||
try {
|
||||
// Parse the pushed content
|
||||
const parsed = await parseFeed(content, feed.url, { contentType });
|
||||
|
||||
console.log(
|
||||
`[Microsub] Processing ${parsed.items.length} items from WebSub push for ${feed.url}`,
|
||||
);
|
||||
|
||||
// Process like a normal feed fetch but with pre-parsed content
|
||||
// This reuses the existing feed processing logic
|
||||
await processFeed(application, {
|
||||
...feed,
|
||||
_websubContent: parsed,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`[Microsub] Failed to parse WebSub content for ${feed.url}: ${error.message}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export const websubHandler = { verify, receive };
|
||||
181
lib/websub/subscriber.js
Normal file
181
lib/websub/subscriber.js
Normal file
@@ -0,0 +1,181 @@
|
||||
/**
|
||||
* WebSub subscriber
|
||||
* @module websub/subscriber
|
||||
*/
|
||||
|
||||
import crypto from "node:crypto";
|
||||
|
||||
import { updateFeedWebsub } from "../storage/feeds.js";
|
||||
|
||||
const DEFAULT_LEASE_SECONDS = 86_400 * 7; // 7 days
|
||||
|
||||
/**
|
||||
* Subscribe to a WebSub hub
|
||||
* @param {object} application - Indiekit application
|
||||
* @param {object} feed - Feed document with websub.hub
|
||||
* @param {string} callbackUrl - Callback URL for this subscription
|
||||
* @returns {Promise<boolean>} Whether subscription was initiated
|
||||
*/
|
||||
export async function subscribe(application, feed, callbackUrl) {
|
||||
if (!feed.websub?.hub) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const topic = feed.websub.topic || feed.url;
|
||||
const secret = generateSecret();
|
||||
|
||||
try {
|
||||
const response = await fetch(feed.websub.hub, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
},
|
||||
body: new URLSearchParams({
|
||||
"hub.mode": "subscribe",
|
||||
"hub.topic": topic,
|
||||
"hub.callback": callbackUrl,
|
||||
"hub.secret": secret,
|
||||
"hub.lease_seconds": String(DEFAULT_LEASE_SECONDS),
|
||||
}),
|
||||
});
|
||||
|
||||
// 202 Accepted means subscription is pending verification
|
||||
// 204 No Content means subscription was immediately accepted
|
||||
if (response.status === 202 || response.status === 204) {
|
||||
// Store the secret for signature verification
|
||||
await updateFeedWebsub(application, feed._id, {
|
||||
hub: feed.websub.hub,
|
||||
topic,
|
||||
secret,
|
||||
pending: true,
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
console.error(
|
||||
`[Microsub] WebSub subscription failed: ${response.status} ${response.statusText}`,
|
||||
);
|
||||
return false;
|
||||
} catch (error) {
|
||||
console.error(`[Microsub] WebSub subscription error: ${error.message}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Unsubscribe from a WebSub hub
|
||||
* @param {object} application - Indiekit application
|
||||
* @param {object} feed - Feed document with websub.hub
|
||||
* @param {string} callbackUrl - Callback URL for this subscription
|
||||
* @returns {Promise<boolean>} Whether unsubscription was initiated
|
||||
*/
|
||||
export async function unsubscribe(application, feed, callbackUrl) {
|
||||
if (!feed.websub?.hub) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const topic = feed.websub.topic || feed.url;
|
||||
|
||||
try {
|
||||
const response = await fetch(feed.websub.hub, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
},
|
||||
body: new URLSearchParams({
|
||||
"hub.mode": "unsubscribe",
|
||||
"hub.topic": topic,
|
||||
"hub.callback": callbackUrl,
|
||||
}),
|
||||
});
|
||||
|
||||
if (response.status === 202 || response.status === 204) {
|
||||
// Clear WebSub data from feed
|
||||
await updateFeedWebsub(application, feed._id, {
|
||||
hub: feed.websub.hub,
|
||||
topic,
|
||||
secret: undefined,
|
||||
leaseSeconds: undefined,
|
||||
pending: false,
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
} catch (error) {
|
||||
console.error(`[Microsub] WebSub unsubscribe error: ${error.message}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a random secret for signature verification
|
||||
* @returns {string} Random hex string
|
||||
*/
|
||||
function generateSecret() {
|
||||
return crypto.randomBytes(32).toString("hex");
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify WebSub signature
|
||||
* @param {string} signature - X-Hub-Signature header value
|
||||
* @param {Buffer|string} body - Request body
|
||||
* @param {string} secret - Subscription secret
|
||||
* @returns {boolean} Whether signature is valid
|
||||
*/
|
||||
export function verifySignature(signature, body, secret) {
|
||||
if (!signature || !secret) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Signature format: sha1=<hex> or sha256=<hex>
|
||||
const [algorithm, hash] = signature.split("=");
|
||||
if (!algorithm || !hash) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Normalize algorithm name
|
||||
const algo = algorithm.toLowerCase().replace("sha", "sha");
|
||||
|
||||
try {
|
||||
const expectedHash = crypto
|
||||
.createHmac(algo, secret)
|
||||
.update(body)
|
||||
.digest("hex");
|
||||
|
||||
// Use timing-safe comparison
|
||||
return crypto.timingSafeEqual(
|
||||
Buffer.from(hash, "hex"),
|
||||
Buffer.from(expectedHash, "hex"),
|
||||
);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a WebSub subscription is about to expire
|
||||
* @param {object} feed - Feed document
|
||||
* @param {number} [thresholdSeconds] - Seconds before expiry to consider "expiring"
|
||||
* @returns {boolean} Whether subscription is expiring soon
|
||||
*/
|
||||
export function isSubscriptionExpiring(feed, thresholdSeconds = 86_400) {
|
||||
if (!feed.websub?.expiresAt) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const expiresAt = new Date(feed.websub.expiresAt);
|
||||
const threshold = new Date(Date.now() + thresholdSeconds * 1000);
|
||||
|
||||
return expiresAt <= threshold;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get callback URL for a feed
|
||||
* @param {string} baseUrl - Base URL of the Microsub endpoint
|
||||
* @param {string} feedId - Feed ID
|
||||
* @returns {string} Callback URL
|
||||
*/
|
||||
export function getCallbackUrl(baseUrl, feedId) {
|
||||
return `${baseUrl}/microsub/websub/${feedId}`;
|
||||
}
|
||||
@@ -1,14 +1,88 @@
|
||||
{
|
||||
"microsub": {
|
||||
"title": "Microsub",
|
||||
"reader": {
|
||||
"title": "Reader",
|
||||
"empty": "No items to display",
|
||||
"markAllRead": "Mark all as read",
|
||||
"newer": "Newer",
|
||||
"older": "Older"
|
||||
},
|
||||
"channels": {
|
||||
"title": "Channels"
|
||||
"title": "Channels",
|
||||
"name": "Channel name",
|
||||
"new": "New channel",
|
||||
"create": "Create channel",
|
||||
"delete": "Delete channel",
|
||||
"settings": "Channel settings",
|
||||
"empty": "No channels yet. Create one to get started.",
|
||||
"notifications": "Notifications"
|
||||
},
|
||||
"timeline": {
|
||||
"title": "Timeline"
|
||||
"title": "Timeline",
|
||||
"empty": "No items in this channel",
|
||||
"markRead": "Mark as read",
|
||||
"markUnread": "Mark as unread",
|
||||
"remove": "Remove"
|
||||
},
|
||||
"feeds": {
|
||||
"title": "Feeds",
|
||||
"follow": "Follow",
|
||||
"subscribe": "Subscribe to a feed",
|
||||
"unfollow": "Unfollow",
|
||||
"empty": "No feeds followed in this channel",
|
||||
"url": "Feed URL",
|
||||
"urlPlaceholder": "https://example.com/feed.xml"
|
||||
},
|
||||
"item": {
|
||||
"reply": "Reply",
|
||||
"like": "Like",
|
||||
"repost": "Repost",
|
||||
"bookmark": "Bookmark",
|
||||
"viewOriginal": "View original"
|
||||
},
|
||||
"compose": {
|
||||
"title": "Compose",
|
||||
"content": "What's on your mind?",
|
||||
"submit": "Post",
|
||||
"cancel": "Cancel",
|
||||
"replyTo": "Replying to",
|
||||
"likeOf": "Liking",
|
||||
"repostOf": "Reposting",
|
||||
"bookmarkOf": "Bookmarking"
|
||||
},
|
||||
"settings": {
|
||||
"title": "{{channel}} settings",
|
||||
"excludeTypes": "Exclude interaction types",
|
||||
"excludeTypesHelp": "Select types of posts to hide from this channel",
|
||||
"excludeRegex": "Exclude pattern",
|
||||
"excludeRegexHelp": "Regular expression to filter out matching content",
|
||||
"save": "Save settings",
|
||||
"dangerZone": "Danger zone",
|
||||
"deleteWarning": "Deleting this channel will permanently remove all feeds and items. This action cannot be undone.",
|
||||
"deleteConfirm": "Are you sure you want to delete this channel and all its content?",
|
||||
"delete": "Delete channel",
|
||||
"types": {
|
||||
"like": "Likes",
|
||||
"repost": "Reposts",
|
||||
"bookmark": "Bookmarks",
|
||||
"reply": "Replies",
|
||||
"checkin": "Check-ins"
|
||||
}
|
||||
},
|
||||
"search": {
|
||||
"title": "Search",
|
||||
"placeholder": "Enter URL or search term",
|
||||
"submit": "Search",
|
||||
"noResults": "No results found"
|
||||
},
|
||||
"preview": {
|
||||
"title": "Preview",
|
||||
"subscribe": "Subscribe to this feed"
|
||||
},
|
||||
"error": {
|
||||
"channelNotFound": "Channel not found",
|
||||
"feedNotFound": "Feed not found",
|
||||
"invalidUrl": "Invalid URL",
|
||||
"invalidAction": "Invalid action"
|
||||
}
|
||||
}
|
||||
|
||||
22
package.json
22
package.json
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@rmdes/indiekit-endpoint-microsub",
|
||||
"version": "1.0.12",
|
||||
"version": "1.0.13",
|
||||
"description": "Microsub endpoint for Indiekit. Enables subscribing to feeds and reading content using the Microsub protocol.",
|
||||
"keywords": [
|
||||
"indiekit",
|
||||
@@ -15,12 +15,6 @@
|
||||
"name": "Ricardo Mendes",
|
||||
"url": "https://rmendes.net"
|
||||
},
|
||||
"contributors": [
|
||||
{
|
||||
"name": "Paul Robert Lloyd",
|
||||
"url": "https://paulrobertlloyd.com"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
@@ -28,8 +22,10 @@
|
||||
"type": "module",
|
||||
"main": "index.js",
|
||||
"files": [
|
||||
"assets",
|
||||
"lib",
|
||||
"locales",
|
||||
"views",
|
||||
"index.js"
|
||||
],
|
||||
"bugs": {
|
||||
@@ -37,12 +33,20 @@
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/rmdes/indiekit-endpoint-microsub.git"
|
||||
"url": "https://github.com/rmdes/indiekit-endpoint-microsub.git"
|
||||
},
|
||||
"dependencies": {
|
||||
"@indiekit/error": "^1.0.0-beta.25",
|
||||
"@indiekit/frontend": "^1.0.0-beta.25",
|
||||
"@indiekit/util": "^1.0.0-beta.25",
|
||||
"debug": "^4.3.2",
|
||||
"express": "^5.0.0",
|
||||
"mongodb": "^6.0.0"
|
||||
"feedparser": "^2.2.10",
|
||||
"htmlparser2": "^9.0.0",
|
||||
"ioredis": "^5.3.0",
|
||||
"luxon": "^3.4.0",
|
||||
"microformats-parser": "^2.0.0",
|
||||
"sanitize-html": "^2.11.0"
|
||||
},
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
|
||||
17
views/404.njk
Normal file
17
views/404.njk
Normal file
@@ -0,0 +1,17 @@
|
||||
{% extends "document.njk" %}
|
||||
|
||||
{% block main %}
|
||||
<article class="main__container -!-container">
|
||||
<header class="heading">
|
||||
<h1 class="heading__title">
|
||||
{{ __("microsub.error.notFound.title") | default("Not found") }}
|
||||
</h1>
|
||||
</header>
|
||||
{{ prose({ text: __("microsub.error.notFound.description") | default("The item you're looking for could not be found.") }) }}
|
||||
<p>
|
||||
<a href="{{ baseUrl }}/channels" class="button button--secondary">
|
||||
{{ __("microsub.reader.backToChannels") | default("Back to channels") }}
|
||||
</a>
|
||||
</p>
|
||||
</article>
|
||||
{% endblock %}
|
||||
31
views/channel-new.njk
Normal file
31
views/channel-new.njk
Normal file
@@ -0,0 +1,31 @@
|
||||
{% extends "layouts/reader.njk" %}
|
||||
|
||||
{% block reader %}
|
||||
<div class="channel-new">
|
||||
<a href="{{ baseUrl }}/channels" class="back-link">
|
||||
{{ icon("previous") }} {{ __("microsub.channels.title") }}
|
||||
</a>
|
||||
|
||||
<h2>{{ __("microsub.channels.new") }}</h2>
|
||||
|
||||
<form method="post" action="{{ baseUrl }}/channels/new">
|
||||
{{ input({
|
||||
id: "name",
|
||||
name: "name",
|
||||
label: __("microsub.channels.name"),
|
||||
required: true,
|
||||
autocomplete: "off",
|
||||
attributes: { autofocus: true }
|
||||
}) }}
|
||||
|
||||
<div class="button-group">
|
||||
{{ button({
|
||||
text: __("microsub.channels.create")
|
||||
}) }}
|
||||
<a href="{{ baseUrl }}/channels" class="button button--secondary">
|
||||
{{ __("Cancel") }}
|
||||
</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
{% endblock %}
|
||||
102
views/channel.njk
Normal file
102
views/channel.njk
Normal file
@@ -0,0 +1,102 @@
|
||||
{% extends "layouts/reader.njk" %}
|
||||
|
||||
{% block reader %}
|
||||
<div class="channel">
|
||||
<header class="channel__header">
|
||||
<a href="{{ baseUrl }}/channels" class="back-link">
|
||||
{{ icon("previous") }} {{ __("microsub.channels.title") }}
|
||||
</a>
|
||||
<div class="channel__actions">
|
||||
<form action="{{ baseUrl }}/api/mark-read" method="POST" style="display: inline;">
|
||||
<input type="hidden" name="channel" value="{{ channel.uid }}">
|
||||
<input type="hidden" name="entry" value="last-read-entry">
|
||||
<button type="submit" class="button button--secondary button--small">
|
||||
{{ icon("checkboxChecked") }} {{ __("microsub.reader.markAllRead") }}
|
||||
</button>
|
||||
</form>
|
||||
<a href="{{ baseUrl }}/channels/{{ channel.uid }}/feeds" class="button button--secondary button--small">
|
||||
{{ icon("syndicate") }} {{ __("microsub.feeds.title") }}
|
||||
</a>
|
||||
<a href="{{ baseUrl }}/channels/{{ channel.uid }}/settings" class="button button--secondary button--small">
|
||||
{{ icon("updatePost") }} {{ __("microsub.channels.settings") }}
|
||||
</a>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{% if items.length > 0 %}
|
||||
<div class="timeline" id="timeline" data-channel="{{ channel.uid }}">
|
||||
{% for item in items %}
|
||||
{% include "partials/item-card.njk" %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
{% if paging %}
|
||||
<nav class="timeline__paging" aria-label="Pagination">
|
||||
{% if paging.before %}
|
||||
<a href="?before={{ paging.before }}" class="button button--secondary">
|
||||
{{ icon("previous") }} {{ __("microsub.reader.newer") }}
|
||||
</a>
|
||||
{% else %}
|
||||
<span></span>
|
||||
{% endif %}
|
||||
{% if paging.after %}
|
||||
<a href="?after={{ paging.after }}" class="button button--secondary">
|
||||
{{ __("microsub.reader.older") }} {{ icon("next") }}
|
||||
</a>
|
||||
{% endif %}
|
||||
</nav>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<div class="reader__empty">
|
||||
{{ icon("syndicate") }}
|
||||
<p>{{ __("microsub.timeline.empty") }}</p>
|
||||
<a href="{{ baseUrl }}/channels/{{ channel.uid }}/feeds" class="button button--primary">
|
||||
{{ __("microsub.feeds.subscribe") }}
|
||||
</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<script type="module">
|
||||
// Keyboard navigation (j/k for items, o to open)
|
||||
const timeline = document.getElementById('timeline');
|
||||
if (timeline) {
|
||||
const items = Array.from(timeline.querySelectorAll('.item-card'));
|
||||
let currentIndex = -1;
|
||||
|
||||
function focusItem(index) {
|
||||
if (items[currentIndex]) {
|
||||
items[currentIndex].classList.remove('item-card--focused');
|
||||
}
|
||||
currentIndex = Math.max(0, Math.min(index, items.length - 1));
|
||||
if (items[currentIndex]) {
|
||||
items[currentIndex].classList.add('item-card--focused');
|
||||
items[currentIndex].scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return;
|
||||
|
||||
switch(e.key) {
|
||||
case 'j':
|
||||
e.preventDefault();
|
||||
focusItem(currentIndex + 1);
|
||||
break;
|
||||
case 'k':
|
||||
e.preventDefault();
|
||||
focusItem(currentIndex - 1);
|
||||
break;
|
||||
case 'o':
|
||||
case 'Enter':
|
||||
e.preventDefault();
|
||||
if (items[currentIndex]) {
|
||||
const link = items[currentIndex].querySelector('.item-card__link');
|
||||
if (link) link.click();
|
||||
}
|
||||
break;
|
||||
}
|
||||
});
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
98
views/compose.njk
Normal file
98
views/compose.njk
Normal file
@@ -0,0 +1,98 @@
|
||||
{% extends "layouts/reader.njk" %}
|
||||
|
||||
{% block reader %}
|
||||
<div class="compose">
|
||||
<a href="{{ backUrl or (baseUrl + '/channels') }}" class="back-link">
|
||||
{{ icon("previous") }} {{ __("Back") }}
|
||||
</a>
|
||||
|
||||
<h2>{{ __("microsub.compose.title") }}</h2>
|
||||
|
||||
{% if replyTo and replyTo is string %}
|
||||
<div class="compose__context">
|
||||
{{ icon("reply") }} {{ __("microsub.compose.replyTo") }}:
|
||||
<a href="{{ replyTo }}" target="_blank" rel="noopener">
|
||||
{{ replyTo | replace("https://", "") | replace("http://", "") }}
|
||||
</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if likeOf and likeOf is string %}
|
||||
<div class="compose__context">
|
||||
{{ icon("like") }} {{ __("microsub.compose.likeOf") }}:
|
||||
<a href="{{ likeOf }}" target="_blank" rel="noopener">
|
||||
{{ likeOf | replace("https://", "") | replace("http://", "") }}
|
||||
</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if repostOf and repostOf is string %}
|
||||
<div class="compose__context">
|
||||
{{ icon("repost") }} {{ __("microsub.compose.repostOf") }}:
|
||||
<a href="{{ repostOf }}" target="_blank" rel="noopener">
|
||||
{{ repostOf | replace("https://", "") | replace("http://", "") }}
|
||||
</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if bookmarkOf and bookmarkOf is string %}
|
||||
<div class="compose__context">
|
||||
{{ icon("bookmark") }} {{ __("microsub.compose.bookmarkOf") }}:
|
||||
<a href="{{ bookmarkOf }}" target="_blank" rel="noopener">
|
||||
{{ bookmarkOf | replace("https://", "") | replace("http://", "") }}
|
||||
</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<form method="post" action="{{ baseUrl }}/compose">
|
||||
{% if replyTo %}
|
||||
<input type="hidden" name="in-reply-to" value="{{ replyTo }}">
|
||||
{% endif %}
|
||||
{% if likeOf %}
|
||||
<input type="hidden" name="like-of" value="{{ likeOf }}">
|
||||
{% endif %}
|
||||
{% if repostOf %}
|
||||
<input type="hidden" name="repost-of" value="{{ repostOf }}">
|
||||
{% endif %}
|
||||
{% if bookmarkOf %}
|
||||
<input type="hidden" name="bookmark-of" value="{{ bookmarkOf }}">
|
||||
{% endif %}
|
||||
|
||||
{% set isAction = likeOf or repostOf or bookmarkOf %}
|
||||
|
||||
{% if not isAction %}
|
||||
{{ textarea({
|
||||
label: __("microsub.compose.content"),
|
||||
id: "content",
|
||||
name: "content",
|
||||
rows: 5,
|
||||
attributes: { autofocus: true }
|
||||
}) }}
|
||||
<div class="compose__counter">
|
||||
<span id="char-count">0</span> characters
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="button-group">
|
||||
{{ button({
|
||||
text: __("microsub.compose.submit")
|
||||
}) }}
|
||||
<a href="{{ backUrl or (baseUrl + '/channels') }}" class="button button--secondary">
|
||||
{{ __("microsub.compose.cancel") }}
|
||||
</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{% if not isAction %}
|
||||
<script type="module">
|
||||
const textarea = document.getElementById('content');
|
||||
const counter = document.getElementById('char-count');
|
||||
if (textarea && counter) {
|
||||
textarea.addEventListener('input', () => {
|
||||
counter.textContent = textarea.value.length;
|
||||
});
|
||||
}
|
||||
</script>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
69
views/feeds.njk
Normal file
69
views/feeds.njk
Normal file
@@ -0,0 +1,69 @@
|
||||
{% extends "layouts/reader.njk" %}
|
||||
|
||||
{% block reader %}
|
||||
<div class="feeds">
|
||||
<header class="feeds__header">
|
||||
<a href="{{ baseUrl }}/channels/{{ channel.uid }}" class="back-link">
|
||||
{{ icon("previous") }} {{ channel.name }}
|
||||
</a>
|
||||
</header>
|
||||
|
||||
<h2>{{ __("microsub.feeds.title") }}</h2>
|
||||
|
||||
{% if feeds.length > 0 %}
|
||||
<div class="feeds__list">
|
||||
{% for feed in feeds %}
|
||||
<div class="feeds__item">
|
||||
<div class="feeds__info">
|
||||
{% if feed.photo %}
|
||||
<img src="{{ feed.photo }}"
|
||||
alt=""
|
||||
class="feeds__photo"
|
||||
width="48"
|
||||
height="48"
|
||||
loading="lazy"
|
||||
onerror="this.style.display='none'">
|
||||
{% endif %}
|
||||
<div class="feeds__details">
|
||||
<span class="feeds__name">{{ feed.title or feed.url }}</span>
|
||||
<a href="{{ feed.url }}" class="feeds__url" target="_blank" rel="noopener">
|
||||
{{ feed.url | replace("https://", "") | replace("http://", "") }}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<form method="post" action="{{ baseUrl }}/channels/{{ channel.uid }}/feeds/remove" class="feeds__actions">
|
||||
<input type="hidden" name="url" value="{{ feed.url }}">
|
||||
{{ button({
|
||||
text: __("microsub.feeds.unfollow"),
|
||||
classes: "button--secondary button--small"
|
||||
}) }}
|
||||
</form>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="reader__empty">
|
||||
{{ icon("syndicate") }}
|
||||
<p>{{ __("microsub.feeds.empty") }}</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="feeds__add">
|
||||
<h3>{{ __("microsub.feeds.follow") }}</h3>
|
||||
<form method="post" action="{{ baseUrl }}/channels/{{ channel.uid }}/feeds" class="feeds__form">
|
||||
{{ input({
|
||||
id: "url",
|
||||
name: "url",
|
||||
label: __("microsub.feeds.url"),
|
||||
type: "url",
|
||||
required: true,
|
||||
placeholder: __("microsub.feeds.urlPlaceholder"),
|
||||
autocomplete: "off"
|
||||
}) }}
|
||||
<div class="button-group">
|
||||
{{ button({ text: __("microsub.feeds.follow") }) }}
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
151
views/item.njk
Normal file
151
views/item.njk
Normal file
@@ -0,0 +1,151 @@
|
||||
{% extends "layouts/reader.njk" %}
|
||||
|
||||
{% block reader %}
|
||||
<article class="item">
|
||||
<a href="{{ backUrl or (baseUrl + '/channels') }}" class="back-link">
|
||||
{{ icon("previous") }} {{ __("Back") }}
|
||||
</a>
|
||||
|
||||
{% if item.author %}
|
||||
<header class="item__author">
|
||||
{% if item.author.photo %}
|
||||
<img src="{{ item.author.photo }}"
|
||||
alt=""
|
||||
class="item__author-photo"
|
||||
width="48"
|
||||
height="48"
|
||||
loading="lazy"
|
||||
onerror="this.style.display='none'">
|
||||
{% endif %}
|
||||
<div class="item__author-info">
|
||||
<span class="item__author-name">
|
||||
{% if item.author.url %}
|
||||
<a href="{{ item.author.url }}" target="_blank" rel="noopener">{{ item.author.name or item.author.url }}</a>
|
||||
{% else %}
|
||||
{{ item.author.name or "Unknown" }}
|
||||
{% endif %}
|
||||
</span>
|
||||
{% if item.published %}
|
||||
<time datetime="{{ item.published }}" class="item__date">
|
||||
{{ item.published | date("PPPp", { locale: locale, timeZone: application.timeZone }) }}
|
||||
</time>
|
||||
{% endif %}
|
||||
</div>
|
||||
</header>
|
||||
{% endif %}
|
||||
|
||||
{# Context for interactions #}
|
||||
{% if item["in-reply-to"] or item["like-of"] or item["repost-of"] or item["bookmark-of"] %}
|
||||
<div class="item__context">
|
||||
{% if item["in-reply-to"] and item["in-reply-to"].length > 0 %}
|
||||
<p class="item__context-label">
|
||||
{{ icon("reply") }} {{ __("Reply to") }}:
|
||||
<a href="{{ item['in-reply-to'][0] }}" target="_blank" rel="noopener">
|
||||
{{ item["in-reply-to"][0] | replace("https://", "") | replace("http://", "") }}
|
||||
</a>
|
||||
</p>
|
||||
{% endif %}
|
||||
{% if item["like-of"] and item["like-of"].length > 0 %}
|
||||
<p class="item__context-label">
|
||||
{{ icon("like") }} {{ __("Liked") }}:
|
||||
<a href="{{ item['like-of'][0] }}" target="_blank" rel="noopener">
|
||||
{{ item["like-of"][0] | replace("https://", "") | replace("http://", "") }}
|
||||
</a>
|
||||
</p>
|
||||
{% endif %}
|
||||
{% if item["repost-of"] and item["repost-of"].length > 0 %}
|
||||
<p class="item__context-label">
|
||||
{{ icon("repost") }} {{ __("Reposted") }}:
|
||||
<a href="{{ item['repost-of'][0] }}" target="_blank" rel="noopener">
|
||||
{{ item["repost-of"][0] | replace("https://", "") | replace("http://", "") }}
|
||||
</a>
|
||||
</p>
|
||||
{% endif %}
|
||||
{% if item["bookmark-of"] and item["bookmark-of"].length > 0 %}
|
||||
<p class="item__context-label">
|
||||
{{ icon("bookmark") }} {{ __("Bookmarked") }}:
|
||||
<a href="{{ item['bookmark-of'][0] }}" target="_blank" rel="noopener">
|
||||
{{ item["bookmark-of"][0] | replace("https://", "") | replace("http://", "") }}
|
||||
</a>
|
||||
</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if item.name %}
|
||||
<h2 class="item__title">{{ item.name }}</h2>
|
||||
{% endif %}
|
||||
|
||||
{% if item.content %}
|
||||
<div class="item__content prose">
|
||||
{% if item.content.html %}
|
||||
{{ item.content.html | safe }}
|
||||
{% else %}
|
||||
{{ item.content.text }}
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{# Categories #}
|
||||
{% if item.category and item.category.length > 0 %}
|
||||
<div class="item-card__categories">
|
||||
{% for cat in item.category %}
|
||||
<span class="item-card__category">#{{ cat | replace("#", "") }}</span>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{# Photos #}
|
||||
{% if item.photo and item.photo.length > 0 %}
|
||||
<div class="item__photos">
|
||||
{% for photo in item.photo %}
|
||||
<a href="{{ photo }}" target="_blank" rel="noopener">
|
||||
<img src="{{ photo }}" alt="" class="item__photo" loading="lazy">
|
||||
</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{# Video #}
|
||||
{% if item.video and item.video.length > 0 %}
|
||||
<div class="item__media">
|
||||
{% for video in item.video %}
|
||||
<video src="{{ video }}"
|
||||
controls
|
||||
preload="metadata"
|
||||
{% if item.photo and item.photo.length > 0 %}poster="{{ item.photo[0] }}"{% endif %}>
|
||||
</video>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{# Audio #}
|
||||
{% if item.audio and item.audio.length > 0 %}
|
||||
<div class="item__media">
|
||||
{% for audio in item.audio %}
|
||||
<audio src="{{ audio }}" controls preload="metadata"></audio>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<footer class="item__actions">
|
||||
{% if item.url %}
|
||||
<a href="{{ item.url }}" class="button button--secondary button--small" target="_blank" rel="noopener">
|
||||
{{ icon("external") }} {{ __("microsub.item.viewOriginal") }}
|
||||
</a>
|
||||
{% endif %}
|
||||
<a href="{{ baseUrl }}/compose?reply={{ item.url | urlencode }}" class="button button--secondary button--small">
|
||||
{{ icon("reply") }} {{ __("microsub.item.reply") }}
|
||||
</a>
|
||||
<a href="{{ baseUrl }}/compose?like={{ item.url | urlencode }}" class="button button--secondary button--small">
|
||||
{{ icon("like") }} {{ __("microsub.item.like") }}
|
||||
</a>
|
||||
<a href="{{ baseUrl }}/compose?repost={{ item.url | urlencode }}" class="button button--secondary button--small">
|
||||
{{ icon("repost") }} {{ __("microsub.item.repost") }}
|
||||
</a>
|
||||
<a href="{{ baseUrl }}/compose?bookmark={{ item.url | urlencode }}" class="button button--secondary button--small">
|
||||
{{ icon("bookmark") }} {{ __("microsub.item.bookmark") }}
|
||||
</a>
|
||||
</footer>
|
||||
</article>
|
||||
{% endblock %}
|
||||
10
views/layouts/reader.njk
Normal file
10
views/layouts/reader.njk
Normal file
@@ -0,0 +1,10 @@
|
||||
{#
|
||||
Microsub Reader Layout
|
||||
Extends document.njk and adds reader-specific stylesheet
|
||||
#}
|
||||
{% extends "document.njk" %}
|
||||
|
||||
{% block content %}
|
||||
<link rel="stylesheet" href="/assets/@rmdes-indiekit-endpoint-microsub/styles.css">
|
||||
{% block reader %}{% endblock %}
|
||||
{% endblock %}
|
||||
15
views/partials/actions.njk
Normal file
15
views/partials/actions.njk
Normal file
@@ -0,0 +1,15 @@
|
||||
{# Item action buttons #}
|
||||
<div class="item-actions">
|
||||
<a href="{{ baseUrl }}/compose?replyTo={{ itemUrl | urlencode }}" class="item-actions__button" title="{{ __('microsub.item.reply') }}">
|
||||
{{ icon("reply") }}
|
||||
</a>
|
||||
<a href="{{ baseUrl }}/compose?likeOf={{ itemUrl | urlencode }}" class="item-actions__button" title="{{ __('microsub.item.like') }}">
|
||||
{{ icon("like") }}
|
||||
</a>
|
||||
<a href="{{ baseUrl }}/compose?repostOf={{ itemUrl | urlencode }}" class="item-actions__button" title="{{ __('microsub.item.repost') }}">
|
||||
{{ icon("repost") }}
|
||||
</a>
|
||||
<a href="{{ itemUrl }}" class="item-actions__button" target="_blank" rel="noopener" title="{{ __('microsub.item.viewOriginal') }}">
|
||||
{{ icon("public") }}
|
||||
</a>
|
||||
</div>
|
||||
17
views/partials/author.njk
Normal file
17
views/partials/author.njk
Normal file
@@ -0,0 +1,17 @@
|
||||
{# Author display #}
|
||||
{% if author %}
|
||||
<div class="author">
|
||||
{% if author.photo %}
|
||||
<img src="{{ author.photo }}" alt="" class="author__photo" width="48" height="48" loading="lazy">
|
||||
{% endif %}
|
||||
<div class="author__info">
|
||||
<span class="author__name">
|
||||
{% if author.url %}
|
||||
<a href="{{ author.url }}">{{ author.name or author.url }}</a>
|
||||
{% else %}
|
||||
{{ author.name or "Unknown" }}
|
||||
{% endif %}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
179
views/partials/item-card.njk
Normal file
179
views/partials/item-card.njk
Normal file
@@ -0,0 +1,179 @@
|
||||
{#
|
||||
Item card for timeline display
|
||||
Inspired by Aperture/Monocle reader
|
||||
#}
|
||||
<article class="item-card{% if item._is_read %} item-card--read{% endif %}"
|
||||
data-item-id="{{ item._id }}"
|
||||
data-is-read="{{ item._is_read | default(false) }}">
|
||||
|
||||
{# Context bar for interactions (Aperture pattern) #}
|
||||
{# Helper to extract URL from value that may be string or object #}
|
||||
{% macro getUrl(val) %}{{ val.url or val.value or val if val is string else val }}{% endmacro %}
|
||||
|
||||
{% if item["like-of"] and item["like-of"].length > 0 %}
|
||||
{% set contextUrl = item['like-of'][0].url or item['like-of'][0].value or item['like-of'][0] %}
|
||||
<div class="item-card__context">
|
||||
{{ icon("like") }}
|
||||
<span>Liked</span>
|
||||
<a href="{{ contextUrl }}" target="_blank" rel="noopener">
|
||||
{{ contextUrl | replace("https://", "") | replace("http://", "") | truncate(50) }}
|
||||
</a>
|
||||
</div>
|
||||
{% elif item["repost-of"] and item["repost-of"].length > 0 %}
|
||||
{% set contextUrl = item['repost-of'][0].url or item['repost-of'][0].value or item['repost-of'][0] %}
|
||||
<div class="item-card__context">
|
||||
{{ icon("repost") }}
|
||||
<span>Reposted</span>
|
||||
<a href="{{ contextUrl }}" target="_blank" rel="noopener">
|
||||
{{ contextUrl | replace("https://", "") | replace("http://", "") | truncate(50) }}
|
||||
</a>
|
||||
</div>
|
||||
{% elif item["in-reply-to"] and item["in-reply-to"].length > 0 %}
|
||||
{% set contextUrl = item['in-reply-to'][0].url or item['in-reply-to'][0].value or item['in-reply-to'][0] %}
|
||||
<div class="item-card__context">
|
||||
{{ icon("reply") }}
|
||||
<span>Reply to</span>
|
||||
<a href="{{ contextUrl }}" target="_blank" rel="noopener">
|
||||
{{ contextUrl | replace("https://", "") | replace("http://", "") | truncate(50) }}
|
||||
</a>
|
||||
</div>
|
||||
{% elif item["bookmark-of"] and item["bookmark-of"].length > 0 %}
|
||||
{% set contextUrl = item['bookmark-of'][0].url or item['bookmark-of'][0].value or item['bookmark-of'][0] %}
|
||||
<div class="item-card__context">
|
||||
{{ icon("bookmark") }}
|
||||
<span>Bookmarked</span>
|
||||
<a href="{{ contextUrl }}" target="_blank" rel="noopener">
|
||||
{{ contextUrl | replace("https://", "") | replace("http://", "") | truncate(50) }}
|
||||
</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<a href="{{ baseUrl }}/item/{{ item._id }}" class="item-card__link">
|
||||
{# Author #}
|
||||
{% if item.author %}
|
||||
<div class="item-card__author">
|
||||
{% if item.author.photo %}
|
||||
<img src="{{ item.author.photo }}"
|
||||
alt=""
|
||||
class="item-card__author-photo"
|
||||
width="40"
|
||||
height="40"
|
||||
loading="lazy"
|
||||
onerror="this.style.display='none'">
|
||||
{% endif %}
|
||||
<div class="item-card__author-info">
|
||||
<span class="item-card__author-name">{{ item.author.name or "Unknown" }}</span>
|
||||
{% if item._source %}
|
||||
<span class="item-card__source">{{ item._source.name or item._source.url }}</span>
|
||||
{% elif item.author.url %}
|
||||
<span class="item-card__source">{{ item.author.url | replace("https://", "") | replace("http://", "") }}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{# Title (for articles) #}
|
||||
{% if item.name %}
|
||||
<h3 class="item-card__title">{{ item.name }}</h3>
|
||||
{% endif %}
|
||||
|
||||
{# Content with overflow handling #}
|
||||
{% if item.summary or item.content %}
|
||||
<div class="item-card__content{% if (item.content.text or item.summary or '') | length > 300 %} item-card__content--truncated{% endif %}">
|
||||
{% if item.content.html %}
|
||||
{{ item.content.html | safe | striptags | truncate(400) }}
|
||||
{% elif item.content.text %}
|
||||
{{ item.content.text | truncate(400) }}
|
||||
{% elif item.summary %}
|
||||
{{ item.summary | truncate(400) }}
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{# Categories/Tags #}
|
||||
{% if item.category and item.category.length > 0 %}
|
||||
<div class="item-card__categories">
|
||||
{% for cat in item.category | slice(0, 5) %}
|
||||
<span class="item-card__category">#{{ cat | replace("#", "") }}</span>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{# Photo grid (Aperture multi-photo pattern) #}
|
||||
{% if item.photo and item.photo.length > 0 %}
|
||||
{% set photoCount = item.photo.length if item.photo.length <= 4 else 4 %}
|
||||
<div class="item-card__photos item-card__photos--{{ photoCount }}">
|
||||
{% for photo in item.photo | slice(0, 4) %}
|
||||
<img src="{{ photo }}"
|
||||
alt=""
|
||||
class="item-card__photo"
|
||||
loading="lazy"
|
||||
onerror="this.parentElement.removeChild(this)">
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{# Video preview #}
|
||||
{% if item.video and item.video.length > 0 %}
|
||||
<div class="item-card__media">
|
||||
<video src="{{ item.video[0] }}"
|
||||
class="item-card__video"
|
||||
controls
|
||||
preload="metadata"
|
||||
{% if item.photo and item.photo.length > 0 %}poster="{{ item.photo[0] }}"{% endif %}>
|
||||
</video>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{# Audio preview #}
|
||||
{% if item.audio and item.audio.length > 0 %}
|
||||
<div class="item-card__media">
|
||||
<audio src="{{ item.audio[0] }}" class="item-card__audio" controls preload="metadata"></audio>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{# Footer with date and actions #}
|
||||
<footer class="item-card__footer">
|
||||
{% if item.published %}
|
||||
<time datetime="{{ item.published }}" class="item-card__date">
|
||||
{{ item.published | date("PP", { locale: locale, timeZone: application.timeZone }) }}
|
||||
</time>
|
||||
{% endif %}
|
||||
{% if not item._is_read %}
|
||||
<span class="item-card__unread" aria-label="Unread">●</span>
|
||||
{% endif %}
|
||||
</footer>
|
||||
</a>
|
||||
|
||||
{# Inline actions (Aperture pattern) #}
|
||||
<div class="item-actions">
|
||||
{% if item.url %}
|
||||
<a href="{{ item.url }}" class="item-actions__button" target="_blank" rel="noopener" title="View original">
|
||||
{{ icon("external") }}
|
||||
<span class="visually-hidden">Original</span>
|
||||
</a>
|
||||
{% endif %}
|
||||
<a href="{{ baseUrl }}/compose?reply={{ item.url | urlencode }}" class="item-actions__button" title="Reply">
|
||||
{{ icon("reply") }}
|
||||
<span class="visually-hidden">Reply</span>
|
||||
</a>
|
||||
<a href="{{ baseUrl }}/compose?like={{ item.url | urlencode }}" class="item-actions__button" title="Like">
|
||||
{{ icon("like") }}
|
||||
<span class="visually-hidden">Like</span>
|
||||
</a>
|
||||
<a href="{{ baseUrl }}/compose?repost={{ item.url | urlencode }}" class="item-actions__button" title="Repost">
|
||||
{{ icon("repost") }}
|
||||
<span class="visually-hidden">Repost</span>
|
||||
</a>
|
||||
{% if not item._is_read %}
|
||||
<button type="button"
|
||||
class="item-actions__button item-actions__mark-read"
|
||||
data-action="mark-read"
|
||||
data-item-id="{{ item._id }}"
|
||||
title="Mark as read">
|
||||
{{ icon("checkboxChecked") }}
|
||||
<span class="visually-hidden">Mark read</span>
|
||||
</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
</article>
|
||||
10
views/partials/timeline.njk
Normal file
10
views/partials/timeline.njk
Normal file
@@ -0,0 +1,10 @@
|
||||
{# Timeline of items #}
|
||||
<div class="timeline">
|
||||
{% if items.length > 0 %}
|
||||
{% for item in items %}
|
||||
{% include "partials/item-card.njk" %}
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
{{ prose({ text: __("microsub.timeline.empty") }) }}
|
||||
{% endif %}
|
||||
</div>
|
||||
41
views/reader.njk
Normal file
41
views/reader.njk
Normal file
@@ -0,0 +1,41 @@
|
||||
{% extends "layouts/reader.njk" %}
|
||||
|
||||
{% block reader %}
|
||||
<div class="reader">
|
||||
{% if channels.length > 0 %}
|
||||
<div class="reader__channels">
|
||||
{% for channel in channels %}
|
||||
<a href="{{ baseUrl }}/channels/{{ channel.uid }}" class="reader__channel{% if channel.uid === currentChannel %} reader__channel--active{% endif %}">
|
||||
<span class="reader__channel-name">
|
||||
{% if channel.uid === "notifications" %}
|
||||
{{ icon("mention") }}
|
||||
{% endif %}
|
||||
{{ channel.name }}
|
||||
</span>
|
||||
{% if channel.unread %}
|
||||
<span class="reader__channel-badge{% if channel.unread === true %} reader__channel-badge--dot{% endif %}">
|
||||
{% if channel.unread !== true %}{{ channel.unread }}{% endif %}
|
||||
</span>
|
||||
{% endif %}
|
||||
</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
<div class="reader__actions">
|
||||
<a href="{{ baseUrl }}/search" class="button button--primary">
|
||||
{{ icon("syndicate") }} {{ __("microsub.feeds.follow") }}
|
||||
</a>
|
||||
<a href="{{ baseUrl }}/channels/new" class="button button--secondary">
|
||||
{{ icon("createPost") }} {{ __("microsub.channels.new") }}
|
||||
</a>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="reader__empty">
|
||||
{{ icon("syndicate") }}
|
||||
<p>{{ __("microsub.channels.empty") }}</p>
|
||||
<a href="{{ baseUrl }}/channels/new" class="button button--primary">
|
||||
{{ __("microsub.channels.new") }}
|
||||
</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
61
views/search.njk
Normal file
61
views/search.njk
Normal file
@@ -0,0 +1,61 @@
|
||||
{% extends "layouts/reader.njk" %}
|
||||
|
||||
{% block reader %}
|
||||
<div class="search">
|
||||
<a href="{{ baseUrl }}/channels" class="back-link">
|
||||
{{ icon("previous") }} {{ __("microsub.channels.title") }}
|
||||
</a>
|
||||
|
||||
<h2>{{ __("microsub.search.title") }}</h2>
|
||||
|
||||
<form method="post" action="{{ baseUrl }}/search" class="search__form">
|
||||
{{ input({
|
||||
id: "query",
|
||||
name: "query",
|
||||
label: __("microsub.search.placeholder"),
|
||||
type: "url",
|
||||
required: true,
|
||||
placeholder: "https://example.com",
|
||||
autocomplete: "off",
|
||||
value: query,
|
||||
attributes: { autofocus: true }
|
||||
}) }}
|
||||
<div class="button-group">
|
||||
{{ button({ text: __("microsub.search.submit") }) }}
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{% if results and results.length > 0 %}
|
||||
<div class="search__results">
|
||||
<h3>{{ __("microsub.search.title") }}</h3>
|
||||
<div class="search__list">
|
||||
{% for result in results %}
|
||||
<div class="search__item">
|
||||
<div class="search__feed">
|
||||
<span class="search__name">{{ result.title or "Feed" }}</span>
|
||||
<span class="search__url">{{ result.url | replace("https://", "") | replace("http://", "") }}</span>
|
||||
</div>
|
||||
<form method="post" action="{{ baseUrl }}/subscribe" class="search__subscribe">
|
||||
<input type="hidden" name="url" value="{{ result.url }}">
|
||||
<label for="channel-{{ loop.index }}" class="visually-hidden">{{ __("microsub.channels.title") }}</label>
|
||||
<select name="channel" id="channel-{{ loop.index }}" class="select select--small">
|
||||
{% for channel in channels %}
|
||||
<option value="{{ channel.uid }}">{{ channel.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
{{ button({
|
||||
text: __("microsub.feeds.follow"),
|
||||
classes: "button--small"
|
||||
}) }}
|
||||
</form>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% elif searched %}
|
||||
<div class="reader__empty">
|
||||
<p>{{ __("microsub.search.noResults") }}</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
75
views/settings.njk
Normal file
75
views/settings.njk
Normal file
@@ -0,0 +1,75 @@
|
||||
{% extends "layouts/reader.njk" %}
|
||||
|
||||
{% block reader %}
|
||||
<div class="settings">
|
||||
<a href="{{ baseUrl }}/channels/{{ channel.uid }}" class="back-link">
|
||||
{{ icon("previous") }} {{ channel.name }}
|
||||
</a>
|
||||
|
||||
<h2>{{ __("microsub.settings.title", { channel: channel.name }) }}</h2>
|
||||
|
||||
<form method="post" action="{{ baseUrl }}/channels/{{ channel.uid }}/settings">
|
||||
{{ checkboxes({
|
||||
name: "excludeTypes",
|
||||
values: channel.settings.excludeTypes,
|
||||
fieldset: {
|
||||
legend: __("microsub.settings.excludeTypes")
|
||||
},
|
||||
hint: __("microsub.settings.excludeTypesHelp"),
|
||||
items: [
|
||||
{
|
||||
label: __("microsub.settings.types.like"),
|
||||
value: "like"
|
||||
},
|
||||
{
|
||||
label: __("microsub.settings.types.repost"),
|
||||
value: "repost"
|
||||
},
|
||||
{
|
||||
label: __("microsub.settings.types.bookmark"),
|
||||
value: "bookmark"
|
||||
},
|
||||
{
|
||||
label: __("microsub.settings.types.reply"),
|
||||
value: "reply"
|
||||
},
|
||||
{
|
||||
label: __("microsub.settings.types.checkin"),
|
||||
value: "checkin"
|
||||
}
|
||||
]
|
||||
}) }}
|
||||
|
||||
{{ input({
|
||||
id: "excludeRegex",
|
||||
name: "excludeRegex",
|
||||
label: __("microsub.settings.excludeRegex"),
|
||||
hint: __("microsub.settings.excludeRegexHelp"),
|
||||
value: channel.settings.excludeRegex
|
||||
}) }}
|
||||
|
||||
<div class="button-group">
|
||||
{{ button({
|
||||
text: __("microsub.settings.save")
|
||||
}) }}
|
||||
<a href="{{ baseUrl }}/channels/{{ channel.uid }}" class="button button--secondary">
|
||||
{{ __("Cancel") }}
|
||||
</a>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{% if channel.uid !== "notifications" %}
|
||||
<hr class="divider">
|
||||
<div class="danger-zone">
|
||||
<h3>{{ __("microsub.settings.dangerZone") }}</h3>
|
||||
<p class="hint">{{ __("microsub.settings.deleteWarning") }}</p>
|
||||
<form method="post" action="{{ baseUrl }}/channels/{{ channel.uid }}/delete" onsubmit="return confirm('{{ __("microsub.settings.deleteConfirm") }}');">
|
||||
{{ button({
|
||||
text: __("microsub.settings.delete"),
|
||||
classes: "button--danger"
|
||||
}) }}
|
||||
</form>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
Reference in New Issue
Block a user