diff --git a/CHANGELOG.md b/CHANGELOG.md
index 2e17ae0..5d39d0e 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -24,6 +24,7 @@
### Added
+- Added optional per-source attachment archive age: cached files are replaced with their original remote links after the configured period.
- Added opt-in Discord/ntfy alerts for exhausted sync retries and stale signed webhooks, with per-event rate limiting.
- Added safe signed Memos webhook rotation: create the replacement first, then remove the previous remote webhook before committing the new credentials.
- Added configurable retention cleanup and a password-confirmed self-service account deletion flow.
diff --git a/app/api/sources/[id]/manage/route.ts b/app/api/sources/[id]/manage/route.ts
index 0e3eb18..d0cefb5 100644
--- a/app/api/sources/[id]/manage/route.ts
+++ b/app/api/sources/[id]/manage/route.ts
@@ -27,9 +27,9 @@ export async function POST(req: Request, { params }: { params: Promise<{ id: str
db.prepare("UPDATE sources SET sync_tags_json=?,sync_from=?,sync_to=?,sync_attachment_mode=?,sync_batch_size=?,sync_max_posts=?,sync_cursor=NULL,sync_imported_count=0,sync_run_id=NULL WHERE id=?").run(JSON.stringify(tags), from || null, to || null, attachmentMode, batchSize, maxPosts || null, id);
queuePull(id, "manual");
} else if (action === "set-attachment-storage") {
- if (!owner) throw new Error("Only the owner can change attachment storage"); const mode = String(form.get("mode") || "remote"); const quotaMiB = Number(form.get("quotaMiB") || 100);
- if (!["remote", "images", "all"].includes(mode) || !Number.isFinite(quotaMiB) || quotaMiB < 10 || quotaMiB > 10_240) throw new Error("Invalid attachment storage settings");
- db.prepare("UPDATE sources SET attachment_storage_mode=?,attachment_cache_limit_bytes=?,attachment_cache_error=NULL WHERE id=?").run(mode, Math.round(quotaMiB * 1024 * 1024), id); queuePull(id, "manual");
+ if (!owner) throw new Error("Only the owner can change attachment storage"); const mode = String(form.get("mode") || "remote"); const quotaMiB = Number(form.get("quotaMiB") || 100); const archiveAfterDays = Number(form.get("archiveAfterDays") || 0);
+ if (!["remote", "images", "all"].includes(mode) || !Number.isFinite(quotaMiB) || quotaMiB < 10 || quotaMiB > 10_240 || !Number.isInteger(archiveAfterDays) || archiveAfterDays < 0 || archiveAfterDays > 3650) throw new Error("Invalid attachment storage settings");
+ db.prepare("UPDATE sources SET attachment_storage_mode=?,attachment_cache_limit_bytes=?,attachment_archive_after_days=?,attachment_cache_error=NULL WHERE id=?").run(mode, Math.round(quotaMiB * 1024 * 1024), archiveAfterDays || null, id); queuePull(id, "manual");
} else if (action === "test-connection") {
if (!owner) throw new Error("Only the owner can test the connection");
try {
diff --git a/app/dashboard/page.tsx b/app/dashboard/page.tsx
index 94e0084..8af3e12 100644
--- a/app/dashboard/page.tsx
+++ b/app/dashboard/page.tsx
@@ -5,14 +5,14 @@ import { PublishForm } from "./publish-form";
import { WebhookControl } from "./webhook-control";
import { InviteControl } from "./invite-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; webhook_mode: string; last_webhook_at: string | null; owner_id: number; membership_role: "owner" | "editor" | "viewer"; 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"; sync_batch_size: number; sync_max_posts: number | null; sync_cursor: string | null; sync_imported_count: number; 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; membership_role: "owner" | "editor" | "viewer"; 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"; sync_batch_size: number; sync_max_posts: number | null; sync_cursor: string | null; sync_imported_count: number; attachment_storage_mode: "remote" | "images" | "all"; attachment_cache_limit_bytes: number; attachment_archive_after_days: number | null; 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.webhook_mode,s.last_webhook_at,s.user_id AS owner_id,sm.role AS membership_role,s.is_enabled,s.disabled_at,s.sync_tags_json,s.sync_from,s.sync_to,s.sync_attachment_mode,s.sync_batch_size,s.sync_max_posts,s.sync_cursor,s.sync_imported_count,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,sm.role AS membership_role,s.is_enabled,s.disabled_at,s.sync_tags_json,s.sync_from,s.sync_to,s.sync_attachment_mode,s.sync_batch_size,s.sync_max_posts,s.sync_cursor,s.sync_imported_count,s.attachment_storage_mode,s.attachment_cache_limit_bytes,s.attachment_archive_after_days,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" && ["owner", "editor"].includes(source.membership_role));
return <>
@@ -29,7 +29,7 @@ export default async function Dashboard({ searchParams }: { searchParams: Promis
{source.owner_id === user.id ? <>
{source.integration_type === "memos" &&