diff --git a/docker-compose.proxyless.yml b/docker-compose.proxyless.yml index 83a6532..15a29e3 100644 --- a/docker-compose.proxyless.yml +++ b/docker-compose.proxyless.yml @@ -45,10 +45,13 @@ services: ALLOW_LOCALHOST: ${ALLOW_LOCALHOST:-} ADMIN_EMAILS: ${ADMIN_EMAILS} BOT_MAX_PER_USER: ${BOT_MAX_PER_USER:-5} + HOST_UPDATER_SOCKET: ${HOST_UPDATER_SOCKET:-/var/run/synapsis-updater/updater.sock} + HOST_UPDATER_TOKEN: ${HOST_UPDATER_TOKEN:-} PORT: ${PORT:-3000} NODE_ENV: production volumes: - uploads_data:/app/uploads + - /var/run/synapsis-updater:/var/run/synapsis-updater networks: - synapsis-network depends_on: diff --git a/docker-compose.yml b/docker-compose.yml index e02a09d..c190a6a 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -66,6 +66,8 @@ services: # Optional settings BOT_MAX_PER_USER: ${BOT_MAX_PER_USER:-5} + HOST_UPDATER_SOCKET: ${HOST_UPDATER_SOCKET:-/var/run/synapsis-updater/updater.sock} + HOST_UPDATER_TOKEN: ${HOST_UPDATER_TOKEN:-} # Port configuration (auto or specific port) PORT: ${PORT:-3000} @@ -77,6 +79,7 @@ services: - uploads_data:/app/uploads # Shared volume for port coordination with Caddy - port_data:/var/run/synapsis + - /var/run/synapsis-updater:/var/run/synapsis-updater networks: - synapsis-network depends_on: diff --git a/docker/.env.example b/docker/.env.example index 94dc042..08c210c 100644 --- a/docker/.env.example +++ b/docker/.env.example @@ -39,6 +39,11 @@ DB_NAME=synapsis # Existing reverse proxy mode (PROXY=none): use a fixed localhost port, e.g. PORT=3000. PORT=auto +# Host updater integration for the admin panel. +# These are generated by the installer for Docker installs. +# HOST_UPDATER_SOCKET=/var/run/synapsis-updater/updater.sock +# HOST_UPDATER_TOKEN= + # Allow localhost domains in production (set only for local testing) # ALLOW_LOCALHOST=1 diff --git a/docker/Dockerfile b/docker/Dockerfile index c17b3b3..26b84f7 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -33,8 +33,21 @@ COPY --from=deps /app/node_modules ./node_modules COPY . . # Set environment variables for build +ARG APP_VERSION=dev +ARG APP_COMMIT=unknown +ARG APP_BUILD_DATE= +ARG APP_GITHUB_URL= +ARG APP_IMAGE_REPO=ghcr.io/gnosyslabs/synapsis +ARG APP_SOURCE_REPO=https://github.com/GnosysLabs/Synapsis + ENV NODE_ENV=production ENV NEXT_TELEMETRY_DISABLED=1 +ENV APP_VERSION=${APP_VERSION} +ENV APP_COMMIT=${APP_COMMIT} +ENV APP_BUILD_DATE=${APP_BUILD_DATE} +ENV APP_GITHUB_URL=${APP_GITHUB_URL} +ENV APP_IMAGE_REPO=${APP_IMAGE_REPO} +ENV APP_SOURCE_REPO=${APP_SOURCE_REPO} # Build the Next.js application RUN npm run build @@ -47,8 +60,18 @@ RUN apk add --no-cache libc6-compat openssl netcat-openbsd wget WORKDIR /app -LABEL org.opencontainers.image.source="https://github.com/GnosysLabs/Synapsis" +ARG APP_VERSION=dev +ARG APP_COMMIT=unknown +ARG APP_BUILD_DATE= +ARG APP_GITHUB_URL= +ARG APP_IMAGE_REPO=ghcr.io/gnosyslabs/synapsis +ARG APP_SOURCE_REPO=https://github.com/GnosysLabs/Synapsis + +LABEL org.opencontainers.image.source="${APP_SOURCE_REPO}" LABEL org.opencontainers.image.description="Synapsis self-hosted social node" +LABEL org.opencontainers.image.version="${APP_VERSION}" +LABEL org.opencontainers.image.revision="${APP_COMMIT}" +LABEL org.opencontainers.image.created="${APP_BUILD_DATE}" # Create non-root user for security RUN addgroup --system --gid 1001 nodejs && \ @@ -62,6 +85,12 @@ ENV NODE_ENV=production ENV NEXT_TELEMETRY_DISABLED=1 ENV PORT=3000 ENV HOSTNAME="0.0.0.0" +ENV APP_VERSION=${APP_VERSION} +ENV APP_COMMIT=${APP_COMMIT} +ENV APP_BUILD_DATE=${APP_BUILD_DATE} +ENV APP_GITHUB_URL=${APP_GITHUB_URL} +ENV APP_IMAGE_REPO=${APP_IMAGE_REPO} +ENV APP_SOURCE_REPO=${APP_SOURCE_REPO} # Copy public assets COPY --from=builder /app/public ./public diff --git a/docker/host-updater.py b/docker/host-updater.py new file mode 100755 index 0000000..ff3c8d6 --- /dev/null +++ b/docker/host-updater.py @@ -0,0 +1,197 @@ +#!/usr/bin/env python3 + +import json +import os +import signal +import socketserver +import subprocess +import threading +from datetime import datetime, timezone +from http import HTTPStatus +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") +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") + +status_lock = threading.Lock() +current_process = None + + +def now_iso(): + return datetime.now(timezone.utc).isoformat() + + +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(LOG_FILE), exist_ok=True) + + +def load_status(): + if not os.path.exists(STATUS_FILE): + return { + "available": True, + "status": "idle", + "message": "Ready to update.", + "lastStartedAt": None, + "lastFinishedAt": None, + "lastExitCode": None, + "lastError": None, + "pid": None, + } + + with open(STATUS_FILE, "r", encoding="utf-8") as handle: + return json.load(handle) + + +def save_status(status): + with open(STATUS_FILE, "w", encoding="utf-8") as handle: + json.dump(status, handle, indent=2) + + +def update_status(**changes): + with status_lock: + status = load_status() + status.update(changes) + save_status(status) + return status + + +def is_authorized(headers): + expected = f"Bearer {TOKEN}" + return bool(TOKEN) and headers.get("Authorization", "") == expected + + +def watch_process(process): + exit_code = process.wait() + update_status( + status="success" if exit_code == 0 else "error", + message="Synapsis update completed." if exit_code == 0 else "Synapsis update failed.", + lastFinishedAt=now_iso(), + lastExitCode=exit_code, + lastError=None if exit_code == 0 else f"Updater exited with code {exit_code}", + pid=None, + ) + + +class ThreadedUnixServer(socketserver.ThreadingMixIn, socketserver.UnixStreamServer): + daemon_threads = True + + +class Handler(BaseHTTPRequestHandler): + server_version = "SynapsisHostUpdater/1.0" + + def log_message(self, format, *args): + return + + def send_json(self, status_code, payload): + body = json.dumps(payload).encode("utf-8") + self.send_response(status_code) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + def do_GET(self): + if self.path == "/health": + self.send_json(HTTPStatus.OK, {"ok": True}) + return + + if self.path == "/status": + if not is_authorized(self.headers): + self.send_json(HTTPStatus.UNAUTHORIZED, {"error": "Unauthorized"}) + return + self.send_json(HTTPStatus.OK, load_status()) + return + + self.send_json(HTTPStatus.NOT_FOUND, {"error": "Not found"}) + + def do_POST(self): + global current_process + + if self.path != "/update": + self.send_json(HTTPStatus.NOT_FOUND, {"error": "Not found"}) + return + + if not is_authorized(self.headers): + 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 + + log_handle = open(LOG_FILE, "a", encoding="utf-8") + current_process = subprocess.Popen( + [UPDATE_SCRIPT], + cwd=INSTALL_DIR, + stdout=log_handle, + stderr=subprocess.STDOUT, + start_new_session=True, + env={ + **os.environ, + "INSTALL_DIR": INSTALL_DIR, + }, + ) + + update_status( + status="updating", + message="Synapsis update started.", + lastStartedAt=now_iso(), + lastFinishedAt=None, + lastExitCode=None, + lastError=None, + pid=current_process.pid, + ) + + thread = threading.Thread(target=watch_process, args=(current_process,), daemon=True) + thread.start() + + self.send_json( + HTTPStatus.ACCEPTED, + { + "ok": True, + "status": "updating", + "message": "Synapsis update started.", + }, + ) + + +def cleanup_socket(*_args): + try: + if os.path.exists(SOCKET_PATH): + os.remove(SOCKET_PATH) + finally: + raise SystemExit(0) + + +def main(): + ensure_parent_dirs() + + if os.path.exists(SOCKET_PATH): + os.remove(SOCKET_PATH) + + signal.signal(signal.SIGTERM, cleanup_socket) + signal.signal(signal.SIGINT, cleanup_socket) + + update_status(available=True) + + with ThreadedUnixServer(SOCKET_PATH, Handler) as server: + os.chmod(SOCKET_PATH, 0o666) + server.serve_forever() + + +if __name__ == "__main__": + main() diff --git a/docker/install.sh b/docker/install.sh index d964966..0dec411 100644 --- a/docker/install.sh +++ b/docker/install.sh @@ -7,6 +7,7 @@ REF="${REF:-main}" INSTALL_DIR="${1:-${INSTALL_DIR:-/opt/synapsis}}" PUBLIC_INSTALL_URL="${PUBLIC_INSTALL_URL:-https://synapsis.social/install.sh}" PROXY="${PROXY:-caddy}" +HOST_UPDATER_SOCKET_PATH="${HOST_UPDATER_SOCKET_PATH:-/var/run/synapsis-updater/updater.sock}" require_command() { if ! command -v "$1" >/dev/null 2>&1; then @@ -79,6 +80,19 @@ set_env_value() { rm -f "${file}.bak" } +ensure_env_value() { + file="$1" + key="$2" + value="$3" + + if grep -q "^${key}=" "$file" 2>/dev/null; then + set_env_value "$file" "$key" "$value" + return + fi + + printf '%s=%s\n' "$key" "$value" >> "$file" +} + get_env_value() { file="$1" key="$2" @@ -94,6 +108,15 @@ generate_db_password() { openssl rand -base64 24 | tr -d '\n' | tr '/+' '_-' | cut -c1-32 } +generate_token() { + if command -v openssl >/dev/null 2>&1; then + openssl rand -hex 32 + return + fi + + date +%s | sha256sum | awk '{print $1}' +} + find_available_port() { start="${1:-3000}" end="${2:-3020}" @@ -153,6 +176,103 @@ install_docker_if_needed() { fi } +install_python3_if_needed() { + if command -v python3 >/dev/null 2>&1; then + return + fi + + if command -v apt-get >/dev/null 2>&1; then + apt-get update + apt-get install -y python3 + return + fi + + if command -v apk >/dev/null 2>&1; then + apk add --no-cache python3 + return + fi + + if command -v dnf >/dev/null 2>&1; then + dnf install -y python3 + return + fi + + if command -v yum >/dev/null 2>&1; then + yum install -y python3 + return + fi + + echo "⚠️ Could not install python3 automatically. Host updater will be unavailable." >&2 +} + +install_host_updater() { + if [ "$(id -u)" -ne 0 ]; then + echo "⚠️ Host updater installation requires root. Skipping one-click updater setup." + return + fi + + download_file "docker/host-updater.py" "${INSTALL_DIR}/host-updater.py" + download_file "docker/update-local.sh" "${INSTALL_DIR}/update-local.sh" + chmod 755 "${INSTALL_DIR}/host-updater.py" "${INSTALL_DIR}/update-local.sh" + + mkdir -p "$(dirname "${HOST_UPDATER_SOCKET_PATH}")" + + updater_token="$(get_env_value "${INSTALL_DIR}/.env" "HOST_UPDATER_TOKEN" || true)" + if [ -z "${updater_token}" ]; then + updater_token="$(generate_token)" + ensure_env_value "${INSTALL_DIR}/.env" "HOST_UPDATER_TOKEN" "${updater_token}" + fi + + ensure_env_value "${INSTALL_DIR}/.env" "HOST_UPDATER_SOCKET" "${HOST_UPDATER_SOCKET_PATH}" + + if ! command -v systemctl >/dev/null 2>&1; then + echo "⚠️ systemd not found. Admin one-click updates will be unavailable on this host." + return + fi + + install_python3_if_needed + if ! command -v python3 >/dev/null 2>&1; then + echo "⚠️ python3 is unavailable. Admin one-click updates will be unavailable." + return + fi + + cat > "${INSTALL_DIR}/updater.env" < /etc/systemd/system/synapsis-updater.service </dev/null 2>&1 || true + systemctl restart synapsis-updater.service >/dev/null 2>&1 || true +} + require_command curl require_command chmod require_command mkdir @@ -228,6 +348,8 @@ else fi fi +install_host_updater + echo "" echo "Next steps:" echo " 1. Review ${INSTALL_DIR}/.env" diff --git a/docker/uninstall.sh b/docker/uninstall.sh index 0a4a779..950cecc 100644 --- a/docker/uninstall.sh +++ b/docker/uninstall.sh @@ -81,6 +81,14 @@ cleanup_named_resources() { echo "🗑️ Removing network synapsis_synapsis-network" docker network rm synapsis_synapsis-network >/dev/null 2>&1 || true fi + + if command -v systemctl >/dev/null 2>&1; then + systemctl disable --now synapsis-updater.service >/dev/null 2>&1 || true + rm -f /etc/systemd/system/synapsis-updater.service + systemctl daemon-reload >/dev/null 2>&1 || true + fi + + rm -rf /var/run/synapsis-updater } cleanup_images() { diff --git a/docker/update-local.sh b/docker/update-local.sh new file mode 100755 index 0000000..b91bd2f --- /dev/null +++ b/docker/update-local.sh @@ -0,0 +1,73 @@ +#!/bin/sh + +set -eu + +REPO="${REPO:-GnosysLabs/Synapsis}" +REF="${REF:-main}" +INSTALL_DIR="${1:-${INSTALL_DIR:-/opt/synapsis}}" +RAW_BASE="https://raw.githubusercontent.com/${REPO}/${REF}" + +require_command() { + if ! command -v "$1" >/dev/null 2>&1; then + echo "❌ Required command not found: $1" >&2 + exit 1 + fi +} + +download_file() { + source_path="$1" + target_path="$2" + echo "⬇️ Downloading ${source_path}" + curl -fsSL "${RAW_BASE}/${source_path}" -o "${target_path}" +} + +detect_proxy_mode() { + if [ -f "${INSTALL_DIR}/Caddyfile" ] || [ -f "${INSTALL_DIR}/caddy-entrypoint.sh" ]; then + printf 'caddy\n' + return + fi + + printf 'none\n' +} + +compose_cmd() { + if [ -f "${INSTALL_DIR}/.env" ]; then + docker compose --env-file "${INSTALL_DIR}/.env" -f "${INSTALL_DIR}/docker-compose.yml" "$@" + else + docker compose -f "${INSTALL_DIR}/docker-compose.yml" "$@" + fi +} + +require_command curl +require_command docker +require_command chmod +require_command mkdir + +if [ ! -d "${INSTALL_DIR}" ]; then + echo "❌ ${INSTALL_DIR} does not exist." >&2 + exit 1 +fi + +ACTIVE_PROXY="$(detect_proxy_mode)" + +case "${ACTIVE_PROXY}" in + caddy) + download_file "docker-compose.yml" "${INSTALL_DIR}/docker-compose.yml" + download_file "docker/Caddyfile" "${INSTALL_DIR}/Caddyfile" + download_file "docker/caddy-entrypoint.sh" "${INSTALL_DIR}/caddy-entrypoint.sh" + chmod 755 "${INSTALL_DIR}/caddy-entrypoint.sh" + ;; + none) + download_file "docker-compose.proxyless.yml" "${INSTALL_DIR}/docker-compose.yml" + rm -f "${INSTALL_DIR}/Caddyfile" "${INSTALL_DIR}/caddy-entrypoint.sh" + ;; +esac + +echo "🐳 Pulling latest Synapsis image" +compose_cmd pull + +echo "🚀 Restarting Synapsis" +compose_cmd up -d --remove-orphans + +echo "" +echo "✅ Synapsis has been updated to the latest published image." diff --git a/docker/update.sh b/docker/update.sh new file mode 100644 index 0000000..f240feb --- /dev/null +++ b/docker/update.sh @@ -0,0 +1,237 @@ +#!/bin/sh + +set -eu + +REPO="${REPO:-GnosysLabs/Synapsis}" +REF="${REF:-main}" +INSTALL_DIR="${1:-${INSTALL_DIR:-/opt/synapsis}}" +RAW_BASE="https://raw.githubusercontent.com/${REPO}/${REF}" +PROXY="${PROXY:-}" +HOST_UPDATER_SOCKET_PATH="${HOST_UPDATER_SOCKET_PATH:-/var/run/synapsis-updater/updater.sock}" + +require_command() { + if ! command -v "$1" >/dev/null 2>&1; then + echo "❌ Required command not found: $1" >&2 + exit 1 + fi +} + +download_file() { + source_path="$1" + target_path="$2" + echo "⬇️ Downloading ${source_path}" + curl -fsSL "${RAW_BASE}/${source_path}" -o "${target_path}" +} + +set_env_value() { + file="$1" + key="$2" + value="$3" + + escaped_value=$(printf '%s\n' "$value" | sed -e 's/[\/&]/\\&/g') + sed -i.bak -E "s|^${key}=.*$|${key}=${escaped_value}|" "$file" + rm -f "${file}.bak" +} + +ensure_env_value() { + file="$1" + key="$2" + value="$3" + + if grep -q "^${key}=" "$file" 2>/dev/null; then + set_env_value "$file" "$key" "$value" + return + fi + + printf '%s=%s\n' "$key" "$value" >> "$file" +} + +get_env_value() { + file="$1" + key="$2" + sed -n -E "s/^${key}=(.*)$/\\1/p" "$file" | head -n 1 +} + +generate_token() { + if command -v openssl >/dev/null 2>&1; then + openssl rand -hex 32 + return + fi + + date +%s | sha256sum | awk '{print $1}' +} + +normalize_proxy_mode() { + case "$1" in + caddy|none) + printf '%s\n' "$1" + ;; + *) + echo "❌ Unsupported PROXY mode: $1" >&2 + echo " Supported values: caddy, none" >&2 + exit 1 + ;; + esac +} + +detect_proxy_mode() { + if [ -n "${PROXY}" ]; then + normalize_proxy_mode "${PROXY}" + return + fi + + if [ -f "${INSTALL_DIR}/Caddyfile" ] || [ -f "${INSTALL_DIR}/caddy-entrypoint.sh" ]; then + printf 'caddy\n' + return + fi + + printf 'none\n' +} + +install_python3_if_needed() { + if command -v python3 >/dev/null 2>&1; then + return + fi + + if command -v apt-get >/dev/null 2>&1; then + apt-get update + apt-get install -y python3 + return + fi + + if command -v apk >/dev/null 2>&1; then + apk add --no-cache python3 + return + fi +} + +install_host_updater() { + if [ "$(id -u)" -ne 0 ]; then + return + fi + + if [ ! -f "${INSTALL_DIR}/.env" ]; then + return + fi + + download_file "docker/host-updater.py" "${INSTALL_DIR}/host-updater.py" + download_file "docker/update-local.sh" "${INSTALL_DIR}/update-local.sh" + chmod 755 "${INSTALL_DIR}/host-updater.py" "${INSTALL_DIR}/update-local.sh" + + mkdir -p "$(dirname "${HOST_UPDATER_SOCKET_PATH}")" + + updater_token="$(get_env_value "${INSTALL_DIR}/.env" "HOST_UPDATER_TOKEN" || true)" + if [ -z "${updater_token}" ]; then + updater_token="$(generate_token)" + ensure_env_value "${INSTALL_DIR}/.env" "HOST_UPDATER_TOKEN" "${updater_token}" + fi + + ensure_env_value "${INSTALL_DIR}/.env" "HOST_UPDATER_SOCKET" "${HOST_UPDATER_SOCKET_PATH}" + + if ! command -v systemctl >/dev/null 2>&1; then + return + fi + + install_python3_if_needed + if ! command -v python3 >/dev/null 2>&1; then + return + fi + + cat > "${INSTALL_DIR}/updater.env" < /etc/systemd/system/synapsis-updater.service </dev/null 2>&1 || true + systemctl restart synapsis-updater.service >/dev/null 2>&1 || true +} + +require_command curl +require_command docker +require_command chmod +require_command mkdir + +if [ ! -d "${INSTALL_DIR}" ]; then + echo "❌ ${INSTALL_DIR} does not exist." >&2 + echo " Run the installer first:" >&2 + echo " curl -fsSL https://synapsis.social/install.sh | bash" >&2 + exit 1 +fi + +if [ ! -f "${INSTALL_DIR}/docker-compose.yml" ]; then + echo "❌ ${INSTALL_DIR}/docker-compose.yml was not found." >&2 + echo " This does not look like a Synapsis install directory." >&2 + exit 1 +fi + +ACTIVE_PROXY="$(detect_proxy_mode)" + +echo "========================================" +echo " Synapsis Docker Updater" +echo "========================================" +echo " Repo: ${REPO}" +echo " Ref: ${REF}" +echo " Install dir: ${INSTALL_DIR}" +echo " Proxy mode: ${ACTIVE_PROXY}" +echo "========================================" + +mkdir -p "${INSTALL_DIR}" + +download_file "docker/.env.example" "${INSTALL_DIR}/.env.example" + +case "${ACTIVE_PROXY}" in + caddy) + download_file "docker-compose.yml" "${INSTALL_DIR}/docker-compose.yml" + download_file "docker/Caddyfile" "${INSTALL_DIR}/Caddyfile" + download_file "docker/caddy-entrypoint.sh" "${INSTALL_DIR}/caddy-entrypoint.sh" + chmod 755 "${INSTALL_DIR}/caddy-entrypoint.sh" + ;; + none) + download_file "docker-compose.proxyless.yml" "${INSTALL_DIR}/docker-compose.yml" + rm -f "${INSTALL_DIR}/Caddyfile" "${INSTALL_DIR}/caddy-entrypoint.sh" + ;; +esac + +install_host_updater + +echo "🐳 Pulling latest Synapsis image" +if [ -f "${INSTALL_DIR}/.env" ]; then + docker compose --env-file "${INSTALL_DIR}/.env" -f "${INSTALL_DIR}/docker-compose.yml" pull + echo "🚀 Restarting Synapsis" + docker compose --env-file "${INSTALL_DIR}/.env" -f "${INSTALL_DIR}/docker-compose.yml" up -d --remove-orphans +else + docker compose -f "${INSTALL_DIR}/docker-compose.yml" pull + echo "🚀 Restarting Synapsis" + docker compose -f "${INSTALL_DIR}/docker-compose.yml" up -d --remove-orphans +fi + +echo "" +echo "✅ Synapsis has been updated to the latest published image." diff --git a/scripts/docker-publish.sh b/scripts/docker-publish.sh new file mode 100755 index 0000000..b8cb6da --- /dev/null +++ b/scripts/docker-publish.sh @@ -0,0 +1,81 @@ +#!/bin/sh + +set -eu + +IMAGE_REPO="${IMAGE_REPO:-ghcr.io/gnosyslabs/synapsis}" +PACKAGE_API="${PACKAGE_API:-/orgs/GnosysLabs/packages/container/synapsis/versions?per_page=100}" +BUILDER="${BUILDER:-colima}" +PLATFORMS="${PLATFORMS:-linux/amd64,linux/arm64}" +DATE_PREFIX="${DATE_PREFIX:-$(date -u +%Y.%m.%d)}" +SOURCE_REPO="${SOURCE_REPO:-https://github.com/GnosysLabs/Synapsis}" + +require_command() { + if ! command -v "$1" >/dev/null 2>&1; then + echo "❌ Required command not found: $1" >&2 + exit 1 + fi +} + +require_command docker +require_command gh +require_command git + +CURRENT_SHA="$(git rev-parse --short HEAD)" +CURRENT_FULL_SHA="$(git rev-parse HEAD)" +BUILD_DATE="$(date -u +%Y-%m-%dT%H:%M:%SZ)" + +existing_tags="$( + gh api "${PACKAGE_API}" --paginate --jq '.[].metadata.container.tags[]?' 2>/dev/null || true +)" + +max_build=0 +for tag in ${existing_tags}; do + case "${tag}" in + "${DATE_PREFIX}".*) + build_number="${tag##${DATE_PREFIX}.}" + case "${build_number}" in + ''|*[!0-9]*) + ;; + *) + if [ "${build_number}" -gt "${max_build}" ]; then + max_build="${build_number}" + fi + ;; + esac + ;; + esac +done + +next_build=$((max_build + 1)) +APP_VERSION="${DATE_PREFIX}.${next_build}" +GITHUB_URL="${SOURCE_REPO}/commit/${CURRENT_FULL_SHA}" + +echo "========================================" +echo " Synapsis Docker Publish" +echo "========================================" +echo " Version: ${APP_VERSION}" +echo " Commit: ${CURRENT_SHA}" +echo " Image: ${IMAGE_REPO}" +echo "========================================" + +docker buildx build \ + --builder "${BUILDER}" \ + --platform "${PLATFORMS}" \ + --build-arg "APP_VERSION=${APP_VERSION}" \ + --build-arg "APP_COMMIT=${CURRENT_FULL_SHA}" \ + --build-arg "APP_BUILD_DATE=${BUILD_DATE}" \ + --build-arg "APP_GITHUB_URL=${GITHUB_URL}" \ + --build-arg "APP_IMAGE_REPO=${IMAGE_REPO}" \ + --build-arg "APP_SOURCE_REPO=${SOURCE_REPO}" \ + -f docker/Dockerfile \ + -t "${IMAGE_REPO}:latest" \ + -t "${IMAGE_REPO}:${APP_VERSION}" \ + -t "${IMAGE_REPO}:${CURRENT_SHA}" \ + --push \ + . + +echo "" +echo "✅ Published:" +echo " ${IMAGE_REPO}:latest" +echo " ${IMAGE_REPO}:${APP_VERSION}" +echo " ${IMAGE_REPO}:${CURRENT_SHA}" diff --git a/src/app/admin/page.tsx b/src/app/admin/page.tsx index 5845e27..92f9217 100644 --- a/src/app/admin/page.tsx +++ b/src/app/admin/page.tsx @@ -38,6 +38,22 @@ export default function AdminPage() { const [logoUploadError, setLogoUploadError] = useState(null); const [isUploadingFavicon, setIsUploadingFavicon] = useState(false); const [faviconUploadError, setFaviconUploadError] = useState(null); + const [updateStatus, setUpdateStatus] = useState<{ + current: { version: string; commit: string | null; buildDate: string | null }; + latest: { version: string; commit: string | null; buildDate: string | null } | null; + updateAvailable: boolean; + updater: { + available: boolean; + status: string; + message?: string; + lastStartedAt?: string | null; + lastFinishedAt?: string | null; + lastExitCode?: number | null; + lastError?: string | null; + }; + } | null>(null); + const [loadingUpdateStatus, setLoadingUpdateStatus] = useState(false); + const [triggeringUpdate, setTriggeringUpdate] = useState(false); useEffect(() => { fetch('/api/admin/me') @@ -71,12 +87,42 @@ export default function AdminPage() { } }; + const loadUpdateStatus = async () => { + setLoadingUpdateStatus(true); + try { + const res = await fetch('/api/admin/update', { cache: 'no-store' }); + if (!res.ok) { + throw new Error('Failed to load update status'); + } + + const data = await res.json(); + setUpdateStatus(data); + } catch { + setUpdateStatus(null); + } finally { + setLoadingUpdateStatus(false); + } + }; + useEffect(() => { if (isAdmin) { loadNodeSettings(); + loadUpdateStatus(); } }, [isAdmin]); + useEffect(() => { + if (!isAdmin) { + return; + } + + const interval = window.setInterval(() => { + loadUpdateStatus(); + }, 30000); + + return () => window.clearInterval(interval); + }, [isAdmin]); + const handleSaveSettings = async (override?: typeof nodeSettings) => { const payload = override ?? nodeSettings; setSavingSettings(true); @@ -182,6 +228,25 @@ export default function AdminPage() { setBannerPromptError(''); }; + const handleTriggerUpdate = async () => { + setTriggeringUpdate(true); + try { + const res = await fetch('/api/admin/update', { method: 'POST' }); + const data = await res.json().catch(() => ({})); + + if (!res.ok) { + throw new Error(data.error || 'Failed to start update'); + } + + showToast(data.message || 'Update started. Synapsis will restart shortly.', 'success'); + await loadUpdateStatus(); + } catch (error) { + showToast(error instanceof Error ? error.message : 'Failed to start update', 'error'); + } finally { + setTriggeringUpdate(false); + } + }; + const handleLogoUpload = async (event: React.ChangeEvent) => { const file = event.target.files?.[0]; event.target.value = ''; @@ -276,6 +341,13 @@ export default function AdminPage() { ); } + const formatTimestamp = (value?: string | null) => { + if (!value) return 'Never'; + const date = new Date(value); + if (Number.isNaN(date.getTime())) return value; + return date.toLocaleString(); + }; + return ( <>
{nodeSettings.bannerUrl && (
-
- Sidebar preview -
-
-
-
-

- Welcome to {nodeSettings.name || 'Your Node'} -

-

- {nodeSettings.description || 'A brief tagline for your node.'} -

-
-
+ />
)}
@@ -604,7 +662,92 @@ export default function AdminPage() { - + + +
+
+
+

System Update

+

+ Keep this node on the latest published Synapsis build. +

+
+ +
+ +
+
+ Current build:{' '} + {updateStatus?.current?.version || 'Unknown'} +
+
+ Latest build:{' '} + {updateStatus?.latest?.version || 'Unavailable'} +
+
+ Status:{' '} + {loadingUpdateStatus ? 'Checking…' : updateStatus?.updater.message || 'Ready'} +
+ {updateStatus?.updater.lastStartedAt && ( +
+ Last started:{' '} + {formatTimestamp(updateStatus.updater.lastStartedAt)} +
+ )} + {updateStatus?.updater.lastFinishedAt && ( +
+ Last finished:{' '} + {formatTimestamp(updateStatus.updater.lastFinishedAt)} +
+ )} + {typeof updateStatus?.updater.lastExitCode === 'number' && ( +
+ Last exit code:{' '} + {updateStatus.updater.lastExitCode} +
+ )} + {updateStatus?.updater.lastError && ( +
+ Last error:{' '} + {updateStatus.updater.lastError} +
+ )} +
+ + {!updateStatus?.updater.available && ( +
+ One-click updates are unavailable on this host. Use: +
+ curl -fsSL https://synapsis.social/update.sh | bash +
+
+ )} +
)} diff --git a/src/app/api/admin/update/route.ts b/src/app/api/admin/update/route.ts new file mode 100644 index 0000000..f44a233 --- /dev/null +++ b/src/app/api/admin/update/route.ts @@ -0,0 +1,58 @@ +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'; + +function isUpdateAvailable(currentVersion: string, latestVersion: string | null | undefined) { + if (!latestVersion) { + return false; + } + + return compareBuildVersions(currentVersion, latestVersion) < 0; +} + +export async function GET() { + try { + await requireAdmin(); + + const [latest, updater] = await Promise.all([ + getLatestPublishedBuild(), + getHostUpdaterStatus(), + ]); + + const current = getCurrentBuildInfo(); + + return NextResponse.json({ + current, + latest, + updateAvailable: isUpdateAvailable(current.version, latest?.version), + updater, + }); + } catch (error) { + if (error instanceof Error && error.message === 'Admin required') { + return NextResponse.json({ error: 'Admin required' }, { status: 403 }); + } + + console.error('[Admin Update] Status error:', error); + return NextResponse.json({ error: 'Failed to get update status' }, { status: 500 }); + } +} + +export async function POST() { + try { + await requireAdmin(); + + const result = await triggerHostUpdate(); + return NextResponse.json(result, { status: 202 }); + } catch (error) { + if (error instanceof Error && error.message === 'Admin required') { + return NextResponse.json({ error: 'Admin required' }, { status: 403 }); + } + + console.error('[Admin Update] Trigger error:', error); + return NextResponse.json( + { error: error instanceof Error ? error.message : 'Failed to trigger update' }, + { status: 500 } + ); + } +} diff --git a/src/app/api/chat/receive/route.ts b/src/app/api/chat/receive/route.ts index f21fb26..14f3b91 100644 --- a/src/app/api/chat/receive/route.ts +++ b/src/app/api/chat/receive/route.ts @@ -7,26 +7,56 @@ import { verifySwarmRequest } from '@/lib/swarm/signature'; import { fetchAndCacheRemoteKey, logKeyChange } from '@/lib/swarm/identity-cache'; import { z } from 'zod'; -// Schema for direct signed action (legacy) -const chatReceiveSchema = z.object({ +const signedChatActionSchema = z.object({ + action: z.string().min(1), + did: z.string().regex(/^did:/, 'Must be a valid DID'), + handle: z.string().min(3).max(30), + ts: z.number(), + nonce: z.string().min(1), + sig: z.string().min(1), + data: z.object({ + recipientDid: z.string().regex(/^did:/, 'Must be a valid DID'), + content: z.string().min(1).max(5000), + }), +}); + +// Backward compatibility for older nodes that sent legacy field names. +const legacyChatActionSchema = z.object({ did: z.string().regex(/^did:/, 'Must be a valid DID'), handle: z.string().min(3).max(30), data: z.object({ recipientDid: z.string().regex(/^did:/, 'Must be a valid DID'), content: z.string().min(1).max(5000), }), - signature: z.string(), - timestamp: z.number().optional(), + signature: z.string().min(1), + timestamp: z.number(), }); -// Schema for federated envelope +const incomingChatActionSchema = z.union([signedChatActionSchema, legacyChatActionSchema]); + const federatedEnvelopeSchema = z.object({ - userAction: chatReceiveSchema, + userAction: incomingChatActionSchema, fullSenderHandle: z.string().min(3).max(60), sourceDomain: z.string().min(1), ts: z.number(), }); +function normalizeSignedAction(action: z.infer): SignedAction { + if ('sig' in action) { + return action; + } + + return { + action: 'chat', + data: action.data, + did: action.did, + handle: action.handle, + ts: action.timestamp, + nonce: `legacy:${action.did}:${action.timestamp}`, + sig: action.signature, + }; +} + /** * POST /api/chat/receive * Endpoint for receiving federated chat messages from other nodes. @@ -67,19 +97,19 @@ export async function POST(request: NextRequest) { } // Extract user's signed action and full handle from envelope - signedAction = body.userAction; + signedAction = normalizeSignedAction(body.userAction); fullSenderHandle = body.fullSenderHandle; console.log(`[Chat Receive] Federated envelope from node: ${sourceDomain}, full handle: ${fullSenderHandle}`); } else { // Legacy format - direct user signed action - const actionValidation = chatReceiveSchema.safeParse(body); + const actionValidation = incomingChatActionSchema.safeParse(body); if (!actionValidation.success) { return NextResponse.json( { error: 'Invalid action payload', details: actionValidation.error.issues }, { status: 400 } ); } - signedAction = body; + signedAction = normalizeSignedAction(actionValidation.data); } const { did, handle, data } = signedAction; diff --git a/src/app/api/posts/[id]/like/route.ts b/src/app/api/posts/[id]/like/route.ts index 0b53f43..4aab45a 100644 --- a/src/app/api/posts/[id]/like/route.ts +++ b/src/app/api/posts/[id]/like/route.ts @@ -79,12 +79,9 @@ export async function POST(request: Request, context: RouteContext) { actorDisplayName: user.displayName || user.handle, actorAvatarUrl: user.avatarUrl || undefined, actorNodeDomain: nodeDomain, - actorDid: user.did, - actorPublicKey: user.publicKey, interactionId: crypto.randomUUID(), timestamp: new Date().toISOString(), }, - userSignature: signedAction.sig, }); if (!result.success) { @@ -181,12 +178,9 @@ export async function POST(request: Request, context: RouteContext) { actorDisplayName: user.displayName || user.handle, actorAvatarUrl: user.avatarUrl || undefined, actorNodeDomain: nodeDomain, - actorDid: user.did, - actorPublicKey: user.publicKey, interactionId: crypto.randomUUID(), timestamp: new Date().toISOString(), }, - userSignature: signedAction.sig, }); if (result.success) { diff --git a/src/app/api/swarm/timeline/route.ts b/src/app/api/swarm/timeline/route.ts index 0c1ee88..baf50eb 100644 --- a/src/app/api/swarm/timeline/route.ts +++ b/src/app/api/swarm/timeline/route.ts @@ -63,6 +63,7 @@ export async function GET(request: NextRequest) { // Local posts may have apId if they've been federated, so we check nodeId instead let whereCondition = and( isNull(posts.replyToId), // Not a reply + isNull(posts.swarmReplyToId), // Not a swarm reply eq(posts.isRemoved, false), // Not removed isNull(users.nodeId) // Local user (not from another swarm node) ); diff --git a/src/app/api/users/[handle]/posts/route.ts b/src/app/api/users/[handle]/posts/route.ts index 5d52a9c..55cd97a 100644 --- a/src/app/api/users/[handle]/posts/route.ts +++ b/src/app/api/users/[handle]/posts/route.ts @@ -1,6 +1,6 @@ import { NextResponse } from 'next/server'; import { db, posts, users, likes } from '@/db'; -import { eq, desc, and, inArray, lt } from 'drizzle-orm'; +import { eq, desc, and, inArray, lt, sql } from 'drizzle-orm'; import { fetchSwarmUserProfile, isSwarmNode } from '@/lib/swarm/interactions'; import { discoverNode } from '@/lib/swarm/discovery'; @@ -51,8 +51,8 @@ export async function GET(request: Request, context: RouteContext) { displayName: profile.displayName || profile.handle, avatarUrl: profile.avatarUrl, }; - - const posts = profileData.posts.map((post: any) => ({ + + const remotePosts = profileData.posts.map((post: any) => ({ id: post.id, content: post.content, createdAt: post.createdAt, @@ -70,7 +70,7 @@ export async function GET(request: Request, context: RouteContext) { originalPostId: post.id, })); - return NextResponse.json({ posts, nextCursor: null }); + return NextResponse.json({ posts: remotePosts, nextCursor: null }); } return NextResponse.json({ posts: [] }); @@ -108,13 +108,35 @@ export async function GET(request: Request, context: RouteContext) { avatarUrl: profile.avatarUrl, }; - const posts = profileData.posts.map((post: any) => ({ + const swarmPostIds = profileData.posts.map((post: any) => `swarm:${remote.domain}:${post.id}`); + let localReplyCounts = new Map(); + + if (swarmPostIds.length > 0) { + const counts = await db.select({ + swarmReplyToId: posts.swarmReplyToId, + replyCount: sql`count(*)::int`, + }) + .from(posts) + .where(and( + inArray(posts.swarmReplyToId, swarmPostIds), + eq(posts.isRemoved, false) + )) + .groupBy(posts.swarmReplyToId); + + localReplyCounts = new Map( + counts + .filter(row => row.swarmReplyToId) + .map(row => [row.swarmReplyToId as string, row.replyCount]) + ); + } + + const remotePosts = profileData.posts.map((post: any) => ({ id: post.id, content: post.content, createdAt: post.createdAt, likesCount: post.likesCount || 0, repostsCount: post.repostsCount || 0, - repliesCount: post.repliesCount || 0, + repliesCount: (post.repliesCount || 0) + (localReplyCounts.get(`swarm:${remote.domain}:${post.id}`) || 0), author, media: post.media || [], linkPreviewUrl: post.linkPreviewUrl || null, @@ -126,7 +148,7 @@ export async function GET(request: Request, context: RouteContext) { originalPostId: post.id, })); - return NextResponse.json({ posts, nextCursor: null }); + return NextResponse.json({ posts: remotePosts, nextCursor: null }); } return NextResponse.json({ posts: [] }); diff --git a/src/app/api/version/route.ts b/src/app/api/version/route.ts index 99fcac3..d0a3b53 100644 --- a/src/app/api/version/route.ts +++ b/src/app/api/version/route.ts @@ -1,59 +1,6 @@ import { NextResponse } from 'next/server'; -import { execSync } from 'child_process'; +import { getCurrentBuildInfo } from '@/lib/version'; export async function GET() { - try { - // Get the total number of commits - const commitCount = execSync('git rev-list --count HEAD', { - encoding: 'utf-8', - stdio: ['pipe', 'pipe', 'ignore'] // Ignore stderr - }).trim(); - - // Get the short hash for reference - const commitHash = execSync('git rev-parse --short HEAD', { - encoding: 'utf-8', - stdio: ['pipe', 'pipe', 'ignore'] - }).trim(); - - const fullHash = execSync('git rev-parse HEAD', { - encoding: 'utf-8', - stdio: ['pipe', 'pipe', 'ignore'] - }).trim(); - - // Try to get the GitHub repo URL - let githubUrl = null; - try { - const remoteUrl = execSync('git config --get remote.origin.url', { - encoding: 'utf-8', - stdio: ['pipe', 'pipe', 'ignore'] - }).trim(); - - // Convert git URL to GitHub web URL - // Handle both SSH (git@github.com:user/repo.git) and HTTPS (https://github.com/user/repo.git) - if (remoteUrl.includes('github.com')) { - let cleanUrl = remoteUrl - .replace('git@github.com:', 'https://github.com/') - .replace(/\.git$/, ''); - - githubUrl = `${cleanUrl}/commit/${fullHash}`; - } - } catch (e) { - // If we can't get the remote URL, that's okay - } - - return NextResponse.json({ - count: parseInt(commitCount, 10), - hash: commitHash, - fullHash: fullHash, - githubUrl: githubUrl - }); - } catch (error) { - // If git is not available or not a git repo, return unknown - return NextResponse.json({ - count: null, - hash: 'unknown', - fullHash: null, - githubUrl: null - }); - } + return NextResponse.json(getCurrentBuildInfo()); } diff --git a/src/app/explore/page.tsx b/src/app/explore/page.tsx index f8dd545..2b7a651 100644 --- a/src/app/explore/page.tsx +++ b/src/app/explore/page.tsx @@ -8,6 +8,7 @@ import { Post } from '@/lib/types'; import { useFormattedHandle } from '@/lib/utils/handle'; import { Bot, Network, Server, EyeOff } from 'lucide-react'; import { useAuth } from '@/lib/contexts/AuthContext'; +import { signedAPI } from '@/lib/api/signed-fetch'; interface User { id: string; @@ -81,7 +82,7 @@ interface SwarmPost { } export default function ExplorePage() { - const { user } = useAuth(); + const { user, did, handle } = useAuth(); const [query, setQuery] = useState(''); const [activeTab, setActiveTab] = useState<'node' | 'swarm' | 'users' | 'search'>('node'); const [nodePosts, setNodePosts] = useState([]); @@ -268,8 +269,13 @@ export default function ExplorePage() { }; const handleLike = async (postId: string, currentLiked: boolean) => { - const method = currentLiked ? 'DELETE' : 'POST'; - const res = await fetch(`/api/posts/${postId}/like`, { method }); + if (!did || !handle) { + throw new Error('Please log in again.'); + } + + const res = currentLiked + ? await signedAPI.unlikePost(postId, did, handle) + : await signedAPI.likePost(postId, did, handle); if (!res.ok) { const data = await res.json().catch(() => ({})); @@ -278,8 +284,13 @@ export default function ExplorePage() { }; const handleRepost = async (postId: string, currentReposted: boolean) => { - const method = currentReposted ? 'DELETE' : 'POST'; - const res = await fetch(`/api/posts/${postId}/repost`, { method }); + if (!did || !handle) { + throw new Error('Please log in again.'); + } + + const res = currentReposted + ? await signedAPI.unrepostPost(postId, did, handle) + : await signedAPI.repostPost(postId, did, handle); if (!res.ok) { const data = await res.json().catch(() => ({})); diff --git a/src/app/search/page.tsx b/src/app/search/page.tsx index e6d0deb..41e4eeb 100644 --- a/src/app/search/page.tsx +++ b/src/app/search/page.tsx @@ -7,6 +7,8 @@ import { useFormattedHandle } from '@/lib/utils/handle'; import { PostCard } from '@/components/PostCard'; import { Post } from '@/lib/types'; import { Bot } from 'lucide-react'; +import { useAuth } from '@/lib/contexts/AuthContext'; +import { signedAPI } from '@/lib/api/signed-fetch'; interface User { id: string; @@ -132,6 +134,7 @@ export default function SearchPage() { const router = useRouter(); const searchParams = useSearchParams(); const initialQuery = searchParams.get('q') || ''; + const { did, handle } = useAuth(); const [query, setQuery] = useState(initialQuery); const [users, setUsers] = useState([]); @@ -181,8 +184,13 @@ export default function SearchPage() { }; const handleLike = async (postId: string, currentLiked: boolean) => { - const method = currentLiked ? 'DELETE' : 'POST'; - const res = await fetch(`/api/posts/${postId}/like`, { method }); + if (!did || !handle) { + throw new Error('Please log in again.'); + } + + const res = currentLiked + ? await signedAPI.unlikePost(postId, did, handle) + : await signedAPI.likePost(postId, did, handle); if (!res.ok) { const data = await res.json().catch(() => ({})); @@ -191,8 +199,13 @@ export default function SearchPage() { }; const handleRepost = async (postId: string, currentReposted: boolean) => { - const method = currentReposted ? 'DELETE' : 'POST'; - const res = await fetch(`/api/posts/${postId}/repost`, { method }); + if (!did || !handle) { + throw new Error('Please log in again.'); + } + + const res = currentReposted + ? await signedAPI.unrepostPost(postId, did, handle) + : await signedAPI.repostPost(postId, did, handle); if (!res.ok) { const data = await res.json().catch(() => ({})); diff --git a/src/app/u/[handle]/page.tsx b/src/app/u/[handle]/page.tsx index cfc48af..e6b805c 100644 --- a/src/app/u/[handle]/page.tsx +++ b/src/app/u/[handle]/page.tsx @@ -12,6 +12,7 @@ import { Rocket, MoreHorizontal, Mail } from 'lucide-react'; import { useFormattedHandle } from '@/lib/utils/handle'; import { Bot } from 'lucide-react'; import { useAuth } from '@/lib/contexts/AuthContext'; +import { signedAPI } from '@/lib/api/signed-fetch'; interface BotOwner { id: string; @@ -81,7 +82,7 @@ export default function ProfilePage() { const params = useParams(); const router = useRouter(); const handle = (params.handle as string)?.replace(/^@/, '') || ''; - const { isIdentityUnlocked, signUserAction } = useAuth(); + const { did, handle: currentHandle, isIdentityUnlocked, signUserAction } = useAuth(); const [user, setUser] = useState(null); const userFullHandle = user ? useFormattedHandle(user.handle) : ''; @@ -207,8 +208,13 @@ export default function ProfilePage() { }, [activeTab, postsCursor, repliesCursor, postsLoadingMore, repliesLoadingMore, loadMorePosts, loadMoreReplies]); const handleLike = async (postId: string, currentLiked: boolean) => { - const method = currentLiked ? 'DELETE' : 'POST'; - const res = await fetch(`/api/posts/${postId}/like`, { method }); + if (!did || !currentHandle) { + throw new Error('Please log in again.'); + } + + const res = currentLiked + ? await signedAPI.unlikePost(postId, did, currentHandle) + : await signedAPI.likePost(postId, did, currentHandle); if (!res.ok) { const data = await res.json().catch(() => ({})); @@ -217,8 +223,13 @@ export default function ProfilePage() { }; const handleRepost = async (postId: string, currentReposted: boolean) => { - const method = currentReposted ? 'DELETE' : 'POST'; - const res = await fetch(`/api/posts/${postId}/repost`, { method }); + if (!did || !currentHandle) { + throw new Error('Please log in again.'); + } + + const res = currentReposted + ? await signedAPI.unrepostPost(postId, did, currentHandle) + : await signedAPI.repostPost(postId, did, currentHandle); if (!res.ok) { const data = await res.json().catch(() => ({})); @@ -322,8 +333,14 @@ export default function ProfilePage() { return; } - const method = isFollowing ? 'DELETE' : 'POST'; - const res = await fetch(`/api/users/${handle}/follow`, { method }); + if (!did || !currentHandle) { + alert('Session expired. Please log in again.'); + return; + } + + const res = isFollowing + ? await signedAPI.unfollowUser(handle, did, currentHandle) + : await signedAPI.followUser(handle, did, currentHandle); if (res.ok && user) { setIsFollowing(!isFollowing); diff --git a/src/components/RightSidebar.tsx b/src/components/RightSidebar.tsx index f421e3c..a3c90c9 100644 --- a/src/components/RightSidebar.tsx +++ b/src/components/RightSidebar.tsx @@ -19,7 +19,12 @@ export function RightSidebar() { bannerUrl: '', admins: [] as Admin[], }); - const [version, setVersion] = useState<{ count: number | null; hash: string; fullHash: string | null; githubUrl: string | null } | null>(null); + const [version, setVersion] = useState<{ + version: string; + commit: string | null; + buildDate: string | null; + githubUrl: string | null; + } | null>(null); const [loading, setLoading] = useState(true); @@ -45,7 +50,7 @@ export function RightSidebar() { fetch('/api/version') .then(res => res.json()) .then(data => setVersion(data)) - .catch(() => setVersion({ count: null, hash: 'unknown', fullHash: null, githubUrl: null })); + .catch(() => setVersion({ version: 'unknown', commit: null, buildDate: null, githubUrl: null })); }, []); if (loading) { @@ -115,25 +120,21 @@ export function RightSidebar() {

Network Info

- Running Synapsis - {version && version.count !== null && ( + Running{' '} + Synapsis + {version?.version ? ` ${version.version}` : ''} + {version?.githubUrl && version?.commit && ( <> {' • '} - {version.githubUrl ? ( - - Commit {version.count} - - ) : ( - - Commit {version.count} - - )} + + {version.commit.slice(0, 7)} + )}

diff --git a/src/lib/host-updater.ts b/src/lib/host-updater.ts new file mode 100644 index 0000000..e598057 --- /dev/null +++ b/src/lib/host-updater.ts @@ -0,0 +1,111 @@ +import http from 'http'; + +const DEFAULT_SOCKET_PATH = '/var/run/synapsis-updater/updater.sock'; + +export interface HostUpdaterStatus { + available: boolean; + status: 'unavailable' | 'idle' | 'updating' | 'success' | 'error'; + message?: string; + lastStartedAt?: string | null; + lastFinishedAt?: string | null; + lastExitCode?: number | null; + lastError?: string | null; + pid?: number | null; +} + +function getUpdaterConfig() { + const socketPath = process.env.HOST_UPDATER_SOCKET || DEFAULT_SOCKET_PATH; + const token = process.env.HOST_UPDATER_TOKEN || ''; + + return { + socketPath, + token, + enabled: Boolean(socketPath && token), + }; +} + +function requestUpdater(method: 'GET' | 'POST', path: string, body?: unknown): Promise { + const { socketPath, token, enabled } = getUpdaterConfig(); + + if (!enabled) { + return Promise.reject(new Error('Host updater is not configured')); + } + + const payload = body ? JSON.stringify(body) : undefined; + + return new Promise((resolve, reject) => { + const request = http.request( + { + method, + socketPath, + path, + headers: { + Authorization: `Bearer ${token}`, + Accept: 'application/json', + ...(payload + ? { + 'Content-Type': 'application/json', + 'Content-Length': Buffer.byteLength(payload), + } + : {}), + }, + }, + (response) => { + const chunks: Buffer[] = []; + + response.on('data', (chunk) => chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk))); + response.on('end', () => { + const text = Buffer.concat(chunks).toString('utf8'); + const data = text ? JSON.parse(text) : {}; + + if ((response.statusCode || 500) >= 400) { + const message = data.error || `Updater request failed with ${response.statusCode}`; + reject(new Error(message)); + return; + } + + resolve(data as T); + }); + } + ); + + request.on('error', (error) => { + reject(error); + }); + + if (payload) { + request.write(payload); + } + + request.end(); + }); +} + +export async function getHostUpdaterStatus(): Promise { + const config = getUpdaterConfig(); + if (!config.enabled) { + return { + available: false, + status: 'unavailable', + message: 'Host updater is not configured for this install.', + }; + } + + try { + const status = await requestUpdater('GET', '/status'); + return { + ...status, + available: true, + }; + } catch (error) { + return { + available: false, + status: 'unavailable', + message: error instanceof Error ? error.message : 'Host updater is unavailable.', + }; + } +} + +export async function triggerHostUpdate() { + return requestUpdater<{ ok: boolean; status: string; message?: string }>('POST', '/update'); +} diff --git a/src/lib/swarm/interactions.ts b/src/lib/swarm/interactions.ts index d0e247a..f345b71 100644 --- a/src/lib/swarm/interactions.ts +++ b/src/lib/swarm/interactions.ts @@ -58,12 +58,9 @@ export interface SwarmLikePayload { actorDisplayName: string; actorAvatarUrl?: string; actorNodeDomain: string; - actorDid: string; // User's DID - actorPublicKey: string; // User's public key for verification interactionId: string; timestamp: string; }; - userSignature: string; // User's cryptographic signature } export interface SwarmUnlikePayload { diff --git a/src/lib/version.ts b/src/lib/version.ts new file mode 100644 index 0000000..0a2417e --- /dev/null +++ b/src/lib/version.ts @@ -0,0 +1,241 @@ +import packageJson from '../../package.json'; + +const DEFAULT_IMAGE_REPO = 'ghcr.io/gnosyslabs/synapsis'; +const VERSION_CACHE_TTL_MS = 5 * 60 * 1000; + +export interface BuildInfo { + version: string; + commit: string | null; + buildDate: string | null; + githubUrl: string | null; + imageDigest: string | null; + imageRepo: string; + sourceRepo: string; +} + +type CachedLatest = { + expiresAt: number; + value: PublishedBuildInfo | null; +}; + +export interface PublishedBuildInfo extends BuildInfo { + tag: string; +} + +let latestVersionCache: CachedLatest | null = null; + +function normalizeImageRepo(value: string): string { + if (!value) { + return DEFAULT_IMAGE_REPO; + } + + return value.startsWith('ghcr.io/') ? value : `ghcr.io/${value}`; +} + +function imageRepoPath(value: string): string { + return normalizeImageRepo(value).replace(/^ghcr\.io\//, ''); +} + +function buildGithubUrl(sourceRepo: string, commit: string | null): string | null { + if (!commit || !sourceRepo) { + return null; + } + + return `${sourceRepo.replace(/\/$/, '')}/commit/${commit}`; +} + +export function getCurrentBuildInfo(): BuildInfo { + const sourceRepo = process.env.APP_SOURCE_REPO || 'https://github.com/GnosysLabs/Synapsis'; + const version = process.env.APP_VERSION || packageJson.version || 'dev'; + const commit = process.env.APP_COMMIT || null; + const buildDate = process.env.APP_BUILD_DATE || null; + const imageDigest = process.env.APP_IMAGE_DIGEST || null; + const imageRepo = normalizeImageRepo(process.env.APP_IMAGE_REPO || DEFAULT_IMAGE_REPO); + const githubUrl = process.env.APP_GITHUB_URL || buildGithubUrl(sourceRepo, commit); + + return { + version, + commit, + buildDate, + githubUrl, + imageDigest, + imageRepo, + sourceRepo, + }; +} + +export function parseVersionTuple(version: string): [number, number, number, number] | null { + const match = /^(\d{4})\.(\d{2})\.(\d{2})\.(\d+)$/.exec(version); + if (!match) { + return null; + } + + return [ + Number(match[1]), + Number(match[2]), + Number(match[3]), + Number(match[4]), + ]; +} + +export function compareBuildVersions(a: string, b: string): number { + const aTuple = parseVersionTuple(a); + const bTuple = parseVersionTuple(b); + + if (!aTuple && !bTuple) { + return a.localeCompare(b); + } + if (!aTuple) { + return -1; + } + if (!bTuple) { + return 1; + } + + for (let i = 0; i < aTuple.length; i += 1) { + if (aTuple[i] !== bTuple[i]) { + return aTuple[i] - bTuple[i]; + } + } + + return 0; +} + +async function fetchGhcrToken(repoPath: string): Promise { + const tokenResponse = await fetch(`https://ghcr.io/token?scope=repository:${repoPath}:pull`, { + cache: 'no-store', + }); + + if (!tokenResponse.ok) { + return null; + } + + const tokenData = await tokenResponse.json(); + return tokenData.token || null; +} + +async function fetchRegistryJson( + repoPath: string, + reference: string, + accept: string, + token: string +) { + const response = await fetch(`https://ghcr.io/v2/${repoPath}/manifests/${reference}`, { + headers: { + Accept: accept, + Authorization: `Bearer ${token}`, + }, + cache: 'no-store', + }); + + if (!response.ok) { + throw new Error(`Registry request failed with ${response.status}`); + } + + return { + digest: response.headers.get('docker-content-digest'), + body: await response.json(), + }; +} + +async function fetchRegistryBlob(repoPath: string, digest: string, token: string) { + const response = await fetch(`https://ghcr.io/v2/${repoPath}/blobs/${digest}`, { + headers: { + Authorization: `Bearer ${token}`, + }, + cache: 'no-store', + }); + + if (!response.ok) { + throw new Error(`Registry blob request failed with ${response.status}`); + } + + return response.json(); +} + +async function loadLatestPublishedBuild(): Promise { + const current = getCurrentBuildInfo(); + const repoPath = imageRepoPath(current.imageRepo); + const token = await fetchGhcrToken(repoPath); + + if (!token) { + return null; + } + + const manifestAccept = [ + 'application/vnd.oci.image.index.v1+json', + 'application/vnd.docker.distribution.manifest.list.v2+json', + 'application/vnd.oci.image.manifest.v1+json', + 'application/vnd.docker.distribution.manifest.v2+json', + ].join(', '); + + const latestManifest = await fetchRegistryJson(repoPath, 'latest', manifestAccept, token); + const latestBody = latestManifest.body; + + let configDigest: string | undefined; + + if (Array.isArray(latestBody.manifests)) { + const platformManifest = + latestBody.manifests.find( + (manifest: any) => + manifest.platform?.os === 'linux' && manifest.platform?.architecture === 'amd64' + ) || + latestBody.manifests.find((manifest: any) => manifest.platform?.os && manifest.platform?.architecture); + + if (!platformManifest?.digest) { + return null; + } + + const platformManifestResponse = await fetchRegistryJson(repoPath, platformManifest.digest, manifestAccept, token); + configDigest = platformManifestResponse.body?.config?.digest; + } else { + configDigest = latestBody?.config?.digest; + } + + if (!configDigest) { + return null; + } + + const configBlob = await fetchRegistryBlob(repoPath, configDigest, token); + const labels = configBlob?.config?.Labels || {}; + const sourceRepo = labels['org.opencontainers.image.source'] || current.sourceRepo; + const commit = labels['org.opencontainers.image.revision'] || null; + const version = labels['org.opencontainers.image.version'] || null; + + if (!version) { + return null; + } + + return { + tag: 'latest', + version, + commit, + buildDate: labels['org.opencontainers.image.created'] || null, + githubUrl: buildGithubUrl(sourceRepo, commit), + imageDigest: latestManifest.digest || null, + imageRepo: current.imageRepo, + sourceRepo, + }; +} + +export async function getLatestPublishedBuild(): Promise { + if (latestVersionCache && latestVersionCache.expiresAt > Date.now()) { + return latestVersionCache.value; + } + + try { + const latest = await loadLatestPublishedBuild(); + latestVersionCache = { + expiresAt: Date.now() + VERSION_CACHE_TTL_MS, + value: latest, + }; + return latest; + } catch (error) { + console.error('[Version] Failed to fetch latest published build:', error); + latestVersionCache = { + expiresAt: Date.now() + VERSION_CACHE_TTL_MS, + value: null, + }; + return null; + } +}