981 lines
32 KiB
TypeScript
981 lines
32 KiB
TypeScript
/**
|
|
* rFeeds module — community RSS dashboard.
|
|
*
|
|
* Subscribe to external RSS/Atom feeds, write manual posts,
|
|
* ingest rApp activity via adapters, manage user feed profiles,
|
|
* expose combined activity as public Atom 1.0 feed.
|
|
*/
|
|
|
|
import { Hono } from "hono";
|
|
import * as Automerge from "@automerge/automerge";
|
|
import { renderShell } from "../../server/shell";
|
|
import { getModuleInfoList } from "../../shared/module";
|
|
import type { RSpaceModule } from "../../shared/module";
|
|
import type { SyncServer } from "../../server/local-first/sync-server";
|
|
import { verifyToken, extractToken } from "../../server/auth";
|
|
import type { EncryptIDClaims } from "../../server/auth";
|
|
import { resolveCallerRole, roleAtLeast } from "../../server/spaces";
|
|
import type { SpaceRoleString } from "../../server/spaces";
|
|
import { feedsSchema, feedsDocId, MAX_ITEMS_PER_SOURCE, MAX_ACTIVITY_CACHE, DEFAULT_SYNC_INTERVAL_MS } from "./schemas";
|
|
import type { FeedsDoc, FeedSource, FeedItem, ManualPost, ActivityCacheEntry, ActivityModuleConfig, UserFeedProfile } from "./schemas";
|
|
import { parseFeed, parseOPML } from "./lib/feed-parser";
|
|
import { adapterRegistry } from "./adapters/index";
|
|
import { renderLanding } from "./landing";
|
|
|
|
let _syncServer: SyncServer | null = null;
|
|
|
|
const routes = new Hono();
|
|
|
|
// ── Automerge doc management ──
|
|
|
|
function ensureDoc(space: string): FeedsDoc {
|
|
const docId = feedsDocId(space);
|
|
let doc = _syncServer!.getDoc<FeedsDoc>(docId);
|
|
if (!doc) {
|
|
doc = Automerge.change(Automerge.init<FeedsDoc>(), "init rfeeds", (d) => {
|
|
const init = feedsSchema.init();
|
|
d.meta = init.meta;
|
|
d.meta.spaceSlug = space;
|
|
d.sources = {};
|
|
d.items = {};
|
|
d.posts = {};
|
|
d.activityCache = {};
|
|
d.userProfiles = {};
|
|
d.settings = init.settings;
|
|
});
|
|
_syncServer!.setDoc(docId, doc);
|
|
}
|
|
// Migrate v1 docs missing new fields
|
|
if (!doc.activityCache || !doc.userProfiles || !doc.settings.activityModules) {
|
|
_syncServer!.changeDoc<FeedsDoc>(docId, "migrate to v2", (d) => {
|
|
if (!d.activityCache) d.activityCache = {} as any;
|
|
if (!d.userProfiles) d.userProfiles = {} as any;
|
|
if (!d.settings.activityModules) d.settings.activityModules = {} as any;
|
|
if (d.meta) d.meta.version = 2;
|
|
});
|
|
doc = _syncServer!.getDoc<FeedsDoc>(docId)!;
|
|
}
|
|
return doc;
|
|
}
|
|
|
|
// ── Auth helpers ──
|
|
|
|
async function requireMember(c: any, space: string): Promise<{ claims: EncryptIDClaims; role: SpaceRoleString } | null> {
|
|
const token = extractToken(c.req.raw);
|
|
if (!token) { c.json({ error: "Unauthorized" }, 401); return null; }
|
|
let claims: EncryptIDClaims;
|
|
try { claims = await verifyToken(token); } catch { c.json({ error: "Invalid token" }, 401); return null; }
|
|
const resolved = await resolveCallerRole(space, claims);
|
|
if (!resolved || !roleAtLeast(resolved.role, "member")) { c.json({ error: "Forbidden" }, 403); return null; }
|
|
return { claims, role: resolved.role };
|
|
}
|
|
|
|
async function requireAuth(c: any): Promise<EncryptIDClaims | null> {
|
|
const token = extractToken(c.req.raw);
|
|
if (!token) { c.json({ error: "Unauthorized" }, 401); return null; }
|
|
try { return await verifyToken(token); } catch { c.json({ error: "Invalid token" }, 401); return null; }
|
|
}
|
|
|
|
// ── Fetch + sync a single source ──
|
|
|
|
async function syncSource(space: string, sourceId: string): Promise<{ added: number; error?: string }> {
|
|
const doc = ensureDoc(space);
|
|
const source = doc.sources[sourceId];
|
|
if (!source) return { added: 0, error: "Source not found" };
|
|
|
|
try {
|
|
const res = await fetch(source.url, {
|
|
signal: AbortSignal.timeout(15_000),
|
|
headers: { "User-Agent": "rSpace-Feeds/1.0" },
|
|
});
|
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
const xml = await res.text();
|
|
const parsed = parseFeed(xml);
|
|
|
|
// Collect existing guids for this source
|
|
const existingGuids = new Set<string>();
|
|
for (const item of Object.values(doc.items)) {
|
|
if (item.sourceId === sourceId) existingGuids.add(item.guid);
|
|
}
|
|
|
|
// Filter new items
|
|
const newItems = parsed.items.filter((i) => !existingGuids.has(i.guid));
|
|
if (newItems.length === 0) {
|
|
// Update lastFetchedAt only
|
|
_syncServer!.changeDoc<FeedsDoc>(feedsDocId(space), "sync: no new items", (d) => {
|
|
if (d.sources[sourceId]) {
|
|
d.sources[sourceId].lastFetchedAt = Date.now();
|
|
d.sources[sourceId].lastError = null;
|
|
}
|
|
});
|
|
return { added: 0 };
|
|
}
|
|
|
|
_syncServer!.changeDoc<FeedsDoc>(feedsDocId(space), `sync: add ${newItems.length} items`, (d) => {
|
|
for (const item of newItems) {
|
|
const id = crypto.randomUUID();
|
|
d.items[id] = {
|
|
id,
|
|
sourceId,
|
|
guid: item.guid,
|
|
title: item.title,
|
|
url: item.url,
|
|
summary: item.summary,
|
|
author: item.author,
|
|
publishedAt: item.publishedAt,
|
|
importedAt: Date.now(),
|
|
reshared: false,
|
|
};
|
|
}
|
|
if (d.sources[sourceId]) {
|
|
d.sources[sourceId].lastFetchedAt = Date.now();
|
|
d.sources[sourceId].lastError = null;
|
|
}
|
|
});
|
|
|
|
// Prune excess items per source
|
|
pruneSourceItems(space, sourceId);
|
|
|
|
// Update item count
|
|
const updatedDoc = _syncServer!.getDoc<FeedsDoc>(feedsDocId(space))!;
|
|
const count = Object.values(updatedDoc.items).filter((i) => i.sourceId === sourceId).length;
|
|
_syncServer!.changeDoc<FeedsDoc>(feedsDocId(space), "update item count", (d) => {
|
|
if (d.sources[sourceId]) d.sources[sourceId].itemCount = count;
|
|
});
|
|
|
|
return { added: newItems.length };
|
|
} catch (err: any) {
|
|
_syncServer!.changeDoc<FeedsDoc>(feedsDocId(space), "sync error", (d) => {
|
|
if (d.sources[sourceId]) {
|
|
d.sources[sourceId].lastFetchedAt = Date.now();
|
|
d.sources[sourceId].lastError = err.message || "Unknown error";
|
|
}
|
|
});
|
|
return { added: 0, error: err.message };
|
|
}
|
|
}
|
|
|
|
function pruneSourceItems(space: string, sourceId: string) {
|
|
const doc = _syncServer!.getDoc<FeedsDoc>(feedsDocId(space))!;
|
|
const sourceItems = Object.values(doc.items)
|
|
.filter((i) => i.sourceId === sourceId)
|
|
.sort((a, b) => b.publishedAt - a.publishedAt);
|
|
|
|
if (sourceItems.length <= MAX_ITEMS_PER_SOURCE) return;
|
|
|
|
const toRemove = sourceItems.slice(MAX_ITEMS_PER_SOURCE);
|
|
_syncServer!.changeDoc<FeedsDoc>(feedsDocId(space), "prune old items", (d) => {
|
|
for (const item of toRemove) {
|
|
delete d.items[item.id];
|
|
}
|
|
});
|
|
}
|
|
|
|
// ── Activity sync (Phase 2) ──
|
|
|
|
function getBaseUrl(): string {
|
|
return process.env.BASE_URL || 'https://rspace.online';
|
|
}
|
|
|
|
function resolveModuleConfig(doc: FeedsDoc, moduleId: string): ActivityModuleConfig {
|
|
const saved = doc.settings.activityModules?.[moduleId];
|
|
if (saved) return saved;
|
|
const entry = adapterRegistry[moduleId];
|
|
return entry?.defaults || { enabled: false, label: moduleId, color: '#94a3b8' };
|
|
}
|
|
|
|
function runActivitySync(space: string, baseUrl: string): { synced: number; modules: string[] } {
|
|
if (!_syncServer) return { synced: 0, modules: [] };
|
|
const doc = ensureDoc(space);
|
|
const since = Date.now() - 7 * 24 * 60 * 60 * 1000; // 7 days
|
|
|
|
// Collect existing reshared flags to preserve
|
|
const resharedFlags = new Map<string, boolean>();
|
|
if (doc.activityCache) {
|
|
for (const [id, entry] of Object.entries(doc.activityCache)) {
|
|
if (entry.reshared) resharedFlags.set(id, true);
|
|
}
|
|
}
|
|
|
|
const allEntries: ActivityCacheEntry[] = [];
|
|
const syncedModules: string[] = [];
|
|
|
|
for (const [moduleId, { adapter }] of Object.entries(adapterRegistry)) {
|
|
const config = resolveModuleConfig(doc, moduleId);
|
|
if (!config.enabled) continue;
|
|
|
|
try {
|
|
const entries = adapter({
|
|
syncServer: _syncServer,
|
|
space,
|
|
since,
|
|
baseUrl,
|
|
});
|
|
// Preserve reshared flags
|
|
for (const entry of entries) {
|
|
if (resharedFlags.has(entry.id)) entry.reshared = true;
|
|
}
|
|
allEntries.push(...entries);
|
|
syncedModules.push(moduleId);
|
|
} catch (err) {
|
|
console.error(`[rFeeds] Activity adapter ${moduleId} failed for ${space}:`, err);
|
|
}
|
|
}
|
|
|
|
if (allEntries.length === 0 && syncedModules.length === 0) return { synced: 0, modules: [] };
|
|
|
|
// Sort by time, keep newest up to cap
|
|
allEntries.sort((a, b) => b.publishedAt - a.publishedAt);
|
|
const capped = allEntries.slice(0, MAX_ACTIVITY_CACHE);
|
|
|
|
_syncServer!.changeDoc<FeedsDoc>(feedsDocId(space), `activity sync: ${capped.length} entries`, (d) => {
|
|
// Replace cache entirely with fresh data
|
|
d.activityCache = {} as any;
|
|
for (const entry of capped) {
|
|
d.activityCache[entry.id] = entry;
|
|
}
|
|
});
|
|
|
|
return { synced: capped.length, modules: syncedModules };
|
|
}
|
|
|
|
// ── Background sync loop ──
|
|
|
|
const SYNC_INTERVAL_MS = 5 * 60 * 1000;
|
|
let _syncTimer: ReturnType<typeof setInterval> | null = null;
|
|
|
|
async function runBackgroundSync() {
|
|
if (!_syncServer) return;
|
|
const docIds = _syncServer.listDocs().filter((id: string) => id.endsWith(':rfeeds:data'));
|
|
const baseUrl = getBaseUrl();
|
|
|
|
for (const docId of docIds) {
|
|
const space = docId.split(':')[0];
|
|
const doc = _syncServer.getDoc<FeedsDoc>(docId);
|
|
if (!doc?.sources) continue;
|
|
|
|
// RSS source sync
|
|
for (const source of Object.values(doc.sources)) {
|
|
if (!source.enabled) continue;
|
|
const interval = source.intervalMs || DEFAULT_SYNC_INTERVAL_MS;
|
|
if (source.lastFetchedAt && source.lastFetchedAt + interval > Date.now()) continue;
|
|
|
|
try {
|
|
await syncSource(space, source.id);
|
|
} catch (err) {
|
|
console.error(`[rFeeds] Background sync failed for ${space}/${source.name}:`, err);
|
|
}
|
|
}
|
|
|
|
// Activity adapter sync
|
|
try {
|
|
runActivitySync(space, baseUrl);
|
|
} catch (err) {
|
|
console.error(`[rFeeds] Activity sync failed for ${space}:`, err);
|
|
}
|
|
}
|
|
}
|
|
|
|
function startSyncLoop() {
|
|
if (_syncTimer) return;
|
|
// Initial sync after 45s startup delay
|
|
setTimeout(() => {
|
|
runBackgroundSync().catch((err) => console.error('[rFeeds] Background sync error:', err));
|
|
}, 45_000);
|
|
_syncTimer = setInterval(() => {
|
|
runBackgroundSync().catch((err) => console.error('[rFeeds] Background sync error:', err));
|
|
}, SYNC_INTERVAL_MS);
|
|
}
|
|
|
|
// ── Timeline helper ──
|
|
|
|
interface TimelineEntry {
|
|
type: 'item' | 'post' | 'activity';
|
|
id: string;
|
|
title: string;
|
|
url: string;
|
|
summary: string;
|
|
author: string;
|
|
sourceName: string;
|
|
sourceColor: string;
|
|
publishedAt: number;
|
|
reshared: boolean;
|
|
moduleId?: string;
|
|
itemType?: string;
|
|
}
|
|
|
|
function buildTimeline(doc: FeedsDoc, limit: number, before?: number): TimelineEntry[] {
|
|
const entries: TimelineEntry[] = [];
|
|
|
|
for (const item of Object.values(doc.items)) {
|
|
const source = doc.sources[item.sourceId];
|
|
if (!source?.enabled) continue;
|
|
entries.push({
|
|
type: 'item',
|
|
id: item.id,
|
|
title: item.title,
|
|
url: item.url,
|
|
summary: item.summary,
|
|
author: item.author,
|
|
sourceName: source?.name || 'Unknown',
|
|
sourceColor: source?.color || '#94a3b8',
|
|
publishedAt: item.publishedAt,
|
|
reshared: item.reshared,
|
|
});
|
|
}
|
|
|
|
for (const post of Object.values(doc.posts)) {
|
|
entries.push({
|
|
type: 'post',
|
|
id: post.id,
|
|
title: '',
|
|
url: '',
|
|
summary: post.content,
|
|
author: post.authorName || post.authorDid,
|
|
sourceName: 'Manual Post',
|
|
sourceColor: '#67e8f9',
|
|
publishedAt: post.createdAt,
|
|
reshared: true, // manual posts always in atom feed
|
|
});
|
|
}
|
|
|
|
// Activity cache entries
|
|
if (doc.activityCache) {
|
|
for (const entry of Object.values(doc.activityCache)) {
|
|
const config = resolveModuleConfig(doc, entry.moduleId);
|
|
if (!config.enabled) continue;
|
|
entries.push({
|
|
type: 'activity',
|
|
id: entry.id,
|
|
title: entry.title,
|
|
url: entry.url,
|
|
summary: entry.summary,
|
|
author: entry.author,
|
|
sourceName: config.label,
|
|
sourceColor: config.color,
|
|
publishedAt: entry.publishedAt,
|
|
reshared: entry.reshared,
|
|
moduleId: entry.moduleId,
|
|
itemType: entry.itemType,
|
|
});
|
|
}
|
|
}
|
|
|
|
entries.sort((a, b) => b.publishedAt - a.publishedAt);
|
|
|
|
if (before) {
|
|
const idx = entries.findIndex((e) => e.publishedAt < before);
|
|
if (idx === -1) return [];
|
|
return entries.slice(idx, idx + limit);
|
|
}
|
|
|
|
return entries.slice(0, limit);
|
|
}
|
|
|
|
// ── Atom 1.0 generator ──
|
|
|
|
function escapeXml(s: string): string {
|
|
return s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
|
}
|
|
|
|
function generateAtom(space: string, doc: FeedsDoc, baseUrl: string): string {
|
|
const title = doc.settings.feedTitle || `${space} — rFeeds`;
|
|
const description = doc.settings.feedDescription || `Community feed for ${space}`;
|
|
const feedUrl = `${baseUrl}/${space}/rfeeds/feed.xml`;
|
|
const siteUrl = baseUrl;
|
|
|
|
// Collect reshared items + all manual posts + reshared activity
|
|
const entries: { id: string; title: string; url: string; summary: string; author: string; publishedAt: number }[] = [];
|
|
|
|
for (const item of Object.values(doc.items)) {
|
|
if (!item.reshared) continue;
|
|
entries.push(item);
|
|
}
|
|
for (const post of Object.values(doc.posts)) {
|
|
entries.push({
|
|
id: post.id,
|
|
title: `Post by ${post.authorName || 'member'}`,
|
|
url: '',
|
|
summary: post.content,
|
|
author: post.authorName || post.authorDid,
|
|
publishedAt: post.createdAt,
|
|
});
|
|
}
|
|
// Include reshared activity cache entries
|
|
if (doc.activityCache) {
|
|
for (const entry of Object.values(doc.activityCache)) {
|
|
if (!entry.reshared) continue;
|
|
entries.push({
|
|
id: entry.id,
|
|
title: entry.title,
|
|
url: entry.url,
|
|
summary: entry.summary,
|
|
author: entry.author,
|
|
publishedAt: entry.publishedAt,
|
|
});
|
|
}
|
|
}
|
|
|
|
entries.sort((a, b) => b.publishedAt - a.publishedAt);
|
|
const latest = entries[0]?.publishedAt || Date.now();
|
|
|
|
const atomEntries = entries.slice(0, 100).map((e) => ` <entry>
|
|
<id>urn:rspace:${escapeXml(space)}:${escapeXml(e.id)}</id>
|
|
<title>${escapeXml(e.title)}</title>
|
|
<summary>${escapeXml(e.summary)}</summary>
|
|
${e.url ? `<link href="${escapeXml(e.url)}" rel="alternate"/>` : ''}
|
|
<author><name>${escapeXml(e.author)}</name></author>
|
|
<updated>${new Date(e.publishedAt).toISOString()}</updated>
|
|
</entry>`).join('\n');
|
|
|
|
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
<feed xmlns="http://www.w3.org/2005/Atom">
|
|
<title>${escapeXml(title)}</title>
|
|
<subtitle>${escapeXml(description)}</subtitle>
|
|
<link href="${escapeXml(feedUrl)}" rel="self" type="application/atom+xml"/>
|
|
<link href="${escapeXml(siteUrl)}" rel="alternate"/>
|
|
<id>urn:rspace:${escapeXml(space)}:rfeeds</id>
|
|
<updated>${new Date(latest).toISOString()}</updated>
|
|
<generator>rSpace rFeeds</generator>
|
|
${atomEntries}
|
|
</feed>`;
|
|
}
|
|
|
|
// ── User feed Atom generator ──
|
|
|
|
function generateUserAtom(space: string, did: string, profile: UserFeedProfile, entries: ActivityCacheEntry[], baseUrl: string): string {
|
|
const title = `${profile.displayName || did.slice(0, 16)} — rFeeds`;
|
|
const description = profile.bio || `Personal feed for ${profile.displayName || did}`;
|
|
const feedUrl = `${baseUrl}/${space}/rfeeds/user/${encodeURIComponent(did)}/feed.xml`;
|
|
const siteUrl = baseUrl;
|
|
|
|
entries.sort((a, b) => b.publishedAt - a.publishedAt);
|
|
const latest = entries[0]?.publishedAt || Date.now();
|
|
|
|
const atomEntries = entries.slice(0, 100).map((e) => ` <entry>
|
|
<id>urn:rspace:${escapeXml(space)}:user:${escapeXml(did)}:${escapeXml(e.id)}</id>
|
|
<title>${escapeXml(e.title)}</title>
|
|
<summary>${escapeXml(e.summary)}</summary>
|
|
${e.url ? `<link href="${escapeXml(e.url)}" rel="alternate"/>` : ''}
|
|
<author><name>${escapeXml(e.author || profile.displayName || did)}</name></author>
|
|
<updated>${new Date(e.publishedAt).toISOString()}</updated>
|
|
</entry>`).join('\n');
|
|
|
|
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
<feed xmlns="http://www.w3.org/2005/Atom">
|
|
<title>${escapeXml(title)}</title>
|
|
<subtitle>${escapeXml(description)}</subtitle>
|
|
<link href="${escapeXml(feedUrl)}" rel="self" type="application/atom+xml"/>
|
|
<link href="${escapeXml(siteUrl)}" rel="alternate"/>
|
|
<id>urn:rspace:${escapeXml(space)}:user:${escapeXml(did)}</id>
|
|
<updated>${new Date(latest).toISOString()}</updated>
|
|
<generator>rSpace rFeeds</generator>
|
|
${atomEntries}
|
|
</feed>`;
|
|
}
|
|
|
|
// ── Routes ──
|
|
|
|
// Page route
|
|
routes.get("/", (c) => {
|
|
const space = c.req.param("space") || "demo";
|
|
const dataSpace = (c as any).get("effectiveSpace") || space;
|
|
ensureDoc(dataSpace);
|
|
|
|
return c.html(renderShell({
|
|
title: `rFeeds — ${space}`,
|
|
moduleId: "rfeeds",
|
|
spaceSlug: space,
|
|
body: `<folk-feeds-dashboard space="${dataSpace}"></folk-feeds-dashboard>`,
|
|
scripts: `<script type="module" src="/modules/rfeeds/folk-feeds-dashboard.js"></script>`,
|
|
styles: `<link rel="stylesheet" href="/modules/rfeeds/feeds.css">`,
|
|
modules: getModuleInfoList(),
|
|
}));
|
|
});
|
|
|
|
// Atom feed (public, no auth)
|
|
routes.get("/feed.xml", (c) => {
|
|
const space = c.req.param("space") || "demo";
|
|
const dataSpace = (c as any).get("effectiveSpace") || space;
|
|
const doc = ensureDoc(dataSpace);
|
|
const proto = c.req.header("x-forwarded-proto") || "https";
|
|
const host = c.req.header("x-forwarded-host") || c.req.header("host") || "rspace.online";
|
|
const baseUrl = `${proto}://${host}`;
|
|
|
|
c.header("Content-Type", "application/atom+xml; charset=utf-8");
|
|
c.header("Cache-Control", "public, max-age=300");
|
|
return c.body(generateAtom(space, doc, baseUrl));
|
|
});
|
|
|
|
// Timeline (public read)
|
|
routes.get("/api/timeline", (c) => {
|
|
const space = c.req.param("space") || "demo";
|
|
const dataSpace = (c as any).get("effectiveSpace") || space;
|
|
const doc = ensureDoc(dataSpace);
|
|
const limit = Math.min(parseInt(c.req.query("limit") || "50"), 200);
|
|
const before = c.req.query("before") ? parseInt(c.req.query("before")!) : undefined;
|
|
|
|
return c.json(buildTimeline(doc, limit, before));
|
|
});
|
|
|
|
// Sources (public read)
|
|
routes.get("/api/sources", (c) => {
|
|
const space = c.req.param("space") || "demo";
|
|
const dataSpace = (c as any).get("effectiveSpace") || space;
|
|
const doc = ensureDoc(dataSpace);
|
|
const sources = Object.values(doc.sources).sort((a, b) => b.addedAt - a.addedAt);
|
|
return c.json(sources);
|
|
});
|
|
|
|
// Add source (member+)
|
|
routes.post("/api/sources", async (c) => {
|
|
const space = c.req.param("space") || "demo";
|
|
const dataSpace = (c as any).get("effectiveSpace") || space;
|
|
const auth = await requireMember(c, dataSpace);
|
|
if (!auth) return;
|
|
|
|
const body = await c.req.json<{ url: string; name?: string; color?: string }>();
|
|
if (!body.url) return c.json({ error: "url required" }, 400);
|
|
|
|
// Validate URL
|
|
try { new URL(body.url); } catch { return c.json({ error: "Invalid URL" }, 400); }
|
|
|
|
const id = crypto.randomUUID();
|
|
const source: FeedSource = {
|
|
id,
|
|
url: body.url,
|
|
name: body.name || new URL(body.url).hostname,
|
|
color: body.color || '#94a3b8',
|
|
enabled: true,
|
|
lastFetchedAt: 0,
|
|
lastError: null,
|
|
itemCount: 0,
|
|
intervalMs: DEFAULT_SYNC_INTERVAL_MS,
|
|
addedBy: (auth.claims.did as string) || (auth.claims.sub as string),
|
|
addedAt: Date.now(),
|
|
};
|
|
|
|
_syncServer!.changeDoc<FeedsDoc>(feedsDocId(dataSpace), "add feed source", (d) => {
|
|
d.sources[id] = source;
|
|
});
|
|
|
|
// Trigger initial sync in background
|
|
syncSource(dataSpace, id).catch(() => {});
|
|
|
|
return c.json(source, 201);
|
|
});
|
|
|
|
// Update source (member+)
|
|
routes.put("/api/sources/:id", async (c) => {
|
|
const space = c.req.param("space") || "demo";
|
|
const dataSpace = (c as any).get("effectiveSpace") || space;
|
|
const auth = await requireMember(c, dataSpace);
|
|
if (!auth) return;
|
|
|
|
const sourceId = c.req.param("id");
|
|
const body = await c.req.json<{ name?: string; color?: string; enabled?: boolean; intervalMs?: number }>();
|
|
|
|
const doc = ensureDoc(dataSpace);
|
|
if (!doc.sources[sourceId]) return c.json({ error: "Not found" }, 404);
|
|
|
|
_syncServer!.changeDoc<FeedsDoc>(feedsDocId(dataSpace), "update feed source", (d) => {
|
|
const s = d.sources[sourceId];
|
|
if (!s) return;
|
|
if (body.name !== undefined) s.name = body.name;
|
|
if (body.color !== undefined) s.color = body.color;
|
|
if (body.enabled !== undefined) s.enabled = body.enabled;
|
|
if (body.intervalMs !== undefined) s.intervalMs = body.intervalMs;
|
|
});
|
|
|
|
const updated = _syncServer!.getDoc<FeedsDoc>(feedsDocId(dataSpace))!;
|
|
return c.json(updated.sources[sourceId]);
|
|
});
|
|
|
|
// Delete source (member+)
|
|
routes.delete("/api/sources/:id", async (c) => {
|
|
const space = c.req.param("space") || "demo";
|
|
const dataSpace = (c as any).get("effectiveSpace") || space;
|
|
const auth = await requireMember(c, dataSpace);
|
|
if (!auth) return;
|
|
|
|
const sourceId = c.req.param("id");
|
|
const doc = ensureDoc(dataSpace);
|
|
if (!doc.sources[sourceId]) return c.json({ error: "Not found" }, 404);
|
|
|
|
// Remove source + its items
|
|
_syncServer!.changeDoc<FeedsDoc>(feedsDocId(dataSpace), "delete feed source", (d) => {
|
|
delete d.sources[sourceId];
|
|
for (const [itemId, item] of Object.entries(d.items)) {
|
|
if (item.sourceId === sourceId) delete d.items[itemId];
|
|
}
|
|
});
|
|
|
|
return c.json({ ok: true });
|
|
});
|
|
|
|
// Force sync one source (member+)
|
|
routes.post("/api/sources/:id/sync", async (c) => {
|
|
const space = c.req.param("space") || "demo";
|
|
const dataSpace = (c as any).get("effectiveSpace") || space;
|
|
const auth = await requireMember(c, dataSpace);
|
|
if (!auth) return;
|
|
|
|
const sourceId = c.req.param("id");
|
|
const result = await syncSource(dataSpace, sourceId);
|
|
return c.json(result);
|
|
});
|
|
|
|
// Import OPML (member+)
|
|
routes.post("/api/sources/import-opml", async (c) => {
|
|
const space = c.req.param("space") || "demo";
|
|
const dataSpace = (c as any).get("effectiveSpace") || space;
|
|
const auth = await requireMember(c, dataSpace);
|
|
if (!auth) return;
|
|
|
|
const body = await c.req.json<{ opml: string }>();
|
|
if (!body.opml) return c.json({ error: "opml required" }, 400);
|
|
|
|
let feeds: { url: string; name: string }[];
|
|
try { feeds = parseOPML(body.opml); } catch (err: any) {
|
|
return c.json({ error: `Invalid OPML: ${err.message}` }, 400);
|
|
}
|
|
|
|
const added: string[] = [];
|
|
const did = (auth.claims.did as string) || (auth.claims.sub as string);
|
|
|
|
for (const feed of feeds) {
|
|
// Skip duplicates
|
|
const doc = _syncServer!.getDoc<FeedsDoc>(feedsDocId(dataSpace))!;
|
|
const exists = Object.values(doc.sources).some((s) => s.url === feed.url);
|
|
if (exists) continue;
|
|
|
|
const id = crypto.randomUUID();
|
|
_syncServer!.changeDoc<FeedsDoc>(feedsDocId(dataSpace), `import: ${feed.name}`, (d) => {
|
|
d.sources[id] = {
|
|
id,
|
|
url: feed.url,
|
|
name: feed.name,
|
|
color: '#94a3b8',
|
|
enabled: true,
|
|
lastFetchedAt: 0,
|
|
lastError: null,
|
|
itemCount: 0,
|
|
intervalMs: DEFAULT_SYNC_INTERVAL_MS,
|
|
addedBy: did,
|
|
addedAt: Date.now(),
|
|
};
|
|
});
|
|
added.push(feed.name);
|
|
|
|
// Background sync each
|
|
syncSource(dataSpace, id).catch(() => {});
|
|
}
|
|
|
|
return c.json({ added: added.length, names: added });
|
|
});
|
|
|
|
// Create manual post (member+)
|
|
routes.post("/api/posts", async (c) => {
|
|
const space = c.req.param("space") || "demo";
|
|
const dataSpace = (c as any).get("effectiveSpace") || space;
|
|
const auth = await requireMember(c, dataSpace);
|
|
if (!auth) return;
|
|
|
|
const body = await c.req.json<{ content: string }>();
|
|
if (!body.content?.trim()) return c.json({ error: "content required" }, 400);
|
|
|
|
const id = crypto.randomUUID();
|
|
const did = (auth.claims.did as string) || (auth.claims.sub as string);
|
|
|
|
_syncServer!.changeDoc<FeedsDoc>(feedsDocId(dataSpace), "create post", (d) => {
|
|
d.posts[id] = {
|
|
id,
|
|
content: body.content.trim().slice(0, 2000),
|
|
authorDid: did,
|
|
authorName: (auth.claims as any).displayName || did.slice(0, 12),
|
|
createdAt: Date.now(),
|
|
updatedAt: Date.now(),
|
|
};
|
|
});
|
|
|
|
const doc = _syncServer!.getDoc<FeedsDoc>(feedsDocId(dataSpace))!;
|
|
return c.json(doc.posts[id], 201);
|
|
});
|
|
|
|
// Edit own post (author only)
|
|
routes.put("/api/posts/:id", async (c) => {
|
|
const space = c.req.param("space") || "demo";
|
|
const dataSpace = (c as any).get("effectiveSpace") || space;
|
|
const auth = await requireMember(c, dataSpace);
|
|
if (!auth) return;
|
|
|
|
const postId = c.req.param("id");
|
|
const doc = ensureDoc(dataSpace);
|
|
const post = doc.posts[postId];
|
|
if (!post) return c.json({ error: "Not found" }, 404);
|
|
|
|
const callerDid = (auth.claims.did as string) || (auth.claims.sub as string);
|
|
if (post.authorDid !== callerDid) return c.json({ error: "Forbidden" }, 403);
|
|
|
|
const body = await c.req.json<{ content: string }>();
|
|
if (!body.content?.trim()) return c.json({ error: "content required" }, 400);
|
|
|
|
_syncServer!.changeDoc<FeedsDoc>(feedsDocId(dataSpace), "edit post", (d) => {
|
|
if (d.posts[postId]) {
|
|
d.posts[postId].content = body.content.trim().slice(0, 2000);
|
|
d.posts[postId].updatedAt = Date.now();
|
|
}
|
|
});
|
|
|
|
const updated = _syncServer!.getDoc<FeedsDoc>(feedsDocId(dataSpace))!;
|
|
return c.json(updated.posts[postId]);
|
|
});
|
|
|
|
// Delete post (author or admin)
|
|
routes.delete("/api/posts/:id", async (c) => {
|
|
const space = c.req.param("space") || "demo";
|
|
const dataSpace = (c as any).get("effectiveSpace") || space;
|
|
const auth = await requireMember(c, dataSpace);
|
|
if (!auth) return;
|
|
|
|
const postId = c.req.param("id");
|
|
const doc = ensureDoc(dataSpace);
|
|
const post = doc.posts[postId];
|
|
if (!post) return c.json({ error: "Not found" }, 404);
|
|
|
|
const callerDid = (auth.claims.did as string) || (auth.claims.sub as string);
|
|
const isAuthor = post.authorDid === callerDid;
|
|
const isAdmin = roleAtLeast(auth.role, "admin");
|
|
if (!isAuthor && !isAdmin) return c.json({ error: "Forbidden" }, 403);
|
|
|
|
_syncServer!.changeDoc<FeedsDoc>(feedsDocId(dataSpace), "delete post", (d) => {
|
|
delete d.posts[postId];
|
|
});
|
|
|
|
return c.json({ ok: true });
|
|
});
|
|
|
|
// Toggle reshare on item (member+)
|
|
routes.patch("/api/items/:id/reshare", async (c) => {
|
|
const space = c.req.param("space") || "demo";
|
|
const dataSpace = (c as any).get("effectiveSpace") || space;
|
|
const auth = await requireMember(c, dataSpace);
|
|
if (!auth) return;
|
|
|
|
const itemId = c.req.param("id");
|
|
const doc = ensureDoc(dataSpace);
|
|
if (!doc.items[itemId]) return c.json({ error: "Not found" }, 404);
|
|
|
|
const current = doc.items[itemId].reshared;
|
|
_syncServer!.changeDoc<FeedsDoc>(feedsDocId(dataSpace), "toggle reshare", (d) => {
|
|
if (d.items[itemId]) d.items[itemId].reshared = !current;
|
|
});
|
|
|
|
return c.json({ reshared: !current });
|
|
});
|
|
|
|
// ── Activity routes (Phase 2) ──
|
|
|
|
// Activity module config (public read)
|
|
routes.get("/api/activity/config", (c) => {
|
|
const space = c.req.param("space") || "demo";
|
|
const dataSpace = (c as any).get("effectiveSpace") || space;
|
|
const doc = ensureDoc(dataSpace);
|
|
|
|
const config: Record<string, ActivityModuleConfig> = {};
|
|
for (const moduleId of Object.keys(adapterRegistry)) {
|
|
config[moduleId] = resolveModuleConfig(doc, moduleId);
|
|
}
|
|
return c.json(config);
|
|
});
|
|
|
|
// Update activity module config (admin)
|
|
routes.put("/api/activity/config", async (c) => {
|
|
const space = c.req.param("space") || "demo";
|
|
const dataSpace = (c as any).get("effectiveSpace") || space;
|
|
const auth = await requireMember(c, dataSpace);
|
|
if (!auth) return;
|
|
if (!roleAtLeast(auth.role, "admin")) return c.json({ error: "Admin required" }, 403);
|
|
|
|
const body = await c.req.json<{ modules: Record<string, ActivityModuleConfig> }>();
|
|
if (!body.modules) return c.json({ error: "modules required" }, 400);
|
|
|
|
_syncServer!.changeDoc<FeedsDoc>(feedsDocId(dataSpace), "update activity config", (d) => {
|
|
for (const [moduleId, config] of Object.entries(body.modules)) {
|
|
if (!adapterRegistry[moduleId]) continue;
|
|
d.settings.activityModules[moduleId] = config;
|
|
}
|
|
});
|
|
|
|
const updated = _syncServer!.getDoc<FeedsDoc>(feedsDocId(dataSpace))!;
|
|
const result: Record<string, ActivityModuleConfig> = {};
|
|
for (const moduleId of Object.keys(adapterRegistry)) {
|
|
result[moduleId] = resolveModuleConfig(updated, moduleId);
|
|
}
|
|
return c.json(result);
|
|
});
|
|
|
|
// Trigger on-demand activity sync (member+)
|
|
routes.post("/api/activity/sync", async (c) => {
|
|
const space = c.req.param("space") || "demo";
|
|
const dataSpace = (c as any).get("effectiveSpace") || space;
|
|
const auth = await requireMember(c, dataSpace);
|
|
if (!auth) return;
|
|
|
|
const baseUrl = getBaseUrl();
|
|
const result = runActivitySync(dataSpace, baseUrl);
|
|
return c.json(result);
|
|
});
|
|
|
|
// Toggle reshare on activity cache entry (member+)
|
|
routes.patch("/api/activity/:id/reshare", async (c) => {
|
|
const space = c.req.param("space") || "demo";
|
|
const dataSpace = (c as any).get("effectiveSpace") || space;
|
|
const auth = await requireMember(c, dataSpace);
|
|
if (!auth) return;
|
|
|
|
const entryId = decodeURIComponent(c.req.param("id"));
|
|
const doc = ensureDoc(dataSpace);
|
|
if (!doc.activityCache?.[entryId]) return c.json({ error: "Not found" }, 404);
|
|
|
|
const current = doc.activityCache[entryId].reshared;
|
|
_syncServer!.changeDoc<FeedsDoc>(feedsDocId(dataSpace), "toggle activity reshare", (d) => {
|
|
if (d.activityCache[entryId]) d.activityCache[entryId].reshared = !current;
|
|
});
|
|
|
|
return c.json({ reshared: !current });
|
|
});
|
|
|
|
// ── User profile routes (Phase 2) ──
|
|
|
|
// Own profile (auth required)
|
|
routes.get("/api/profile", async (c) => {
|
|
const space = c.req.param("space") || "demo";
|
|
const dataSpace = (c as any).get("effectiveSpace") || space;
|
|
const claims = await requireAuth(c);
|
|
if (!claims) return;
|
|
|
|
const did = (claims.did as string) || (claims.sub as string);
|
|
const doc = ensureDoc(dataSpace);
|
|
const profile = doc.userProfiles?.[did] || null;
|
|
return c.json(profile);
|
|
});
|
|
|
|
// Update own profile (auth required)
|
|
routes.put("/api/profile", async (c) => {
|
|
const space = c.req.param("space") || "demo";
|
|
const dataSpace = (c as any).get("effectiveSpace") || space;
|
|
const claims = await requireAuth(c);
|
|
if (!claims) return;
|
|
|
|
const did = (claims.did as string) || (claims.sub as string);
|
|
const body = await c.req.json<{ displayName?: string; bio?: string; publishModules?: string[] }>();
|
|
|
|
const doc = ensureDoc(dataSpace);
|
|
const existing = doc.userProfiles?.[did];
|
|
const now = Date.now();
|
|
|
|
_syncServer!.changeDoc<FeedsDoc>(feedsDocId(dataSpace), "update user profile", (d) => {
|
|
if (!d.userProfiles[did]) {
|
|
d.userProfiles[did] = {
|
|
did,
|
|
displayName: body.displayName || (claims as any).displayName || did.slice(0, 16),
|
|
bio: body.bio || '',
|
|
publishModules: body.publishModules || [],
|
|
createdAt: now,
|
|
updatedAt: now,
|
|
};
|
|
} else {
|
|
if (body.displayName !== undefined) d.userProfiles[did].displayName = body.displayName;
|
|
if (body.bio !== undefined) d.userProfiles[did].bio = body.bio;
|
|
if (body.publishModules !== undefined) d.userProfiles[did].publishModules = body.publishModules as any;
|
|
d.userProfiles[did].updatedAt = now;
|
|
}
|
|
});
|
|
|
|
const updated = _syncServer!.getDoc<FeedsDoc>(feedsDocId(dataSpace))!;
|
|
return c.json(updated.userProfiles[did]);
|
|
});
|
|
|
|
// Read any user's profile (public)
|
|
routes.get("/api/profile/:did", (c) => {
|
|
const space = c.req.param("space") || "demo";
|
|
const dataSpace = (c as any).get("effectiveSpace") || space;
|
|
const did = decodeURIComponent(c.req.param("did"));
|
|
const doc = ensureDoc(dataSpace);
|
|
const profile = doc.userProfiles?.[did];
|
|
if (!profile) return c.json({ error: "Profile not found" }, 404);
|
|
return c.json(profile);
|
|
});
|
|
|
|
// Personal Atom feed (public)
|
|
routes.get("/user/:did/feed.xml", (c) => {
|
|
const space = c.req.param("space") || "demo";
|
|
const dataSpace = (c as any).get("effectiveSpace") || space;
|
|
const did = decodeURIComponent(c.req.param("did"));
|
|
const doc = ensureDoc(dataSpace);
|
|
const profile = doc.userProfiles?.[did];
|
|
if (!profile) return c.text("Profile not found", 404);
|
|
|
|
const proto = c.req.header("x-forwarded-proto") || "https";
|
|
const host = c.req.header("x-forwarded-host") || c.req.header("host") || "rspace.online";
|
|
const baseUrl = `${proto}://${host}`;
|
|
|
|
// Collect activity entries matching user's published modules
|
|
const publishSet = new Set(profile.publishModules || []);
|
|
const entries: ActivityCacheEntry[] = [];
|
|
|
|
if (doc.activityCache) {
|
|
for (const entry of Object.values(doc.activityCache)) {
|
|
if (!publishSet.has(entry.moduleId)) continue;
|
|
entries.push(entry);
|
|
}
|
|
}
|
|
|
|
// Also include user's manual posts as pseudo-entries
|
|
if (publishSet.has('posts') || publishSet.size === 0) {
|
|
for (const post of Object.values(doc.posts)) {
|
|
if (post.authorDid !== did) continue;
|
|
entries.push({
|
|
id: `post:${post.id}`,
|
|
moduleId: 'posts',
|
|
itemType: 'post',
|
|
title: `Post by ${post.authorName || 'member'}`,
|
|
summary: post.content.slice(0, 280),
|
|
url: '',
|
|
author: post.authorName || post.authorDid,
|
|
publishedAt: post.createdAt,
|
|
reshared: true,
|
|
syncedAt: post.updatedAt,
|
|
});
|
|
}
|
|
}
|
|
|
|
c.header("Content-Type", "application/atom+xml; charset=utf-8");
|
|
c.header("Cache-Control", "public, max-age=300");
|
|
return c.body(generateUserAtom(space, did, profile, entries, baseUrl));
|
|
});
|
|
|
|
// ── Module export ──
|
|
|
|
export const feedsModule: RSpaceModule = {
|
|
id: "rfeeds",
|
|
name: "rFeeds",
|
|
icon: "\uD83D\uDCE1",
|
|
description: "Community RSS dashboard — subscribe, curate, republish",
|
|
routes,
|
|
scoping: { defaultScope: "space", userConfigurable: false },
|
|
docSchemas: [feedsSchema as any],
|
|
standaloneDomain: "rfeeds.online",
|
|
landingPage: renderLanding,
|
|
|
|
onInit: async ({ syncServer }) => {
|
|
_syncServer = syncServer;
|
|
startSyncLoop();
|
|
},
|
|
|
|
onSpaceCreate: async ({ spaceSlug }) => {
|
|
if (_syncServer) ensureDoc(spaceSlug);
|
|
},
|
|
};
|