diff --git a/CHANGELOG.md b/CHANGELOG.md
index 9c2c801..0a68959 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -6,6 +6,8 @@
### Fixed
+- Prevented manual URL regeneration from invalidating automatically configured, signed Memos webhooks.
+
- 貼文頁、首頁卡片、RSS 與 Atom 優先顯示 Memos 原始發布時間,不再顯示同一次匯入的 Hub 寫入時間。
- 多使用者 Memos 來源只同步 API Key 所屬帳號建立的公開貼文;舊來源會在下一次同步自動補回遠端帳號身分並重新篩選鏡像。
- 遠端附件快取失敗會在來源控制台顯示可重試提示,並保留原始連結作為回退。
@@ -22,6 +24,7 @@
### Added
+- Added owner/member-only JSON and Markdown exports for individual posts and complete sources.
- 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 與文章底部標籤。
diff --git a/app/api/export/posts/[id]/route.ts b/app/api/export/posts/[id]/route.ts
new file mode 100644
index 0000000..45218b4
--- /dev/null
+++ b/app/api/export/posts/[id]/route.ts
@@ -0,0 +1,21 @@
+import { NextResponse } from "next/server";
+import { requireUser } from "@/lib/auth";
+import { db } from "@/lib/db";
+
+function markdown(post: any) {
+ const tags = (() => { try { return JSON.parse(post.tags_json || "[]"); } catch { return []; } })();
+ const attachments = (() => { try { return JSON.parse(post.attachments_json || "[]"); } catch { return []; } })();
+ const quote = (value: unknown) => JSON.stringify(value ?? "");
+ const attachmentList = attachments.length ? `\n\n## Attachments\n${attachments.map((item: any) => `- [${item.filename || item.name || "attachment"}](${item.url || item.externalLink || ""})`).join("\n")}` : "";
+ return `---\nid: ${post.id}\norigin: ${quote(post.origin)}\nvisibility: ${quote(post.visibility)}\npublished_at: ${quote(post.remote_created_at || post.created_at)}\ntags: ${JSON.stringify(tags)}\nremote_url: ${quote(post.remote_url)}\n---\n\n${post.content}${attachmentList}\n`;
+}
+
+export async function GET(request: Request, { params }: { params: Promise<{ id: string }> }) {
+ const user = await requireUser(); const { id: rawId } = await params; const post = db.prepare("SELECT p.*,s.name AS source_name FROM posts p LEFT JOIN sources s ON s.id=p.source_id WHERE p.id=?").get(Number(rawId)) as any;
+ if (!post) return NextResponse.json({ error: "Not found" }, { status: 404 });
+ const permitted = post.author_id === user.id || (post.source_id && db.prepare("SELECT 1 FROM source_members WHERE source_id=? AND user_id=?").get(post.source_id, user.id));
+ if (!permitted) return NextResponse.json({ error: "Forbidden" }, { status: 403 });
+ const format = new URL(request.url).searchParams.get("format") === "markdown" ? "markdown" : "json";
+ const body = format === "markdown" ? markdown(post) : JSON.stringify({ version: 1, exportedAt: new Date().toISOString(), post: { ...post, tags: JSON.parse(post.tags_json || "[]"), attachments: JSON.parse(post.attachments_json || "[]") } }, null, 2);
+ return new NextResponse(body, { headers: { "Content-Type": format === "markdown" ? "text/markdown; charset=utf-8" : "application/json; charset=utf-8", "Content-Disposition": `attachment; filename="mebbling-post-${post.id}.${format === "markdown" ? "md" : "json"}"` } });
+}
diff --git a/app/api/export/sources/[id]/route.ts b/app/api/export/sources/[id]/route.ts
new file mode 100644
index 0000000..6f9aee1
--- /dev/null
+++ b/app/api/export/sources/[id]/route.ts
@@ -0,0 +1,15 @@
+import { NextResponse } from "next/server";
+import { requireUser } from "@/lib/auth";
+import { db } from "@/lib/db";
+
+function postMarkdown(post: any) { return `## ${post.remote_created_at || post.created_at}\n\n${post.content}\n`; }
+
+export async function GET(request: Request, { params }: { params: Promise<{ id: string }> }) {
+ const user = await requireUser(); const { id: rawId } = await params; const id = Number(rawId); const source = db.prepare("SELECT id,name,base_url,integration_type,rss_feed_url,created_at FROM sources WHERE id=?").get(id) as any;
+ if (!source) return NextResponse.json({ error: "Not found" }, { status: 404 });
+ if (!db.prepare("SELECT 1 FROM source_members WHERE source_id=? AND user_id=?").get(id, user.id)) return NextResponse.json({ error: "Forbidden" }, { status: 403 });
+ const posts = db.prepare("SELECT id,content,visibility,tags_json,attachments_json,origin,remote_created_at,remote_updated_at,remote_url,created_at,updated_at FROM posts WHERE source_id=? ORDER BY COALESCE(remote_created_at,created_at)").all(id) as any[];
+ const format = new URL(request.url).searchParams.get("format") === "markdown" ? "markdown" : "json";
+ const body = format === "markdown" ? `# ${source.name}\n\n${posts.map(postMarkdown).join("\n---\n\n")}` : JSON.stringify({ version: 1, exportedAt: new Date().toISOString(), source, posts: posts.map((post) => ({ ...post, tags: JSON.parse(post.tags_json || "[]"), attachments: JSON.parse(post.attachments_json || "[]") })) }, null, 2);
+ return new NextResponse(body, { headers: { "Content-Type": format === "markdown" ? "text/markdown; charset=utf-8" : "application/json; charset=utf-8", "Content-Disposition": `attachment; filename="mebbling-source-${id}.${format === "markdown" ? "md" : "json"}"` } });
+}
diff --git a/app/api/sources/[id]/webhook/route.ts b/app/api/sources/[id]/webhook/route.ts
index ab60a95..7236bfb 100644
--- a/app/api/sources/[id]/webhook/route.ts
+++ b/app/api/sources/[id]/webhook/route.ts
@@ -7,8 +7,9 @@ import { requireSameOrigin } from "@/lib/security";
export async function POST(request: Request, { params }: { params: Promise<{ id: string }> }) {
try {
requireSameOrigin(request); const user = await requireUser(); const { id: rawId } = await params; const id = Number(rawId);
- const source = db.prepare("SELECT id FROM sources WHERE id=? AND user_id=?").get(id, user.id);
+ const source = db.prepare("SELECT id,webhook_mode FROM sources WHERE id=? AND user_id=?").get(id, user.id) as { id: number; webhook_mode: string } | undefined;
if (!source) return NextResponse.json({ error: "Not found" }, { status: 404 });
+ if (source.webhook_mode === "signed") return NextResponse.json({ error: "這個 Webhook 已由 Memos 自動管理;請重新連接來源以重新建立。" }, { status: 409 });
const secret = createWebhookSecret();
db.prepare("UPDATE sources SET webhook_secret_hash=? WHERE id=?").run(webhookSecretHash(secret), id);
const publicOrigin = (process.env.NEXT_PUBLIC_APP_URL || new URL(request.url).origin).replace(/\/$/, "");
diff --git a/app/dashboard/page.tsx b/app/dashboard/page.tsx
index 84fe9d9..18cceef 100644
--- a/app/dashboard/page.tsx
+++ b/app/dashboard/page.tsx
@@ -4,14 +4,14 @@ import { db } from "@/lib/db";
import { PublishForm } from "./publish-form";
import { WebhookControl } from "./webhook-control";
-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 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; webhook_mode: string; 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.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 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.webhook_mode,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 && source.integration_type === "memos");
return <>
@@ -26,7 +26,7 @@ export default async function Dashboard({ searchParams }: { searchParams: Promis
{source.name} {source.is_enabled ? source.sync_status : "disabled"}
來源 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.integration_type === "memos" && }
來源管理 {source.members.length > 1 && }
> : }
diff --git a/app/dashboard/webhook-control.tsx b/app/dashboard/webhook-control.tsx
index 6898dcf..9c544a1 100644
--- a/app/dashboard/webhook-control.tsx
+++ b/app/dashboard/webhook-control.tsx
@@ -2,7 +2,7 @@
import { useState } from "react";
-export function WebhookControl({ sourceId, configured }: { sourceId: number; configured: boolean }) {
+export function WebhookControl({ sourceId, configured, automatic = false }: { sourceId: number; configured: boolean; automatic?: boolean }) {
const [url, setUrl] = useState(""); const [error, setError] = useState(""); const [busy, setBusy] = useState(false);
async function generate() {
setBusy(true); setError("");
@@ -13,6 +13,7 @@ export function WebhookControl({ sourceId, configured }: { sourceId: number; con
finally { setBusy(false); }
}
async function copy() { if (url) await navigator.clipboard.writeText(url); }
+ if (automatic) return Webhook:已由 Memos 自動設定並驗證簽章。為避免讓遠端設定失效,此處不提供手動重產網址。
;
return Webhook:{configured ? "已設定" : "尚未設定"}
{url ? <>
Webhook URL event.currentTarget.select()} />
複製 URL 重新產生
請立即複製到 Memos;重新整理後完整密鑰不會再顯示。
> :
{busy ? "產生中…" : configured ? "重新產生 webhook URL" : "產生 webhook URL"} }
{error &&
{error}
}
diff --git a/app/posts/[id]/page.tsx b/app/posts/[id]/page.tsx
index af40cd6..2997075 100644
--- a/app/posts/[id]/page.tsx
+++ b/app/posts/[id]/page.tsx
@@ -23,7 +23,7 @@ export default async function PostPage({ params, searchParams }: { params: Promi
const bookmark = user ? db.prepare("SELECT kind FROM bookmarks WHERE user_id=? AND post_id=?").get(user.id, id) as { kind: string } | undefined : undefined;
const comments = db.prepare("SELECT c.*,u.username FROM comments c JOIN users u ON u.id=c.author_id WHERE c.post_id=? AND c.hidden=0 ORDER BY c.created_at").all(id) as any[];
const reactions = db.prepare("SELECT emoji,count(*) count FROM reactions WHERE post_id=? GROUP BY emoji").all(id) as any[];
- let tags: string[] = []; try { tags = canonicalTags(JSON.parse(post.tags_json)); } catch {} const publishedAt = post.remote_created_at || post.created_at; return
@{post.username} · {post.remote_display_name || post.name || "Hub"} · {new Date(publishedAt).toLocaleString("zh-TW")}{post.remote_url && <> · 在 Memos 開啟 >}
+ let tags: string[] = []; try { tags = canonicalTags(JSON.parse(post.tags_json)); } catch {} const publishedAt = post.remote_created_at || post.created_at; const canExport = Boolean(user && (post.author_id === user.id || (post.source_id && db.prepare("SELECT 1 FROM source_members WHERE source_id=? AND user_id=?").get(post.source_id, user.id)))); return @{post.username} · {post.remote_display_name || post.name || "Hub"} · {new Date(publishedAt).toLocaleString("zh-TW")}{post.remote_url && <> · 在 Memos 開啟 >}
{canExport && 匯出 JSON 匯出 Markdown
}
{reactions.map((reaction: any) => {reaction.emoji} {reaction.count} )}{user && <>>}{user && ["👍", "❤️", "🎉", "🤔"].map((emoji) => )}
留言 {user ? <>檢舉這篇貼文 {query.reported && 已收到檢舉,管理員會審核。
}原因 送出檢舉 > : 請先登入以留言、互動或檢舉。
}{comments.map((comment) => @{comment.username} {comment.content}
{new Date(comment.created_at).toLocaleString("zh-TW")} )}
;
diff --git a/app/sources/[id]/page.tsx b/app/sources/[id]/page.tsx
index 1b586aa..e19a8fa 100644
--- a/app/sources/[id]/page.tsx
+++ b/app/sources/[id]/page.tsx
@@ -2,10 +2,12 @@ import Link from "next/link";
import { notFound } from "next/navigation";
import { db } from "@/lib/db";
import { PostCard, type PublicPost } from "@/app/components/post-card";
+import { getSession } from "@/lib/auth";
export const dynamic = "force-dynamic";
export default async function SourcePage({ params }: { params: Promise<{ id: string }> }) {
const { id: rawId } = await params; const id = Number(rawId); const source = db.prepare("SELECT id,name,base_url,remote_display_name,remote_avatar_url FROM sources WHERE id=?").get(id) as { id: number; name: string; base_url: string; remote_display_name: string | null; remote_avatar_url: string | null } | undefined; if (!source) notFound();
const posts = db.prepare("SELECT p.*,u.username,s.name,s.remote_display_name,s.base_url AS source_base_url,(SELECT count(*) FROM comments c WHERE c.post_id=p.id AND c.hidden=0) comment_count,(SELECT count(*) FROM reactions r WHERE r.post_id=p.id) reaction_count FROM posts p JOIN users u ON u.id=p.author_id LEFT JOIN sources s ON s.id=p.source_id WHERE p.source_id=? AND p.visibility='PUBLIC' AND p.hidden=0 ORDER BY COALESCE(p.remote_created_at,p.created_at) DESC LIMIT 100").all(id) as PublicPost[];
- return <> ← 探索
{source.name} {source.remote_avatar_url && } {source.remote_display_name || "Memos"} {source.base_url} · {posts.length} 篇公開貼文
{posts.map((post) => )}>;
+ const user = await getSession(); const member = Boolean(user && db.prepare("SELECT 1 FROM source_members WHERE source_id=? AND user_id=?").get(id, user.id));
+ return <> ← 探索
{source.name} {source.remote_avatar_url && } {source.remote_display_name || "Memos"} {source.base_url} · {posts.length} 篇公開貼文
{member && 匯出來源 JSON 匯出來源 Markdown
}{posts.map((post) => )}>;
}