diff --git a/CHANGELOG.md b/CHANGELOG.md
index e2af3e6..9c2c801 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -22,6 +22,8 @@
### Added
+- Added read-only RSS sources: validate a feed URL, queue recurring imports, and show imported posts under their RSS source rather than a Hub account.
+
- 文章顯示時隱藏已辨識的內文 hashtag,保留原始 Markdown 與文章底部標籤。
- 具時間範圍篩選的公開標籤雲與標籤導覽入口。
- 瀏覽器端自動儲存的發文草稿與 Markdown 預覽。
diff --git a/app/api/sources/rss/route.ts b/app/api/sources/rss/route.ts
new file mode 100644
index 0000000..5f2a6f7
--- /dev/null
+++ b/app/api/sources/rss/route.ts
@@ -0,0 +1,17 @@
+import { NextResponse } from "next/server";
+import { requireUser } from "@/lib/auth";
+import { encrypt } from "@/lib/crypto";
+import { db } from "@/lib/db";
+import { externalUrl } from "@/lib/http";
+import { fetchRss } from "@/lib/rss";
+import { requireSameOrigin } from "@/lib/security";
+import { queuePull } from "@/lib/sync";
+
+export async function POST(request: Request) {
+ try { requireSameOrigin(request); const user = await requireUser(); const form = await request.formData(); const raw = String(form.get("feedUrl") || "").trim(); const url = new URL(raw); if (url.protocol !== "https:") throw new Error("RSS feed must use HTTPS"); const items = await fetchRss(url.toString()); if (!items.length) throw new Error("RSS feed has no items");
+ const existing = db.prepare("SELECT id FROM sources WHERE user_id=? AND rss_feed_url=?").get(user.id, url.toString()) as { id: number } | undefined;
+ if (existing) { queuePull(existing.id, "manual"); return NextResponse.redirect(externalUrl(request, "/dashboard?source=rss-refreshed")); }
+ const name = `RSS · ${url.hostname}`; const out = db.prepare("INSERT INTO sources(user_id,name,base_url,token_encrypted,integration_type,rss_feed_url,sync_status) VALUES(?,?,?,?, 'rss',?, 'queued')").run(user.id, name, url.origin, encrypt("rss-read-only"), url.toString()); const id = Number(out.lastInsertRowid);
+ db.prepare("INSERT INTO source_members(source_id,user_id,role) VALUES(?,?,'owner')").run(id, user.id); queuePull(id, "source-created"); return NextResponse.redirect(externalUrl(request, "/dashboard?source=rss-connected"));
+ } catch (error) { return NextResponse.redirect(externalUrl(request, "/dashboard?error=" + encodeURIComponent(error instanceof Error ? error.message : "rss"))); }
+}
diff --git a/app/components/post-card.tsx b/app/components/post-card.tsx
index 43c1060..daba735 100644
--- a/app/components/post-card.tsx
+++ b/app/components/post-card.tsx
@@ -7,5 +7,5 @@ export type PublicPost = { id: number; source_id: number | null; origin: string;
export function PostCard({ post }: { post: PublicPost }) {
let tags: string[] = []; try { tags = canonicalTags(JSON.parse(post.tags_json)); } catch { /* Ignore malformed legacy tags. */ }
- const publishedAt = post.remote_created_at || post.created_at; const author = post.origin === "memos" ? (post.remote_display_name || post.name || post.username) : post.username; return @{author}{post.name ? ` · ${post.name}` : ""}{new Date(publishedAt).toLocaleString("zh-TW")}
{tags.map((tag) => #{tag})}{post.source_id && 來源}閱讀全文 · 💬 {post.comment_count} 🙂 {post.reaction_count}
;
+ const publishedAt = post.remote_created_at || post.created_at; const author = post.origin === "memos" ? (post.remote_display_name || post.name || post.username) : (post.name || post.username); return @{author}{post.name ? ` · ${post.name}` : ""}{new Date(publishedAt).toLocaleString("zh-TW")}
{tags.map((tag) => #{tag})}{post.source_id && 來源}閱讀全文 · 💬 {post.comment_count} 🙂 {post.reaction_count}
;
}
diff --git a/app/dashboard/page.tsx b/app/dashboard/page.tsx
index a764253..84fe9d9 100644
--- a/app/dashboard/page.tsx
+++ b/app/dashboard/page.tsx
@@ -4,16 +4,16 @@ import { db } from "@/lib/db";
import { PublishForm } from "./publish-form";
import { WebhookControl } from "./webhook-control";
-type Source = { id: number; name: string; base_url: string; sync_status: string; last_synced_at: string | null; last_error: string | null; webhook_secret_hash: string | null; last_webhook_at: string | null; owner_id: number; is_enabled: number; disabled_at: string | null; sync_tags_json: string; sync_from: string | null; sync_to: string | null; sync_attachment_mode: "all" | "images" | "none"; attachment_storage_mode: "remote" | "images" | "all"; attachment_cache_limit_bytes: number; attachment_cache_error: string | null; remote_display_name: string | null; remote_avatar_url: string | null; last_connection_at: string | null; last_connection_error: string | null };
+type Source = { id: number; name: string; base_url: string; integration_type: "memos" | "rss"; rss_feed_url: string | null; sync_status: string; last_synced_at: string | null; last_error: string | null; webhook_secret_hash: string | null; last_webhook_at: string | null; owner_id: number; is_enabled: number; disabled_at: string | null; sync_tags_json: string; sync_from: string | null; sync_to: string | null; sync_attachment_mode: "all" | "images" | "none"; attachment_storage_mode: "remote" | "images" | "all"; attachment_cache_limit_bytes: number; attachment_cache_error: string | null; remote_display_name: string | null; remote_avatar_url: string | null; last_connection_at: string | null; last_connection_error: string | null };
type Job = { id: number; kind: string; trigger: string | null; status: string; attempts: number; last_error: string | null; created_at: string; finished_at: string | null };
export const dynamic = "force-dynamic";
export default async function Dashboard({ searchParams }: { searchParams: Promise<{ error?: string; source?: string; sync?: string }> }) {
const query = await searchParams; const user = await getSession(); if (!user) redirect("/login");
- const sourceRows = db.prepare("SELECT s.id,s.name,s.base_url,s.sync_status,s.last_synced_at,s.last_error,s.webhook_secret_hash,s.last_webhook_at,s.user_id AS owner_id,s.is_enabled,s.disabled_at,s.sync_tags_json,s.sync_from,s.sync_to,s.sync_attachment_mode,s.attachment_storage_mode,s.attachment_cache_limit_bytes,s.attachment_cache_error,s.remote_display_name,s.remote_avatar_url,s.last_connection_at,s.last_connection_error FROM sources s JOIN source_members sm ON sm.source_id=s.id WHERE sm.user_id=? ORDER BY s.id DESC").all(user.id) as Source[];
+ const sourceRows = db.prepare("SELECT s.id,s.name,s.base_url,s.integration_type,s.rss_feed_url,s.sync_status,s.last_synced_at,s.last_error,s.webhook_secret_hash,s.last_webhook_at,s.user_id AS owner_id,s.is_enabled,s.disabled_at,s.sync_tags_json,s.sync_from,s.sync_to,s.sync_attachment_mode,s.attachment_storage_mode,s.attachment_cache_limit_bytes,s.attachment_cache_error,s.remote_display_name,s.remote_avatar_url,s.last_connection_at,s.last_connection_error FROM sources s JOIN source_members sm ON sm.source_id=s.id WHERE sm.user_id=? ORDER BY s.id DESC").all(user.id) as Source[];
const sources = sourceRows.map((source) => ({ ...source, syncTags: (() => { try { return JSON.parse(source.sync_tags_json) as string[]; } catch { return []; } })(), members: db.prepare("SELECT u.username,u.id,sm.role FROM source_members sm JOIN users u ON u.id=sm.user_id WHERE sm.source_id=? ORDER BY sm.role DESC,u.username").all(source.id) as { username: string; id: number; role: string }[], jobs: db.prepare("SELECT id,kind,trigger,status,attempts,last_error,created_at,finished_at FROM sync_jobs WHERE source_id=? ORDER BY id DESC LIMIT 5").all(source.id) as Job[] }));
- const publishSources = sources.filter((source) => source.is_enabled);
+ const publishSources = sources.filter((source) => source.is_enabled && source.integration_type === "memos");
return <>
控制台
{query.error && {query.error}
}
@@ -21,11 +21,12 @@ export default async function Dashboard({ searchParams }: { searchParams: Promis
{query.sync === "queued" && 同步已排入佇列。
}{query.sync === "already-queued" && 此來源已有同步工作處理中,不重複排入。
}
發佈到自己的 Memos
{publishSources.length ? : 請先連接並啟用一個 Memos 來源。
}
連接 Memos
來源名稱會自動使用 API Key 對應的 Memos 帳號。Token 會使用伺服器金鑰加密保存;同一個 Memos 帳號與網址會自動共用來源,不會建立重複貼文。
+ 連接 RSS
RSS 為唯讀來源,不需要 API Key,也不會回寫原網站。同步時會更新 Feed 內公開的項目。
已連接來源
{sources.map((source) =>
{source.name}{source.is_enabled ? source.sync_status : "disabled"}
- 來源 ID:{source.id}
{source.base_url}{source.remote_display_name && <>
Memos 帳號:{source.remote_avatar_url &&
} {source.remote_display_name}>}
成員:{source.members.map((member) => `${member.username}${member.role === "owner" ? "(建立者)" : ""}`).join("、")}
上次同步:{source.last_synced_at || "尚未完成"}
附件保存:{source.attachment_storage_mode === "remote" ? "遠端連結" : source.attachment_storage_mode === "images" ? "只快取圖片" : "完整備份"}(配額 {Math.round(source.attachment_cache_limit_bytes / 1024 / 1024)} MiB)
連線:{source.last_connection_at ? `最近成功:${new Date(source.last_connection_at + "Z").toLocaleString("zh-TW")}` : "尚未測試"}
Webhook:{source.webhook_secret_hash ? (source.last_webhook_at ? (Date.now() - new Date(source.last_webhook_at + "Z").getTime() > 7 * 24 * 60 * 60 * 1000 ? `警示:超過 7 天未收到(最近:${new Date(source.last_webhook_at + "Z").toLocaleString("zh-TW")})` : `健康(最近收到:${new Date(source.last_webhook_at + "Z").toLocaleString("zh-TW")})`) : "已建立 URL,尚未收到呼叫") : "尚未建立 URL"}{!source.is_enabled && <>
已停用:{source.disabled_at ? new Date(source.disabled_at + "Z").toLocaleString("zh-TW") : "是"}>}{source.last_error && <>
同步:{source.last_error}>}{source.last_connection_error && <>
連線:{source.last_connection_error}>}{source.attachment_cache_error && <>
附件快取:{source.attachment_cache_error}>}
+ 來源 ID:{source.id}
{source.integration_type === "rss" ? source.rss_feed_url : source.base_url}{source.remote_display_name && <>
Memos 帳號:{source.remote_avatar_url &&
} {source.remote_display_name}>}
成員:{source.members.map((member) => `${member.username}${member.role === "owner" ? "(建立者)" : ""}`).join("、")}
上次同步:{source.last_synced_at || "尚未完成"}
{source.integration_type === "memos" && <>附件保存:{source.attachment_storage_mode === "remote" ? "遠端連結" : source.attachment_storage_mode === "images" ? "只快取圖片" : "完整備份"}(配額 {Math.round(source.attachment_cache_limit_bytes / 1024 / 1024)} MiB)
連線:{source.last_connection_at ? `最近成功:${new Date(source.last_connection_at + "Z").toLocaleString("zh-TW")}` : "尚未測試"}
Webhook:{source.webhook_secret_hash ? (source.last_webhook_at ? (Date.now() - new Date(source.last_webhook_at + "Z").getTime() > 7 * 24 * 60 * 60 * 1000 ? `警示:超過 7 天未收到(最近:${new Date(source.last_webhook_at + "Z").toLocaleString("zh-TW")})` : `健康(最近收到:${new Date(source.last_webhook_at + "Z").toLocaleString("zh-TW")})`) : "已建立 URL,尚未收到呼叫") : "尚未建立 URL"}>}{!source.is_enabled && <>
已停用:{source.disabled_at ? new Date(source.disabled_at + "Z").toLocaleString("zh-TW") : "是"}>}{source.last_error && <>
同步:{source.last_error}>}{source.last_connection_error && <>
連線:{source.last_connection_error}>}{source.attachment_cache_error && <>
附件快取:{source.attachment_cache_error}>}
{source.owner_id === user.id ? <>
-
+ {source.integration_type === "memos" && }
來源管理
{source.members.length > 1 && }
> : }
diff --git a/lib/rss.ts b/lib/rss.ts
new file mode 100644
index 0000000..0d13404
--- /dev/null
+++ b/lib/rss.ts
@@ -0,0 +1,8 @@
+export type RssItem = { id: string; content: string; link: string; publishedAt: string | null };
+const decode = (value: string) => value.replace(//g, "$1").replace(/</g, "<").replace(/>/g, ">").replace(/&/g, "&");
+const field = (xml: string, name: string) => decode(xml.match(new RegExp(`<${name}[^>]*>([\\s\\S]*?)${name}>`, "i"))?.[1]?.trim() || "");
+export async function fetchRss(url: string) {
+ const response = await fetch(url, { signal: AbortSignal.timeout(15_000), headers: { Accept: "application/rss+xml, application/xml, text/xml" } }); if (!response.ok) throw new Error(`RSS ${response.status}`);
+ const xml = await response.text(); const entries = xml.match(/- /gi) || [];
+ return entries.map((item) => { const link = field(item, "link"); const id = field(item, "guid") || link; return { id, link, content: field(item, "description") || field(item, "title"), publishedAt: field(item, "pubDate") ? new Date(field(item, "pubDate")).toISOString() : null }; }).filter((item) => item.id && item.content) as RssItem[];
+}
diff --git a/worker/index.ts b/worker/index.ts
index db17644..ec3a917 100644
--- a/worker/index.ts
+++ b/worker/index.ts
@@ -5,8 +5,9 @@ import { db } from "../lib/db";
import { decrypt } from "../lib/crypto";
import { createMemo, createRemoteFile, getMemosIdentity, listMemos, memoUrl, setMemoAttachments } from "../lib/memos";
import { recordError } from "../lib/observability";
+import { fetchRss } from "../lib/rss";
-type Source = { id: number; user_id: number; base_url: string; token_encrypted: string; remote_user: string | null; is_enabled: number; sync_tags_json: string; sync_from: string | null; sync_to: string | null; sync_attachment_mode: "all" | "images" | "none"; attachment_storage_mode: "remote" | "images" | "all"; attachment_cache_limit_bytes: number };
+type Source = { id: number; user_id: number; base_url: string; token_encrypted: string; remote_user: string | null; integration_type: "memos" | "rss"; rss_feed_url: string | null; is_enabled: number; sync_tags_json: string; sync_from: string | null; sync_to: string | null; sync_attachment_mode: "all" | "images" | "none"; attachment_storage_mode: "remote" | "images" | "all"; attachment_cache_limit_bytes: number };
type Job = { id: number; source_id: number; kind: "pull" | "push"; payload_json: string | null; attempts: number };
function remoteAttachmentUrl(attachment: any, baseUrl: string) {
@@ -49,6 +50,7 @@ async function upsertRemote(source: Source, memo: any) {
}
async function pull(source: Source) {
+ if (source.integration_type === "rss") { const items = await fetchRss(source.rss_feed_url || ""); for (const item of items) db.prepare("INSERT INTO posts(source_id,author_id,remote_memo_name,content,visibility,tags_json,attachments_json,origin,remote_created_at,remote_updated_at,sync_status,hidden,remote_url) VALUES(?,?,?,?,?,'[]','[]','rss',?,?, 'synced',0,?) ON CONFLICT(source_id,remote_memo_name) DO UPDATE SET content=excluded.content,remote_created_at=excluded.remote_created_at,remote_url=excluded.remote_url,hidden=0,updated_at=CURRENT_TIMESTAMP").run(source.id,source.user_id,`rss:${item.id}`,item.content,"PUBLIC",item.publishedAt,item.publishedAt,item.link); const names=items.map((item)=>`rss:${item.id}`); if(names.length) db.prepare(`UPDATE posts SET hidden=1 WHERE source_id=? AND remote_memo_name NOT IN (${names.map(()=>"?").join(",")})`).run(source.id,...names); db.prepare("UPDATE sources SET sync_status='synced',last_synced_at=CURRENT_TIMESTAMP,last_error=NULL WHERE id=?").run(source.id); return; }
const token = decrypt(source.token_encrypted); const identity = await getMemosIdentity(source.base_url, token); const creator = identity.name;
const rules = { creator, tags: JSON.parse(source.sync_tags_json || "[]") as string[], from: source.sync_from, to: source.sync_to, attachmentMode: source.sync_attachment_mode };
const memos = await listMemos(source.base_url, token, rules);