Add auto updates and tighten reply feeds

This commit is contained in:
cyph3rasi
2026-03-08 19:22:18 -07:00
parent 5f21ea8e5d
commit 044196cfa7
7 changed files with 281 additions and 41 deletions
+144 -26
View File
@@ -14,12 +14,15 @@ from http.server import BaseHTTPRequestHandler
SOCKET_PATH = os.environ.get("HOST_UPDATER_SOCKET", "/var/run/synapsis-updater/updater.sock") SOCKET_PATH = os.environ.get("HOST_UPDATER_SOCKET", "/var/run/synapsis-updater/updater.sock")
TOKEN = os.environ.get("HOST_UPDATER_TOKEN", "") TOKEN = os.environ.get("HOST_UPDATER_TOKEN", "")
STATUS_FILE = os.environ.get("HOST_UPDATER_STATUS_FILE", "/opt/synapsis/updater-status.json") 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") 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") UPDATE_SCRIPT = os.environ.get("HOST_UPDATER_SCRIPT", "/opt/synapsis/update-local.sh")
INSTALL_DIR = os.environ.get("INSTALL_DIR", "/opt/synapsis") 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() status_lock = threading.RLock()
current_process = None current_process = None
last_auto_trigger_at = 0.0
def now_iso(): def now_iso():
@@ -29,9 +32,48 @@ def now_iso():
def ensure_parent_dirs(): def ensure_parent_dirs():
os.makedirs(os.path.dirname(SOCKET_PATH), exist_ok=True) 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(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) 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(): def load_status():
if not os.path.exists(STATUS_FILE): if not os.path.exists(STATUS_FILE):
return { return {
@@ -85,6 +127,12 @@ def update_status(**changes):
return status return status
def get_status_payload():
status = load_status()
status["config"] = load_config()
return status
def is_authorized(headers): def is_authorized(headers):
expected = f"Bearer {TOKEN}" expected = f"Bearer {TOKEN}"
return bool(TOKEN) and headers.get("Authorization", "") == expected 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 global current_process
# Give the app enough time to return the 202 response before the container restarts. # 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 = threading.Thread(target=watch_process, args=(current_process,), daemon=True)
thread.start() 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): class ThreadedUnixServer(socketserver.ThreadingMixIn, socketserver.UnixStreamServer):
daemon_threads = True daemon_threads = True
@@ -158,7 +263,7 @@ class Handler(BaseHTTPRequestHandler):
if not is_authorized(self.headers): if not is_authorized(self.headers):
self.send_json(HTTPStatus.UNAUTHORIZED, {"error": "Unauthorized"}) self.send_json(HTTPStatus.UNAUTHORIZED, {"error": "Unauthorized"})
return return
self.send_json(HTTPStatus.OK, load_status()) self.send_json(HTTPStatus.OK, get_status_payload())
return return
self.send_json(HTTPStatus.NOT_FOUND, {"error": "Not found"}) self.send_json(HTTPStatus.NOT_FOUND, {"error": "Not found"})
@@ -174,30 +279,16 @@ class Handler(BaseHTTPRequestHandler):
self.send_json(HTTPStatus.UNAUTHORIZED, {"error": "Unauthorized"}) self.send_json(HTTPStatus.UNAUTHORIZED, {"error": "Unauthorized"})
return return
with status_lock: if not schedule_update(trigger="manual"):
if current_process and current_process.poll() is None: self.send_json(
self.send_json( HTTPStatus.CONFLICT,
HTTPStatus.CONFLICT, {
{ "ok": False,
"ok": False, "status": "updating",
"status": "updating", "message": "An update is already running.",
"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,
) )
return
thread = threading.Thread(target=start_update_process, daemon=True)
thread.start()
self.send_json( self.send_json(
HTTPStatus.ACCEPTED, 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): def cleanup_socket(*_args):
try: try:
@@ -224,11 +340,13 @@ def main():
os.remove(SOCKET_PATH) os.remove(SOCKET_PATH)
normalize_status_on_startup() normalize_status_on_startup()
load_config()
signal.signal(signal.SIGTERM, cleanup_socket) signal.signal(signal.SIGTERM, cleanup_socket)
signal.signal(signal.SIGINT, cleanup_socket) signal.signal(signal.SIGINT, cleanup_socket)
update_status(available=True) update_status(available=True)
threading.Thread(target=auto_update_loop, daemon=True).start()
with ThreadedUnixServer(SOCKET_PATH, Handler) as server: with ThreadedUnixServer(SOCKET_PATH, Handler) as server:
os.chmod(SOCKET_PATH, 0o666) os.chmod(SOCKET_PATH, 0o666)
+79
View File
@@ -50,10 +50,16 @@ export default function AdminPage() {
lastFinishedAt?: string | null; lastFinishedAt?: string | null;
lastExitCode?: number | null; lastExitCode?: number | null;
lastError?: string | null; lastError?: string | null;
trigger?: 'manual' | 'auto' | null;
config?: {
autoUpdateEnabled: boolean;
intervalMinutes: number;
};
}; };
} | null>(null); } | null>(null);
const [loadingUpdateStatus, setLoadingUpdateStatus] = useState(false); const [loadingUpdateStatus, setLoadingUpdateStatus] = useState(false);
const [triggeringUpdate, setTriggeringUpdate] = useState(false); const [triggeringUpdate, setTriggeringUpdate] = useState(false);
const [savingAutoUpdate, setSavingAutoUpdate] = useState(false);
useEffect(() => { useEffect(() => {
fetch('/api/admin/me') 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<HTMLInputElement>) => { const handleFaviconUpload = async (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0]; const file = event.target.files?.[0];
event.target.value = ''; event.target.value = '';
@@ -710,6 +748,41 @@ export default function AdminPage() {
</button> </button>
</div> </div>
{updateStatus?.updater.available && updateStatus?.updater.config && (
<div style={{
marginTop: '16px',
padding: '12px',
borderRadius: '8px',
border: '1px solid var(--border)',
background: 'var(--background)',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'flex-start',
gap: '16px',
}}>
<div>
<div style={{ fontSize: '13px', fontWeight: 600, marginBottom: '4px' }}>
Automatic updates
</div>
<div style={{ fontSize: '12px', color: 'var(--foreground-secondary)' }}>
Enabled by default. This node checks for updates every {updateStatus.updater.config.intervalMinutes} minutes and installs them automatically.
</div>
</div>
<button
className={`btn btn-sm ${updateStatus.updater.config.autoUpdateEnabled ? 'btn-primary' : 'btn-ghost'}`}
onClick={handleToggleAutoUpdate}
disabled={savingAutoUpdate}
style={{ flexShrink: 0 }}
>
{savingAutoUpdate
? 'Saving...'
: updateStatus.updater.config.autoUpdateEnabled
? 'Disable'
: 'Enable'}
</button>
</div>
)}
<div style={{ display: 'grid', gap: '8px', marginTop: '16px', fontSize: '13px' }}> <div style={{ display: 'grid', gap: '8px', marginTop: '16px', fontSize: '13px' }}>
<div> <div>
<strong>Current build:</strong>{' '} <strong>Current build:</strong>{' '}
@@ -741,6 +814,12 @@ export default function AdminPage() {
{updateStatus.updater.lastExitCode} {updateStatus.updater.lastExitCode}
</div> </div>
)} )}
{updateStatus?.updater.trigger && (
<div>
<strong>Last trigger:</strong>{' '}
{updateStatus.updater.trigger === 'auto' ? 'Automatic update' : 'Manual update'}
</div>
)}
{updateStatus?.updater.lastError && ( {updateStatus?.updater.lastError && (
<div style={{ color: 'var(--danger)' }}> <div style={{ color: 'var(--danger)' }}>
<strong>Last error:</strong>{' '} <strong>Last error:</strong>{' '}
+25 -1
View File
@@ -1,7 +1,7 @@
import { NextResponse } from 'next/server'; import { NextResponse } from 'next/server';
import { requireAdmin } from '@/lib/auth/admin'; import { requireAdmin } from '@/lib/auth/admin';
import { getCurrentBuildInfo, getLatestPublishedBuild, compareBuildVersions } from '@/lib/version'; 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) { function isUpdateAvailable(currentVersion: string, latestVersion: string | null | undefined) {
if (!latestVersion) { 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 }
);
}
}
+3 -1
View File
@@ -735,7 +735,9 @@ export async function GET(request: Request) {
); );
if (!profileData?.posts) return []; 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}`, id: `swarm:${domain}:${post.id}`,
content: post.content, content: post.content,
createdAt: new Date(post.createdAt), createdAt: new Date(post.createdAt),
+4 -2
View File
@@ -6,7 +6,7 @@
import { NextRequest, NextResponse } from 'next/server'; import { NextRequest, NextResponse } from 'next/server';
import { db, posts, users, media, nodes } from '@/db'; 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 { export interface SwarmUserProfile {
handle: string; handle: string;
@@ -116,7 +116,9 @@ export async function GET(request: NextRequest, context: RouteContext) {
.where( .where(
and( and(
eq(posts.userId, user.id), eq(posts.userId, user.id),
eq(posts.isRemoved, false) eq(posts.isRemoved, false),
isNull(posts.replyToId),
isNull(posts.swarmReplyToId)
) )
) )
.orderBy(desc(posts.createdAt)) .orderBy(desc(posts.createdAt))
+12 -10
View File
@@ -19,6 +19,7 @@ export default function PostDetailPage() {
const [post, setPost] = useState<Post | null>(null); const [post, setPost] = useState<Post | null>(null);
const [replies, setReplies] = useState<Post[]>([]); const [replies, setReplies] = useState<Post[]>([]);
const [replyingTo, setReplyingTo] = useState<Post | null>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
@@ -31,6 +32,7 @@ export default function PostDetailPage() {
const data = await res.json(); const data = await res.json();
setPost(data.post); setPost(data.post);
setReplies(data.replies || []); setReplies(data.replies || []);
setReplyingTo(data.post);
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load post'); setError(err instanceof Error ? err.message : 'Failed to load post');
} finally { } finally {
@@ -47,13 +49,13 @@ export default function PostDetailPage() {
let swarmReplyTo: { postId: string; nodeDomain: string; content?: string; author?: any } | undefined; let swarmReplyTo: { postId: string; nodeDomain: string; content?: string; author?: any } | undefined;
let localReplyToId: string | undefined = replyToId; 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 // This is a reply to a swarm post - send to the origin node
swarmReplyTo = { swarmReplyTo = {
postId: post.originalPostId, postId: replyingTo.originalPostId,
nodeDomain: post.nodeDomain, nodeDomain: replyingTo.nodeDomain,
content: post.content, content: replyingTo.content,
author: post.author, author: replyingTo.author,
}; };
localReplyToId = undefined; // Can't use UUID foreign key for swarm posts localReplyToId = undefined; // Can't use UUID foreign key for swarm posts
} }
@@ -78,6 +80,7 @@ export default function PostDetailPage() {
if (post) { if (post) {
setPost({ ...post, repliesCount: post.repliesCount + 1 }); setPost({ ...post, repliesCount: post.repliesCount + 1 });
} }
setReplyingTo(post);
} }
}; };
@@ -172,9 +175,10 @@ export default function PostDetailPage() {
<div style={{ borderBottom: '1px solid var(--border)' }}> <div style={{ borderBottom: '1px solid var(--border)' }}>
<Compose <Compose
onPost={handlePost} onPost={handlePost}
replyingTo={post} replyingTo={replyingTo}
isReply isReply
placeholder="Post your reply" placeholder="Post your reply"
onCancelReply={() => setReplyingTo(post)}
/> />
</div> </div>
)} )}
@@ -188,10 +192,8 @@ export default function PostDetailPage() {
onRepost={handleRepost} onRepost={handleRepost}
onDelete={handleDelete} onDelete={handleDelete}
parentPostAuthorId={post.author.id} parentPostAuthorId={post.author.id}
onComment={(p) => { onComment={(replyTarget) => {
// In detail view, commenting on a reply should probably just focus the main composer setReplyingTo(replyTarget);
// but we could also implement nested replies later.
// For now, let's keep it simple.
const composer = document.querySelector('.compose-input') as HTMLTextAreaElement; const composer = document.querySelector('.compose-input') as HTMLTextAreaElement;
composer?.focus(); composer?.focus();
}} }}
+14 -1
View File
@@ -12,6 +12,11 @@ export interface HostUpdaterStatus {
lastExitCode?: number | null; lastExitCode?: number | null;
lastError?: string | null; lastError?: string | null;
pid?: number | null; pid?: number | null;
trigger?: 'manual' | 'auto' | null;
config?: {
autoUpdateEnabled: boolean;
intervalMinutes: number;
};
} }
function getUpdaterConfig() { function getUpdaterConfig() {
@@ -25,7 +30,7 @@ function getUpdaterConfig() {
}; };
} }
function requestUpdater<T>(method: 'GET' | 'POST', path: string, body?: unknown): Promise<T> { function requestUpdater<T>(method: 'GET' | 'POST' | 'PATCH', path: string, body?: unknown): Promise<T> {
const { socketPath, token, enabled } = getUpdaterConfig(); const { socketPath, token, enabled } = getUpdaterConfig();
if (!enabled) { if (!enabled) {
@@ -114,3 +119,11 @@ export async function getHostUpdaterStatus(): Promise<HostUpdaterStatus> {
export async function triggerHostUpdate() { export async function triggerHostUpdate() {
return requestUpdater<{ ok: boolean; status: string; message?: string }>('POST', '/update'); 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 }
);
}