From 044196cfa7b9b33086c6f3a1fae7365bf1939f5c Mon Sep 17 00:00:00 2001 From: cyph3rasi Date: Sun, 8 Mar 2026 19:22:18 -0700 Subject: [PATCH] Add auto updates and tighten reply feeds --- docker/host-updater.py | 170 ++++++++++++++++++---- src/app/admin/page.tsx | 79 ++++++++++ src/app/api/admin/update/route.ts | 26 +++- src/app/api/posts/route.ts | 4 +- src/app/api/swarm/users/[handle]/route.ts | 6 +- src/app/u/[handle]/posts/[id]/page.tsx | 22 +-- src/lib/host-updater.ts | 15 +- 7 files changed, 281 insertions(+), 41 deletions(-) diff --git a/docker/host-updater.py b/docker/host-updater.py index 761c308..66d0856 100755 --- a/docker/host-updater.py +++ b/docker/host-updater.py @@ -14,12 +14,15 @@ from http.server import BaseHTTPRequestHandler SOCKET_PATH = os.environ.get("HOST_UPDATER_SOCKET", "/var/run/synapsis-updater/updater.sock") TOKEN = os.environ.get("HOST_UPDATER_TOKEN", "") STATUS_FILE = os.environ.get("HOST_UPDATER_STATUS_FILE", "/opt/synapsis/updater-status.json") +CONFIG_FILE = os.environ.get("HOST_UPDATER_CONFIG_FILE", "/opt/synapsis/updater-config.json") LOG_FILE = os.environ.get("HOST_UPDATER_LOG_FILE", "/opt/synapsis/updater.log") UPDATE_SCRIPT = os.environ.get("HOST_UPDATER_SCRIPT", "/opt/synapsis/update-local.sh") INSTALL_DIR = os.environ.get("INSTALL_DIR", "/opt/synapsis") +AUTO_UPDATE_INTERVAL_MINUTES = max(5, int(os.environ.get("HOST_UPDATER_INTERVAL_MINUTES", "30"))) status_lock = threading.RLock() current_process = None +last_auto_trigger_at = 0.0 def now_iso(): @@ -29,9 +32,48 @@ def now_iso(): def ensure_parent_dirs(): os.makedirs(os.path.dirname(SOCKET_PATH), exist_ok=True) os.makedirs(os.path.dirname(STATUS_FILE), exist_ok=True) + os.makedirs(os.path.dirname(CONFIG_FILE), exist_ok=True) os.makedirs(os.path.dirname(LOG_FILE), exist_ok=True) +def default_config(): + return { + "autoUpdateEnabled": True, + "intervalMinutes": AUTO_UPDATE_INTERVAL_MINUTES, + } + + +def load_config(): + if not os.path.exists(CONFIG_FILE): + config = default_config() + save_config(config) + return config + + with open(CONFIG_FILE, "r", encoding="utf-8") as handle: + stored = json.load(handle) + + config = default_config() + config.update({ + "autoUpdateEnabled": bool(stored.get("autoUpdateEnabled", True)), + "intervalMinutes": max(5, int(stored.get("intervalMinutes", AUTO_UPDATE_INTERVAL_MINUTES))), + }) + return config + + +def save_config(config): + with open(CONFIG_FILE, "w", encoding="utf-8") as handle: + json.dump(config, handle, indent=2) + + +def update_config(**changes): + config = load_config() + config.update(changes) + config["autoUpdateEnabled"] = bool(config.get("autoUpdateEnabled", True)) + config["intervalMinutes"] = max(5, int(config.get("intervalMinutes", AUTO_UPDATE_INTERVAL_MINUTES))) + save_config(config) + return config + + def load_status(): if not os.path.exists(STATUS_FILE): return { @@ -85,6 +127,12 @@ def update_status(**changes): return status +def get_status_payload(): + status = load_status() + status["config"] = load_config() + return status + + def is_authorized(headers): expected = f"Bearer {TOKEN}" return bool(TOKEN) and headers.get("Authorization", "") == expected @@ -105,7 +153,7 @@ def watch_process(process): ) -def start_update_process(): +def start_update_process(trigger="manual"): global current_process # Give the app enough time to return the 202 response before the container restarts. @@ -125,12 +173,69 @@ def start_update_process(): }, ) - update_status(pid=current_process.pid) + update_status(pid=current_process.pid, trigger=trigger) thread = threading.Thread(target=watch_process, args=(current_process,), daemon=True) thread.start() +def schedule_update(trigger="manual"): + with status_lock: + if current_process and current_process.poll() is None: + return False + + update_status( + status="updating", + message="Synapsis update scheduled." if trigger == "manual" else "Automatic Synapsis update scheduled.", + lastStartedAt=now_iso(), + lastFinishedAt=None, + lastExitCode=None, + lastError=None, + pid=None, + trigger=trigger, + ) + + thread = threading.Thread(target=start_update_process, args=(trigger,), daemon=True) + thread.start() + return True + + +def auto_update_loop(): + global last_auto_trigger_at + while True: + time.sleep(60) + try: + config = load_config() + if not config.get("autoUpdateEnabled", True): + continue + + interval_seconds = max(300, int(config.get("intervalMinutes", AUTO_UPDATE_INTERVAL_MINUTES)) * 60) + now = time.time() + + with status_lock: + if current_process and current_process.poll() is None: + continue + + last_reference = last_auto_trigger_at + status = load_status() + if not last_reference: + last_started = status.get("lastStartedAt") + if last_started: + try: + last_reference = datetime.fromisoformat(last_started).timestamp() + except Exception: + last_reference = 0.0 + + if last_reference and (now - last_reference) < interval_seconds: + continue + + last_auto_trigger_at = now + + schedule_update(trigger="auto") + except Exception as error: + update_status(lastError=f"Auto-update scheduler error: {error}") + + class ThreadedUnixServer(socketserver.ThreadingMixIn, socketserver.UnixStreamServer): daemon_threads = True @@ -158,7 +263,7 @@ class Handler(BaseHTTPRequestHandler): if not is_authorized(self.headers): self.send_json(HTTPStatus.UNAUTHORIZED, {"error": "Unauthorized"}) return - self.send_json(HTTPStatus.OK, load_status()) + self.send_json(HTTPStatus.OK, get_status_payload()) return self.send_json(HTTPStatus.NOT_FOUND, {"error": "Not found"}) @@ -174,30 +279,16 @@ class Handler(BaseHTTPRequestHandler): self.send_json(HTTPStatus.UNAUTHORIZED, {"error": "Unauthorized"}) return - with status_lock: - if current_process and current_process.poll() is None: - self.send_json( - HTTPStatus.CONFLICT, - { - "ok": False, - "status": "updating", - "message": "An update is already running.", - }, - ) - return - - update_status( - status="updating", - message="Synapsis update scheduled.", - lastStartedAt=now_iso(), - lastFinishedAt=None, - lastExitCode=None, - lastError=None, - pid=None, + if not schedule_update(trigger="manual"): + self.send_json( + HTTPStatus.CONFLICT, + { + "ok": False, + "status": "updating", + "message": "An update is already running.", + }, ) - - thread = threading.Thread(target=start_update_process, daemon=True) - thread.start() + return self.send_json( HTTPStatus.ACCEPTED, @@ -208,6 +299,31 @@ class Handler(BaseHTTPRequestHandler): }, ) + def do_PATCH(self): + if self.path != "/config": + self.send_json(HTTPStatus.NOT_FOUND, {"error": "Not found"}) + return + + if not is_authorized(self.headers): + self.send_json(HTTPStatus.UNAUTHORIZED, {"error": "Unauthorized"}) + return + + content_length = int(self.headers.get("Content-Length", "0") or "0") + raw_body = self.rfile.read(content_length) if content_length > 0 else b"{}" + + try: + payload = json.loads(raw_body.decode("utf-8") or "{}") + except json.JSONDecodeError: + self.send_json(HTTPStatus.BAD_REQUEST, {"error": "Invalid JSON"}) + return + + if "autoUpdateEnabled" not in payload: + self.send_json(HTTPStatus.BAD_REQUEST, {"error": "autoUpdateEnabled is required"}) + return + + config = update_config(autoUpdateEnabled=payload.get("autoUpdateEnabled")) + self.send_json(HTTPStatus.OK, {"ok": True, "config": config}) + def cleanup_socket(*_args): try: @@ -224,11 +340,13 @@ def main(): os.remove(SOCKET_PATH) normalize_status_on_startup() + load_config() signal.signal(signal.SIGTERM, cleanup_socket) signal.signal(signal.SIGINT, cleanup_socket) update_status(available=True) + threading.Thread(target=auto_update_loop, daemon=True).start() with ThreadedUnixServer(SOCKET_PATH, Handler) as server: os.chmod(SOCKET_PATH, 0o666) diff --git a/src/app/admin/page.tsx b/src/app/admin/page.tsx index cd186e1..846d8d0 100644 --- a/src/app/admin/page.tsx +++ b/src/app/admin/page.tsx @@ -50,10 +50,16 @@ export default function AdminPage() { lastFinishedAt?: string | null; lastExitCode?: number | null; lastError?: string | null; + trigger?: 'manual' | 'auto' | null; + config?: { + autoUpdateEnabled: boolean; + intervalMinutes: number; + }; }; } | null>(null); const [loadingUpdateStatus, setLoadingUpdateStatus] = useState(false); const [triggeringUpdate, setTriggeringUpdate] = useState(false); + const [savingAutoUpdate, setSavingAutoUpdate] = useState(false); useEffect(() => { fetch('/api/admin/me') @@ -291,6 +297,38 @@ export default function AdminPage() { } }; + const handleToggleAutoUpdate = async () => { + if (!updateStatus?.updater.available || !updateStatus.updater.config) return; + + const nextValue = !updateStatus.updater.config.autoUpdateEnabled; + setSavingAutoUpdate(true); + try { + const res = await fetch('/api/admin/update', { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ autoUpdateEnabled: nextValue }), + }); + const data = await res.json().catch(() => ({})); + + if (!res.ok) { + throw new Error(data.error || 'Failed to update auto-update setting'); + } + + setUpdateStatus((prev) => prev ? { + ...prev, + updater: { + ...prev.updater, + config: data.config, + }, + } : prev); + showToast(nextValue ? 'Automatic updates enabled' : 'Automatic updates disabled', 'success'); + } catch (error) { + showToast(error instanceof Error ? error.message : 'Failed to update auto-update setting', 'error'); + } finally { + setSavingAutoUpdate(false); + } + }; + const handleFaviconUpload = async (event: React.ChangeEvent) => { const file = event.target.files?.[0]; event.target.value = ''; @@ -710,6 +748,41 @@ export default function AdminPage() { + {updateStatus?.updater.available && updateStatus?.updater.config && ( +
+
+
+ Automatic updates +
+
+ Enabled by default. This node checks for updates every {updateStatus.updater.config.intervalMinutes} minutes and installs them automatically. +
+
+ +
+ )} +
Current build:{' '} @@ -741,6 +814,12 @@ export default function AdminPage() { {updateStatus.updater.lastExitCode}
)} + {updateStatus?.updater.trigger && ( +
+ Last trigger:{' '} + {updateStatus.updater.trigger === 'auto' ? 'Automatic update' : 'Manual update'} +
+ )} {updateStatus?.updater.lastError && (
Last error:{' '} diff --git a/src/app/api/admin/update/route.ts b/src/app/api/admin/update/route.ts index f44a233..0fa06e3 100644 --- a/src/app/api/admin/update/route.ts +++ b/src/app/api/admin/update/route.ts @@ -1,7 +1,7 @@ import { NextResponse } from 'next/server'; import { requireAdmin } from '@/lib/auth/admin'; import { getCurrentBuildInfo, getLatestPublishedBuild, compareBuildVersions } from '@/lib/version'; -import { getHostUpdaterStatus, triggerHostUpdate } from '@/lib/host-updater'; +import { getHostUpdaterStatus, triggerHostUpdate, updateHostUpdaterConfig } from '@/lib/host-updater'; function isUpdateAvailable(currentVersion: string, latestVersion: string | null | undefined) { if (!latestVersion) { @@ -56,3 +56,27 @@ export async function POST() { ); } } + +export async function PATCH(request: Request) { + try { + await requireAdmin(); + + const body = await request.json(); + if (typeof body.autoUpdateEnabled !== 'boolean') { + return NextResponse.json({ error: 'autoUpdateEnabled must be a boolean' }, { status: 400 }); + } + + const result = await updateHostUpdaterConfig(body.autoUpdateEnabled); + return NextResponse.json(result); + } catch (error) { + if (error instanceof Error && error.message === 'Admin required') { + return NextResponse.json({ error: 'Admin required' }, { status: 403 }); + } + + console.error('[Admin Update] Config error:', error); + return NextResponse.json( + { error: error instanceof Error ? error.message : 'Failed to update auto-update settings' }, + { status: 500 } + ); + } +} diff --git a/src/app/api/posts/route.ts b/src/app/api/posts/route.ts index 85459a7..9c1aaf3 100644 --- a/src/app/api/posts/route.ts +++ b/src/app/api/posts/route.ts @@ -735,7 +735,9 @@ export async function GET(request: Request) { ); if (!profileData?.posts) return []; - return profileData.posts.map(post => ({ + return profileData.posts + .filter((post: any) => !post.replyToId && !post.swarmReplyToId && !post.isReply) + .map(post => ({ id: `swarm:${domain}:${post.id}`, content: post.content, createdAt: new Date(post.createdAt), diff --git a/src/app/api/swarm/users/[handle]/route.ts b/src/app/api/swarm/users/[handle]/route.ts index 20b7cf7..b9375ab 100644 --- a/src/app/api/swarm/users/[handle]/route.ts +++ b/src/app/api/swarm/users/[handle]/route.ts @@ -6,7 +6,7 @@ import { NextRequest, NextResponse } from 'next/server'; import { db, posts, users, media, nodes } from '@/db'; -import { eq, desc, and } from 'drizzle-orm'; +import { eq, desc, and, isNull } from 'drizzle-orm'; export interface SwarmUserProfile { handle: string; @@ -116,7 +116,9 @@ export async function GET(request: NextRequest, context: RouteContext) { .where( and( eq(posts.userId, user.id), - eq(posts.isRemoved, false) + eq(posts.isRemoved, false), + isNull(posts.replyToId), + isNull(posts.swarmReplyToId) ) ) .orderBy(desc(posts.createdAt)) diff --git a/src/app/u/[handle]/posts/[id]/page.tsx b/src/app/u/[handle]/posts/[id]/page.tsx index e71a1ad..3edfe0e 100644 --- a/src/app/u/[handle]/posts/[id]/page.tsx +++ b/src/app/u/[handle]/posts/[id]/page.tsx @@ -19,6 +19,7 @@ export default function PostDetailPage() { const [post, setPost] = useState(null); const [replies, setReplies] = useState([]); + const [replyingTo, setReplyingTo] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); @@ -31,6 +32,7 @@ export default function PostDetailPage() { const data = await res.json(); setPost(data.post); setReplies(data.replies || []); + setReplyingTo(data.post); } catch (err) { setError(err instanceof Error ? err.message : 'Failed to load post'); } finally { @@ -47,13 +49,13 @@ export default function PostDetailPage() { let swarmReplyTo: { postId: string; nodeDomain: string; content?: string; author?: any } | undefined; let localReplyToId: string | undefined = replyToId; - if (post?.isSwarm && post.nodeDomain && post.originalPostId) { + if (replyingTo?.isSwarm && replyingTo.nodeDomain && replyingTo.originalPostId) { // This is a reply to a swarm post - send to the origin node swarmReplyTo = { - postId: post.originalPostId, - nodeDomain: post.nodeDomain, - content: post.content, - author: post.author, + postId: replyingTo.originalPostId, + nodeDomain: replyingTo.nodeDomain, + content: replyingTo.content, + author: replyingTo.author, }; localReplyToId = undefined; // Can't use UUID foreign key for swarm posts } @@ -78,6 +80,7 @@ export default function PostDetailPage() { if (post) { setPost({ ...post, repliesCount: post.repliesCount + 1 }); } + setReplyingTo(post); } }; @@ -172,9 +175,10 @@ export default function PostDetailPage() {
setReplyingTo(post)} />
)} @@ -188,10 +192,8 @@ export default function PostDetailPage() { onRepost={handleRepost} onDelete={handleDelete} parentPostAuthorId={post.author.id} - onComment={(p) => { - // In detail view, commenting on a reply should probably just focus the main composer - // but we could also implement nested replies later. - // For now, let's keep it simple. + onComment={(replyTarget) => { + setReplyingTo(replyTarget); const composer = document.querySelector('.compose-input') as HTMLTextAreaElement; composer?.focus(); }} diff --git a/src/lib/host-updater.ts b/src/lib/host-updater.ts index ec50930..8d99bd5 100644 --- a/src/lib/host-updater.ts +++ b/src/lib/host-updater.ts @@ -12,6 +12,11 @@ export interface HostUpdaterStatus { lastExitCode?: number | null; lastError?: string | null; pid?: number | null; + trigger?: 'manual' | 'auto' | null; + config?: { + autoUpdateEnabled: boolean; + intervalMinutes: number; + }; } function getUpdaterConfig() { @@ -25,7 +30,7 @@ function getUpdaterConfig() { }; } -function requestUpdater(method: 'GET' | 'POST', path: string, body?: unknown): Promise { +function requestUpdater(method: 'GET' | 'POST' | 'PATCH', path: string, body?: unknown): Promise { const { socketPath, token, enabled } = getUpdaterConfig(); if (!enabled) { @@ -114,3 +119,11 @@ export async function getHostUpdaterStatus(): Promise { export async function triggerHostUpdate() { return requestUpdater<{ ok: boolean; status: string; message?: string }>('POST', '/update'); } + +export async function updateHostUpdaterConfig(autoUpdateEnabled: boolean) { + return requestUpdater<{ ok: boolean; config: { autoUpdateEnabled: boolean; intervalMinutes: number } }>( + 'PATCH', + '/config', + { autoUpdateEnabled } + ); +}