rspace-online/modules/rfeeds/mod.ts

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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
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);
},
};