- 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 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 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.
- Added configurable retention cleanup and a password-confirmed self-service account deletion flow.
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);
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");
queuePull(id,"manual");
}elseif(action==="set-attachment-storage"){
}elseif(action==="set-attachment-storage"){
if(!owner)thrownewError("Only the owner can change attachment storage");constmode=String(form.get("mode")||"remote");constquotaMiB=Number(form.get("quotaMiB")||100);
if(!owner)thrownewError("Only the owner can change attachment storage");constmode=String(form.get("mode")||"remote");constquotaMiB=Number(form.get("quotaMiB")||100);constarchiveAfterDays=Number(form.get("archiveAfterDays")||0);
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");
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");
}elseif(action==="test-connection"){
}elseif(action==="test-connection"){
if(!owner)thrownewError("Only the owner can test the connection");
if(!owner)thrownewError("Only the owner can test the connection");
constsourceRows=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)asSource[];
constsourceRows=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)asSource[];
constsources=sourceRows.map((source)=>({...source,syncTags:(()=>{try{returnJSON.parse(source.sync_tags_json)asstring[];}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)asJob[]}));
constsources=sourceRows.map((source)=>({...source,syncTags:(()=>{try{returnJSON.parse(source.sync_tags_json)asstring[];}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)asJob[]}));
constuserId=Number(db.prepare("INSERT INTO users(username,password_hash) VALUES('sync-test','hash')").run().lastInsertRowid);
constuserId=Number(db.prepare("INSERT INTO users(username,password_hash) VALUES('sync-test','hash')").run().lastInsertRowid);
constsourceId=Number(db.prepare("INSERT INTO sources(user_id,name,base_url,token_encrypted,is_enabled) VALUES(?,?,?,?,1)").run(userId,"Test","https://example.test","encrypted").lastInsertRowid);
constsourceId=Number(db.prepare("INSERT INTO sources(user_id,name,base_url,token_encrypted,is_enabled) VALUES(?,?,?,?,1)").run(userId,"Test","https://example.test","encrypted").lastInsertRowid);
constoldPosts=db.prepare("SELECT id,attachments_json FROM posts WHERE source_id=? AND COALESCE(remote_created_at,created_at)<datetime('now', ?) AND attachments_json LIKE ?").all(source.id,`-${source.attachment_archive_after_days} days`,`%/uploads/cache/source-${source.id}/%`)as{id: number;attachments_json: string}[];
for(constpostofoldPosts){try{constattachments=(JSON.parse(post.attachments_json)asany[]).map((item)=>String(item.url||"").startsWith(`/uploads/cache/source-${source.id}/`)&&item.originalUrl?{...item,url: item.originalUrl}:item);db.prepare("UPDATE posts SET attachments_json=?,updated_at=CURRENT_TIMESTAMP WHERE id=?").run(JSON.stringify(attachments),post.id);}catch{}}
constused=newSet<string>();for(constrowofdb.prepare("SELECT attachments_json FROM posts WHERE source_id=?").all(source.id)as{attachments_json: string}[]){try{for(constattachmentofJSON.parse(row.attachments_json)asany[]){consturl=String(attachment.url||"");if(url.startsWith(`/uploads/cache/source-${source.id}/`))used.add(url.split("/").at(-1)!);}}catch{}}
constused=newSet<string>();for(constrowofdb.prepare("SELECT attachments_json FROM posts WHERE source_id=?").all(source.id)as{attachments_json: string}[]){try{for(constattachmentofJSON.parse(row.attachments_json)asany[]){consturl=String(attachment.url||"");if(url.startsWith(`/uploads/cache/source-${source.id}/`))used.add(url.split("/").at(-1)!);}}catch{}}
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.