Fix federation flows and add versioned updater
This commit is contained in:
@@ -45,10 +45,13 @@ services:
|
|||||||
ALLOW_LOCALHOST: ${ALLOW_LOCALHOST:-}
|
ALLOW_LOCALHOST: ${ALLOW_LOCALHOST:-}
|
||||||
ADMIN_EMAILS: ${ADMIN_EMAILS}
|
ADMIN_EMAILS: ${ADMIN_EMAILS}
|
||||||
BOT_MAX_PER_USER: ${BOT_MAX_PER_USER:-5}
|
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}
|
PORT: ${PORT:-3000}
|
||||||
NODE_ENV: production
|
NODE_ENV: production
|
||||||
volumes:
|
volumes:
|
||||||
- uploads_data:/app/uploads
|
- uploads_data:/app/uploads
|
||||||
|
- /var/run/synapsis-updater:/var/run/synapsis-updater
|
||||||
networks:
|
networks:
|
||||||
- synapsis-network
|
- synapsis-network
|
||||||
depends_on:
|
depends_on:
|
||||||
|
|||||||
@@ -66,6 +66,8 @@ services:
|
|||||||
|
|
||||||
# Optional settings
|
# Optional settings
|
||||||
BOT_MAX_PER_USER: ${BOT_MAX_PER_USER:-5}
|
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 configuration (auto or specific port)
|
||||||
PORT: ${PORT:-3000}
|
PORT: ${PORT:-3000}
|
||||||
@@ -77,6 +79,7 @@ services:
|
|||||||
- uploads_data:/app/uploads
|
- uploads_data:/app/uploads
|
||||||
# Shared volume for port coordination with Caddy
|
# Shared volume for port coordination with Caddy
|
||||||
- port_data:/var/run/synapsis
|
- port_data:/var/run/synapsis
|
||||||
|
- /var/run/synapsis-updater:/var/run/synapsis-updater
|
||||||
networks:
|
networks:
|
||||||
- synapsis-network
|
- synapsis-network
|
||||||
depends_on:
|
depends_on:
|
||||||
|
|||||||
@@ -39,6 +39,11 @@ DB_NAME=synapsis
|
|||||||
# Existing reverse proxy mode (PROXY=none): use a fixed localhost port, e.g. PORT=3000.
|
# Existing reverse proxy mode (PROXY=none): use a fixed localhost port, e.g. PORT=3000.
|
||||||
PORT=auto
|
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 domains in production (set only for local testing)
|
||||||
# ALLOW_LOCALHOST=1
|
# ALLOW_LOCALHOST=1
|
||||||
|
|
||||||
|
|||||||
+30
-1
@@ -33,8 +33,21 @@ COPY --from=deps /app/node_modules ./node_modules
|
|||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
# Set environment variables for build
|
# 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 NODE_ENV=production
|
||||||
ENV NEXT_TELEMETRY_DISABLED=1
|
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
|
# Build the Next.js application
|
||||||
RUN npm run build
|
RUN npm run build
|
||||||
@@ -47,8 +60,18 @@ RUN apk add --no-cache libc6-compat openssl netcat-openbsd wget
|
|||||||
|
|
||||||
WORKDIR /app
|
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.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
|
# Create non-root user for security
|
||||||
RUN addgroup --system --gid 1001 nodejs && \
|
RUN addgroup --system --gid 1001 nodejs && \
|
||||||
@@ -62,6 +85,12 @@ ENV NODE_ENV=production
|
|||||||
ENV NEXT_TELEMETRY_DISABLED=1
|
ENV NEXT_TELEMETRY_DISABLED=1
|
||||||
ENV PORT=3000
|
ENV PORT=3000
|
||||||
ENV HOSTNAME="0.0.0.0"
|
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 public assets
|
||||||
COPY --from=builder /app/public ./public
|
COPY --from=builder /app/public ./public
|
||||||
|
|||||||
Executable
+197
@@ -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()
|
||||||
@@ -7,6 +7,7 @@ REF="${REF:-main}"
|
|||||||
INSTALL_DIR="${1:-${INSTALL_DIR:-/opt/synapsis}}"
|
INSTALL_DIR="${1:-${INSTALL_DIR:-/opt/synapsis}}"
|
||||||
PUBLIC_INSTALL_URL="${PUBLIC_INSTALL_URL:-https://synapsis.social/install.sh}"
|
PUBLIC_INSTALL_URL="${PUBLIC_INSTALL_URL:-https://synapsis.social/install.sh}"
|
||||||
PROXY="${PROXY:-caddy}"
|
PROXY="${PROXY:-caddy}"
|
||||||
|
HOST_UPDATER_SOCKET_PATH="${HOST_UPDATER_SOCKET_PATH:-/var/run/synapsis-updater/updater.sock}"
|
||||||
|
|
||||||
require_command() {
|
require_command() {
|
||||||
if ! command -v "$1" >/dev/null 2>&1; then
|
if ! command -v "$1" >/dev/null 2>&1; then
|
||||||
@@ -79,6 +80,19 @@ set_env_value() {
|
|||||||
rm -f "${file}.bak"
|
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() {
|
get_env_value() {
|
||||||
file="$1"
|
file="$1"
|
||||||
key="$2"
|
key="$2"
|
||||||
@@ -94,6 +108,15 @@ generate_db_password() {
|
|||||||
openssl rand -base64 24 | tr -d '\n' | tr '/+' '_-' | cut -c1-32
|
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() {
|
find_available_port() {
|
||||||
start="${1:-3000}"
|
start="${1:-3000}"
|
||||||
end="${2:-3020}"
|
end="${2:-3020}"
|
||||||
@@ -153,6 +176,103 @@ install_docker_if_needed() {
|
|||||||
fi
|
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" <<EOF
|
||||||
|
HOST_UPDATER_TOKEN=${updater_token}
|
||||||
|
REPO=${REPO}
|
||||||
|
REF=${REF}
|
||||||
|
EOF
|
||||||
|
chmod 600 "${INSTALL_DIR}/updater.env"
|
||||||
|
|
||||||
|
cat > /etc/systemd/system/synapsis-updater.service <<EOF
|
||||||
|
[Unit]
|
||||||
|
Description=Synapsis Host Updater
|
||||||
|
After=network-online.target docker.service
|
||||||
|
Wants=network-online.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
User=root
|
||||||
|
Group=root
|
||||||
|
WorkingDirectory=${INSTALL_DIR}
|
||||||
|
Environment=INSTALL_DIR=${INSTALL_DIR}
|
||||||
|
Environment=HOST_UPDATER_SOCKET=${HOST_UPDATER_SOCKET_PATH}
|
||||||
|
Environment=HOST_UPDATER_STATUS_FILE=${INSTALL_DIR}/updater-status.json
|
||||||
|
Environment=HOST_UPDATER_LOG_FILE=${INSTALL_DIR}/updater.log
|
||||||
|
Environment=HOST_UPDATER_SCRIPT=${INSTALL_DIR}/update-local.sh
|
||||||
|
EnvironmentFile=${INSTALL_DIR}/updater.env
|
||||||
|
ExecStart=/usr/bin/env python3 ${INSTALL_DIR}/host-updater.py
|
||||||
|
Restart=always
|
||||||
|
RestartSec=3
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
|
EOF
|
||||||
|
|
||||||
|
systemctl daemon-reload
|
||||||
|
systemctl enable --now synapsis-updater.service >/dev/null 2>&1 || true
|
||||||
|
systemctl restart synapsis-updater.service >/dev/null 2>&1 || true
|
||||||
|
}
|
||||||
|
|
||||||
require_command curl
|
require_command curl
|
||||||
require_command chmod
|
require_command chmod
|
||||||
require_command mkdir
|
require_command mkdir
|
||||||
@@ -228,6 +348,8 @@ else
|
|||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
install_host_updater
|
||||||
|
|
||||||
echo ""
|
echo ""
|
||||||
echo "Next steps:"
|
echo "Next steps:"
|
||||||
echo " 1. Review ${INSTALL_DIR}/.env"
|
echo " 1. Review ${INSTALL_DIR}/.env"
|
||||||
|
|||||||
@@ -81,6 +81,14 @@ cleanup_named_resources() {
|
|||||||
echo "🗑️ Removing network synapsis_synapsis-network"
|
echo "🗑️ Removing network synapsis_synapsis-network"
|
||||||
docker network rm synapsis_synapsis-network >/dev/null 2>&1 || true
|
docker network rm synapsis_synapsis-network >/dev/null 2>&1 || true
|
||||||
fi
|
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() {
|
cleanup_images() {
|
||||||
|
|||||||
Executable
+73
@@ -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."
|
||||||
@@ -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" <<EOF
|
||||||
|
HOST_UPDATER_TOKEN=${updater_token}
|
||||||
|
REPO=${REPO}
|
||||||
|
REF=${REF}
|
||||||
|
EOF
|
||||||
|
chmod 600 "${INSTALL_DIR}/updater.env"
|
||||||
|
|
||||||
|
cat > /etc/systemd/system/synapsis-updater.service <<EOF
|
||||||
|
[Unit]
|
||||||
|
Description=Synapsis Host Updater
|
||||||
|
After=network-online.target docker.service
|
||||||
|
Wants=network-online.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
User=root
|
||||||
|
Group=root
|
||||||
|
WorkingDirectory=${INSTALL_DIR}
|
||||||
|
Environment=INSTALL_DIR=${INSTALL_DIR}
|
||||||
|
Environment=HOST_UPDATER_SOCKET=${HOST_UPDATER_SOCKET_PATH}
|
||||||
|
Environment=HOST_UPDATER_STATUS_FILE=${INSTALL_DIR}/updater-status.json
|
||||||
|
Environment=HOST_UPDATER_LOG_FILE=${INSTALL_DIR}/updater.log
|
||||||
|
Environment=HOST_UPDATER_SCRIPT=${INSTALL_DIR}/update-local.sh
|
||||||
|
EnvironmentFile=${INSTALL_DIR}/updater.env
|
||||||
|
ExecStart=/usr/bin/env python3 ${INSTALL_DIR}/host-updater.py
|
||||||
|
Restart=always
|
||||||
|
RestartSec=3
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
|
EOF
|
||||||
|
|
||||||
|
systemctl daemon-reload
|
||||||
|
systemctl enable --now 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."
|
||||||
Executable
+81
@@ -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}"
|
||||||
+169
-26
@@ -38,6 +38,22 @@ export default function AdminPage() {
|
|||||||
const [logoUploadError, setLogoUploadError] = useState<string | null>(null);
|
const [logoUploadError, setLogoUploadError] = useState<string | null>(null);
|
||||||
const [isUploadingFavicon, setIsUploadingFavicon] = useState(false);
|
const [isUploadingFavicon, setIsUploadingFavicon] = useState(false);
|
||||||
const [faviconUploadError, setFaviconUploadError] = useState<string | null>(null);
|
const [faviconUploadError, setFaviconUploadError] = useState<string | null>(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(() => {
|
useEffect(() => {
|
||||||
fetch('/api/admin/me')
|
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(() => {
|
useEffect(() => {
|
||||||
if (isAdmin) {
|
if (isAdmin) {
|
||||||
loadNodeSettings();
|
loadNodeSettings();
|
||||||
|
loadUpdateStatus();
|
||||||
}
|
}
|
||||||
}, [isAdmin]);
|
}, [isAdmin]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isAdmin) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const interval = window.setInterval(() => {
|
||||||
|
loadUpdateStatus();
|
||||||
|
}, 30000);
|
||||||
|
|
||||||
|
return () => window.clearInterval(interval);
|
||||||
|
}, [isAdmin]);
|
||||||
|
|
||||||
const handleSaveSettings = async (override?: typeof nodeSettings) => {
|
const handleSaveSettings = async (override?: typeof nodeSettings) => {
|
||||||
const payload = override ?? nodeSettings;
|
const payload = override ?? nodeSettings;
|
||||||
setSavingSettings(true);
|
setSavingSettings(true);
|
||||||
@@ -182,6 +228,25 @@ export default function AdminPage() {
|
|||||||
setBannerPromptError('');
|
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<HTMLInputElement>) => {
|
const handleLogoUpload = async (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
const file = event.target.files?.[0];
|
const file = event.target.files?.[0];
|
||||||
event.target.value = '';
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<header style={{
|
<header style={{
|
||||||
@@ -438,33 +510,19 @@ export default function AdminPage() {
|
|||||||
</div>
|
</div>
|
||||||
{nodeSettings.bannerUrl && (
|
{nodeSettings.bannerUrl && (
|
||||||
<div style={{ marginTop: '12px' }}>
|
<div style={{ marginTop: '12px' }}>
|
||||||
<div style={{ fontSize: '12px', color: 'var(--foreground-tertiary)', marginBottom: '8px' }}>
|
<img
|
||||||
Sidebar preview
|
src={nodeSettings.bannerUrl}
|
||||||
</div>
|
alt="Banner preview"
|
||||||
<div
|
|
||||||
className="card"
|
|
||||||
style={{
|
style={{
|
||||||
padding: 0,
|
width: '100%',
|
||||||
overflow: 'hidden',
|
maxWidth: '520px',
|
||||||
maxWidth: '360px',
|
maxHeight: '220px',
|
||||||
|
borderRadius: '12px',
|
||||||
|
border: '1px solid var(--border)',
|
||||||
|
objectFit: 'cover',
|
||||||
|
display: 'block',
|
||||||
}}
|
}}
|
||||||
>
|
/>
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
height: '140px',
|
|
||||||
background: `url(${nodeSettings.bannerUrl}) center/cover no-repeat`,
|
|
||||||
borderBottom: '1px solid var(--border)',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<div style={{ padding: '16px' }}>
|
|
||||||
<h3 style={{ fontWeight: 600, marginBottom: '12px' }}>
|
|
||||||
Welcome to {nodeSettings.name || 'Your Node'}
|
|
||||||
</h3>
|
|
||||||
<p style={{ color: 'var(--foreground-secondary)', fontSize: '14px', lineHeight: 1.6 }}>
|
|
||||||
{nodeSettings.description || 'A brief tagline for your node.'}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -604,7 +662,92 @@ export default function AdminPage() {
|
|||||||
<button className="btn btn-primary" onClick={() => handleSaveSettings()} disabled={savingSettings}>
|
<button className="btn btn-primary" onClick={() => handleSaveSettings()} disabled={savingSettings}>
|
||||||
{savingSettings ? 'Saving...' : 'Save Settings'}
|
{savingSettings ? 'Saving...' : 'Save Settings'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div style={{
|
||||||
|
padding: '16px',
|
||||||
|
background: 'var(--background-secondary)',
|
||||||
|
borderRadius: '8px',
|
||||||
|
border: '1px solid var(--border)',
|
||||||
|
}}>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', gap: '16px', flexWrap: 'wrap' }}>
|
||||||
|
<div>
|
||||||
|
<h2 style={{ fontSize: '16px', fontWeight: 600, marginBottom: '6px' }}>System Update</h2>
|
||||||
|
<p style={{ fontSize: '13px', color: 'var(--foreground-secondary)', margin: 0 }}>
|
||||||
|
Keep this node on the latest published Synapsis build.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
className="btn btn-primary"
|
||||||
|
onClick={handleTriggerUpdate}
|
||||||
|
disabled={
|
||||||
|
triggeringUpdate ||
|
||||||
|
loadingUpdateStatus ||
|
||||||
|
updateStatus?.updater.available === false ||
|
||||||
|
updateStatus?.updater.status === 'updating' ||
|
||||||
|
!updateStatus?.updateAvailable
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{triggeringUpdate || updateStatus?.updater.status === 'updating' ? 'Updating...' : 'Update Now'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ display: 'grid', gap: '8px', marginTop: '16px', fontSize: '13px' }}>
|
||||||
|
<div>
|
||||||
|
<strong>Current build:</strong>{' '}
|
||||||
|
{updateStatus?.current?.version || 'Unknown'}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<strong>Latest build:</strong>{' '}
|
||||||
|
{updateStatus?.latest?.version || 'Unavailable'}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<strong>Status:</strong>{' '}
|
||||||
|
{loadingUpdateStatus ? 'Checking…' : updateStatus?.updater.message || 'Ready'}
|
||||||
|
</div>
|
||||||
|
{updateStatus?.updater.lastStartedAt && (
|
||||||
|
<div>
|
||||||
|
<strong>Last started:</strong>{' '}
|
||||||
|
{formatTimestamp(updateStatus.updater.lastStartedAt)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{updateStatus?.updater.lastFinishedAt && (
|
||||||
|
<div>
|
||||||
|
<strong>Last finished:</strong>{' '}
|
||||||
|
{formatTimestamp(updateStatus.updater.lastFinishedAt)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{typeof updateStatus?.updater.lastExitCode === 'number' && (
|
||||||
|
<div>
|
||||||
|
<strong>Last exit code:</strong>{' '}
|
||||||
|
{updateStatus.updater.lastExitCode}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{updateStatus?.updater.lastError && (
|
||||||
|
<div style={{ color: 'var(--danger)' }}>
|
||||||
|
<strong>Last error:</strong>{' '}
|
||||||
|
{updateStatus.updater.lastError}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!updateStatus?.updater.available && (
|
||||||
|
<div style={{
|
||||||
|
marginTop: '16px',
|
||||||
|
padding: '12px',
|
||||||
|
borderRadius: '8px',
|
||||||
|
border: '1px solid var(--border)',
|
||||||
|
background: 'var(--background)',
|
||||||
|
fontSize: '12px',
|
||||||
|
color: 'var(--foreground-secondary)',
|
||||||
|
}}>
|
||||||
|
One-click updates are unavailable on this host. Use:
|
||||||
|
<div style={{ marginTop: '8px', fontFamily: 'monospace', color: 'var(--foreground)' }}>
|
||||||
|
curl -fsSL https://synapsis.social/update.sh | bash
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,26 +7,56 @@ import { verifySwarmRequest } from '@/lib/swarm/signature';
|
|||||||
import { fetchAndCacheRemoteKey, logKeyChange } from '@/lib/swarm/identity-cache';
|
import { fetchAndCacheRemoteKey, logKeyChange } from '@/lib/swarm/identity-cache';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
// Schema for direct signed action (legacy)
|
const signedChatActionSchema = z.object({
|
||||||
const chatReceiveSchema = 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'),
|
did: z.string().regex(/^did:/, 'Must be a valid DID'),
|
||||||
handle: z.string().min(3).max(30),
|
handle: z.string().min(3).max(30),
|
||||||
data: z.object({
|
data: z.object({
|
||||||
recipientDid: z.string().regex(/^did:/, 'Must be a valid DID'),
|
recipientDid: z.string().regex(/^did:/, 'Must be a valid DID'),
|
||||||
content: z.string().min(1).max(5000),
|
content: z.string().min(1).max(5000),
|
||||||
}),
|
}),
|
||||||
signature: z.string(),
|
signature: z.string().min(1),
|
||||||
timestamp: z.number().optional(),
|
timestamp: z.number(),
|
||||||
});
|
});
|
||||||
|
|
||||||
// Schema for federated envelope
|
const incomingChatActionSchema = z.union([signedChatActionSchema, legacyChatActionSchema]);
|
||||||
|
|
||||||
const federatedEnvelopeSchema = z.object({
|
const federatedEnvelopeSchema = z.object({
|
||||||
userAction: chatReceiveSchema,
|
userAction: incomingChatActionSchema,
|
||||||
fullSenderHandle: z.string().min(3).max(60),
|
fullSenderHandle: z.string().min(3).max(60),
|
||||||
sourceDomain: z.string().min(1),
|
sourceDomain: z.string().min(1),
|
||||||
ts: z.number(),
|
ts: z.number(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function normalizeSignedAction(action: z.infer<typeof incomingChatActionSchema>): 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
|
* POST /api/chat/receive
|
||||||
* Endpoint for receiving federated chat messages from other nodes.
|
* 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
|
// Extract user's signed action and full handle from envelope
|
||||||
signedAction = body.userAction;
|
signedAction = normalizeSignedAction(body.userAction);
|
||||||
fullSenderHandle = body.fullSenderHandle;
|
fullSenderHandle = body.fullSenderHandle;
|
||||||
console.log(`[Chat Receive] Federated envelope from node: ${sourceDomain}, full handle: ${fullSenderHandle}`);
|
console.log(`[Chat Receive] Federated envelope from node: ${sourceDomain}, full handle: ${fullSenderHandle}`);
|
||||||
} else {
|
} else {
|
||||||
// Legacy format - direct user signed action
|
// Legacy format - direct user signed action
|
||||||
const actionValidation = chatReceiveSchema.safeParse(body);
|
const actionValidation = incomingChatActionSchema.safeParse(body);
|
||||||
if (!actionValidation.success) {
|
if (!actionValidation.success) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: 'Invalid action payload', details: actionValidation.error.issues },
|
{ error: 'Invalid action payload', details: actionValidation.error.issues },
|
||||||
{ status: 400 }
|
{ status: 400 }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
signedAction = body;
|
signedAction = normalizeSignedAction(actionValidation.data);
|
||||||
}
|
}
|
||||||
|
|
||||||
const { did, handle, data } = signedAction;
|
const { did, handle, data } = signedAction;
|
||||||
|
|||||||
@@ -79,12 +79,9 @@ export async function POST(request: Request, context: RouteContext) {
|
|||||||
actorDisplayName: user.displayName || user.handle,
|
actorDisplayName: user.displayName || user.handle,
|
||||||
actorAvatarUrl: user.avatarUrl || undefined,
|
actorAvatarUrl: user.avatarUrl || undefined,
|
||||||
actorNodeDomain: nodeDomain,
|
actorNodeDomain: nodeDomain,
|
||||||
actorDid: user.did,
|
|
||||||
actorPublicKey: user.publicKey,
|
|
||||||
interactionId: crypto.randomUUID(),
|
interactionId: crypto.randomUUID(),
|
||||||
timestamp: new Date().toISOString(),
|
timestamp: new Date().toISOString(),
|
||||||
},
|
},
|
||||||
userSignature: signedAction.sig,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!result.success) {
|
if (!result.success) {
|
||||||
@@ -181,12 +178,9 @@ export async function POST(request: Request, context: RouteContext) {
|
|||||||
actorDisplayName: user.displayName || user.handle,
|
actorDisplayName: user.displayName || user.handle,
|
||||||
actorAvatarUrl: user.avatarUrl || undefined,
|
actorAvatarUrl: user.avatarUrl || undefined,
|
||||||
actorNodeDomain: nodeDomain,
|
actorNodeDomain: nodeDomain,
|
||||||
actorDid: user.did,
|
|
||||||
actorPublicKey: user.publicKey,
|
|
||||||
interactionId: crypto.randomUUID(),
|
interactionId: crypto.randomUUID(),
|
||||||
timestamp: new Date().toISOString(),
|
timestamp: new Date().toISOString(),
|
||||||
},
|
},
|
||||||
userSignature: signedAction.sig,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
|
|||||||
@@ -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
|
// Local posts may have apId if they've been federated, so we check nodeId instead
|
||||||
let whereCondition = and(
|
let whereCondition = and(
|
||||||
isNull(posts.replyToId), // Not a reply
|
isNull(posts.replyToId), // Not a reply
|
||||||
|
isNull(posts.swarmReplyToId), // Not a swarm reply
|
||||||
eq(posts.isRemoved, false), // Not removed
|
eq(posts.isRemoved, false), // Not removed
|
||||||
isNull(users.nodeId) // Local user (not from another swarm node)
|
isNull(users.nodeId) // Local user (not from another swarm node)
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { NextResponse } from 'next/server';
|
import { NextResponse } from 'next/server';
|
||||||
import { db, posts, users, likes } from '@/db';
|
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 { fetchSwarmUserProfile, isSwarmNode } from '@/lib/swarm/interactions';
|
||||||
import { discoverNode } from '@/lib/swarm/discovery';
|
import { discoverNode } from '@/lib/swarm/discovery';
|
||||||
|
|
||||||
@@ -51,8 +51,8 @@ export async function GET(request: Request, context: RouteContext) {
|
|||||||
displayName: profile.displayName || profile.handle,
|
displayName: profile.displayName || profile.handle,
|
||||||
avatarUrl: profile.avatarUrl,
|
avatarUrl: profile.avatarUrl,
|
||||||
};
|
};
|
||||||
|
|
||||||
const posts = profileData.posts.map((post: any) => ({
|
const remotePosts = profileData.posts.map((post: any) => ({
|
||||||
id: post.id,
|
id: post.id,
|
||||||
content: post.content,
|
content: post.content,
|
||||||
createdAt: post.createdAt,
|
createdAt: post.createdAt,
|
||||||
@@ -70,7 +70,7 @@ export async function GET(request: Request, context: RouteContext) {
|
|||||||
originalPostId: post.id,
|
originalPostId: post.id,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
return NextResponse.json({ posts, nextCursor: null });
|
return NextResponse.json({ posts: remotePosts, nextCursor: null });
|
||||||
}
|
}
|
||||||
|
|
||||||
return NextResponse.json({ posts: [] });
|
return NextResponse.json({ posts: [] });
|
||||||
@@ -108,13 +108,35 @@ export async function GET(request: Request, context: RouteContext) {
|
|||||||
avatarUrl: profile.avatarUrl,
|
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<string, number>();
|
||||||
|
|
||||||
|
if (swarmPostIds.length > 0) {
|
||||||
|
const counts = await db.select({
|
||||||
|
swarmReplyToId: posts.swarmReplyToId,
|
||||||
|
replyCount: sql<number>`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,
|
id: post.id,
|
||||||
content: post.content,
|
content: post.content,
|
||||||
createdAt: post.createdAt,
|
createdAt: post.createdAt,
|
||||||
likesCount: post.likesCount || 0,
|
likesCount: post.likesCount || 0,
|
||||||
repostsCount: post.repostsCount || 0,
|
repostsCount: post.repostsCount || 0,
|
||||||
repliesCount: post.repliesCount || 0,
|
repliesCount: (post.repliesCount || 0) + (localReplyCounts.get(`swarm:${remote.domain}:${post.id}`) || 0),
|
||||||
author,
|
author,
|
||||||
media: post.media || [],
|
media: post.media || [],
|
||||||
linkPreviewUrl: post.linkPreviewUrl || null,
|
linkPreviewUrl: post.linkPreviewUrl || null,
|
||||||
@@ -126,7 +148,7 @@ export async function GET(request: Request, context: RouteContext) {
|
|||||||
originalPostId: post.id,
|
originalPostId: post.id,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
return NextResponse.json({ posts, nextCursor: null });
|
return NextResponse.json({ posts: remotePosts, nextCursor: null });
|
||||||
}
|
}
|
||||||
|
|
||||||
return NextResponse.json({ posts: [] });
|
return NextResponse.json({ posts: [] });
|
||||||
|
|||||||
@@ -1,59 +1,6 @@
|
|||||||
import { NextResponse } from 'next/server';
|
import { NextResponse } from 'next/server';
|
||||||
import { execSync } from 'child_process';
|
import { getCurrentBuildInfo } from '@/lib/version';
|
||||||
|
|
||||||
export async function GET() {
|
export async function GET() {
|
||||||
try {
|
return NextResponse.json(getCurrentBuildInfo());
|
||||||
// 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
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { Post } from '@/lib/types';
|
|||||||
import { useFormattedHandle } from '@/lib/utils/handle';
|
import { useFormattedHandle } from '@/lib/utils/handle';
|
||||||
import { Bot, Network, Server, EyeOff } from 'lucide-react';
|
import { Bot, Network, Server, EyeOff } from 'lucide-react';
|
||||||
import { useAuth } from '@/lib/contexts/AuthContext';
|
import { useAuth } from '@/lib/contexts/AuthContext';
|
||||||
|
import { signedAPI } from '@/lib/api/signed-fetch';
|
||||||
|
|
||||||
interface User {
|
interface User {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -81,7 +82,7 @@ interface SwarmPost {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function ExplorePage() {
|
export default function ExplorePage() {
|
||||||
const { user } = useAuth();
|
const { user, did, handle } = useAuth();
|
||||||
const [query, setQuery] = useState('');
|
const [query, setQuery] = useState('');
|
||||||
const [activeTab, setActiveTab] = useState<'node' | 'swarm' | 'users' | 'search'>('node');
|
const [activeTab, setActiveTab] = useState<'node' | 'swarm' | 'users' | 'search'>('node');
|
||||||
const [nodePosts, setNodePosts] = useState<Post[]>([]);
|
const [nodePosts, setNodePosts] = useState<Post[]>([]);
|
||||||
@@ -268,8 +269,13 @@ export default function ExplorePage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleLike = async (postId: string, currentLiked: boolean) => {
|
const handleLike = async (postId: string, currentLiked: boolean) => {
|
||||||
const method = currentLiked ? 'DELETE' : 'POST';
|
if (!did || !handle) {
|
||||||
const res = await fetch(`/api/posts/${postId}/like`, { method });
|
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) {
|
if (!res.ok) {
|
||||||
const data = await res.json().catch(() => ({}));
|
const data = await res.json().catch(() => ({}));
|
||||||
@@ -278,8 +284,13 @@ export default function ExplorePage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleRepost = async (postId: string, currentReposted: boolean) => {
|
const handleRepost = async (postId: string, currentReposted: boolean) => {
|
||||||
const method = currentReposted ? 'DELETE' : 'POST';
|
if (!did || !handle) {
|
||||||
const res = await fetch(`/api/posts/${postId}/repost`, { method });
|
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) {
|
if (!res.ok) {
|
||||||
const data = await res.json().catch(() => ({}));
|
const data = await res.json().catch(() => ({}));
|
||||||
|
|||||||
+17
-4
@@ -7,6 +7,8 @@ import { useFormattedHandle } from '@/lib/utils/handle';
|
|||||||
import { PostCard } from '@/components/PostCard';
|
import { PostCard } from '@/components/PostCard';
|
||||||
import { Post } from '@/lib/types';
|
import { Post } from '@/lib/types';
|
||||||
import { Bot } from 'lucide-react';
|
import { Bot } from 'lucide-react';
|
||||||
|
import { useAuth } from '@/lib/contexts/AuthContext';
|
||||||
|
import { signedAPI } from '@/lib/api/signed-fetch';
|
||||||
|
|
||||||
interface User {
|
interface User {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -132,6 +134,7 @@ export default function SearchPage() {
|
|||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
const initialQuery = searchParams.get('q') || '';
|
const initialQuery = searchParams.get('q') || '';
|
||||||
|
const { did, handle } = useAuth();
|
||||||
|
|
||||||
const [query, setQuery] = useState(initialQuery);
|
const [query, setQuery] = useState(initialQuery);
|
||||||
const [users, setUsers] = useState<User[]>([]);
|
const [users, setUsers] = useState<User[]>([]);
|
||||||
@@ -181,8 +184,13 @@ export default function SearchPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleLike = async (postId: string, currentLiked: boolean) => {
|
const handleLike = async (postId: string, currentLiked: boolean) => {
|
||||||
const method = currentLiked ? 'DELETE' : 'POST';
|
if (!did || !handle) {
|
||||||
const res = await fetch(`/api/posts/${postId}/like`, { method });
|
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) {
|
if (!res.ok) {
|
||||||
const data = await res.json().catch(() => ({}));
|
const data = await res.json().catch(() => ({}));
|
||||||
@@ -191,8 +199,13 @@ export default function SearchPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleRepost = async (postId: string, currentReposted: boolean) => {
|
const handleRepost = async (postId: string, currentReposted: boolean) => {
|
||||||
const method = currentReposted ? 'DELETE' : 'POST';
|
if (!did || !handle) {
|
||||||
const res = await fetch(`/api/posts/${postId}/repost`, { method });
|
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) {
|
if (!res.ok) {
|
||||||
const data = await res.json().catch(() => ({}));
|
const data = await res.json().catch(() => ({}));
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import { Rocket, MoreHorizontal, Mail } from 'lucide-react';
|
|||||||
import { useFormattedHandle } from '@/lib/utils/handle';
|
import { useFormattedHandle } from '@/lib/utils/handle';
|
||||||
import { Bot } from 'lucide-react';
|
import { Bot } from 'lucide-react';
|
||||||
import { useAuth } from '@/lib/contexts/AuthContext';
|
import { useAuth } from '@/lib/contexts/AuthContext';
|
||||||
|
import { signedAPI } from '@/lib/api/signed-fetch';
|
||||||
|
|
||||||
interface BotOwner {
|
interface BotOwner {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -81,7 +82,7 @@ export default function ProfilePage() {
|
|||||||
const params = useParams();
|
const params = useParams();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const handle = (params.handle as string)?.replace(/^@/, '') || '';
|
const handle = (params.handle as string)?.replace(/^@/, '') || '';
|
||||||
const { isIdentityUnlocked, signUserAction } = useAuth();
|
const { did, handle: currentHandle, isIdentityUnlocked, signUserAction } = useAuth();
|
||||||
|
|
||||||
const [user, setUser] = useState<User | null>(null);
|
const [user, setUser] = useState<User | null>(null);
|
||||||
const userFullHandle = user ? useFormattedHandle(user.handle) : '';
|
const userFullHandle = user ? useFormattedHandle(user.handle) : '';
|
||||||
@@ -207,8 +208,13 @@ export default function ProfilePage() {
|
|||||||
}, [activeTab, postsCursor, repliesCursor, postsLoadingMore, repliesLoadingMore, loadMorePosts, loadMoreReplies]);
|
}, [activeTab, postsCursor, repliesCursor, postsLoadingMore, repliesLoadingMore, loadMorePosts, loadMoreReplies]);
|
||||||
|
|
||||||
const handleLike = async (postId: string, currentLiked: boolean) => {
|
const handleLike = async (postId: string, currentLiked: boolean) => {
|
||||||
const method = currentLiked ? 'DELETE' : 'POST';
|
if (!did || !currentHandle) {
|
||||||
const res = await fetch(`/api/posts/${postId}/like`, { method });
|
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) {
|
if (!res.ok) {
|
||||||
const data = await res.json().catch(() => ({}));
|
const data = await res.json().catch(() => ({}));
|
||||||
@@ -217,8 +223,13 @@ export default function ProfilePage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleRepost = async (postId: string, currentReposted: boolean) => {
|
const handleRepost = async (postId: string, currentReposted: boolean) => {
|
||||||
const method = currentReposted ? 'DELETE' : 'POST';
|
if (!did || !currentHandle) {
|
||||||
const res = await fetch(`/api/posts/${postId}/repost`, { method });
|
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) {
|
if (!res.ok) {
|
||||||
const data = await res.json().catch(() => ({}));
|
const data = await res.json().catch(() => ({}));
|
||||||
@@ -322,8 +333,14 @@ export default function ProfilePage() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const method = isFollowing ? 'DELETE' : 'POST';
|
if (!did || !currentHandle) {
|
||||||
const res = await fetch(`/api/users/${handle}/follow`, { method });
|
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) {
|
if (res.ok && user) {
|
||||||
setIsFollowing(!isFollowing);
|
setIsFollowing(!isFollowing);
|
||||||
|
|||||||
@@ -19,7 +19,12 @@ export function RightSidebar() {
|
|||||||
bannerUrl: '',
|
bannerUrl: '',
|
||||||
admins: [] as Admin[],
|
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);
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
@@ -45,7 +50,7 @@ export function RightSidebar() {
|
|||||||
fetch('/api/version')
|
fetch('/api/version')
|
||||||
.then(res => res.json())
|
.then(res => res.json())
|
||||||
.then(data => setVersion(data))
|
.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) {
|
if (loading) {
|
||||||
@@ -115,25 +120,21 @@ export function RightSidebar() {
|
|||||||
<div className="card" style={{ marginTop: '16px' }}>
|
<div className="card" style={{ marginTop: '16px' }}>
|
||||||
<h3 style={{ fontWeight: 600, marginBottom: '12px' }}>Network Info</h3>
|
<h3 style={{ fontWeight: 600, marginBottom: '12px' }}>Network Info</h3>
|
||||||
<p style={{ color: 'var(--foreground-secondary)', fontSize: '13px' }}>
|
<p style={{ color: 'var(--foreground-secondary)', fontSize: '13px' }}>
|
||||||
Running <a href="https://synapsis.social" target="_blank" rel="noopener noreferrer" style={{ color: 'var(--accent)' }}>Synapsis</a>
|
Running{' '}
|
||||||
{version && version.count !== null && (
|
<a href="https://synapsis.social" target="_blank" rel="noopener noreferrer" style={{ color: 'var(--accent)' }}>Synapsis</a>
|
||||||
|
{version?.version ? ` ${version.version}` : ''}
|
||||||
|
{version?.githubUrl && version?.commit && (
|
||||||
<>
|
<>
|
||||||
{' • '}
|
{' • '}
|
||||||
{version.githubUrl ? (
|
<a
|
||||||
<a
|
href={version.githubUrl}
|
||||||
href={version.githubUrl}
|
target="_blank"
|
||||||
target="_blank"
|
rel="noopener noreferrer"
|
||||||
rel="noopener noreferrer"
|
title={version.commit}
|
||||||
title={`${version.hash} (${version.fullHash})`}
|
style={{ color: 'var(--accent)' }}
|
||||||
style={{ color: 'var(--accent)' }}
|
>
|
||||||
>
|
{version.commit.slice(0, 7)}
|
||||||
Commit {version.count}
|
</a>
|
||||||
</a>
|
|
||||||
) : (
|
|
||||||
<span title={`${version.hash} (${version.fullHash})`}>
|
|
||||||
Commit {version.count}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -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<T>(method: 'GET' | 'POST', path: string, body?: unknown): Promise<T> {
|
||||||
|
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<HostUpdaterStatus> {
|
||||||
|
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<HostUpdaterStatus>('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');
|
||||||
|
}
|
||||||
@@ -58,12 +58,9 @@ export interface SwarmLikePayload {
|
|||||||
actorDisplayName: string;
|
actorDisplayName: string;
|
||||||
actorAvatarUrl?: string;
|
actorAvatarUrl?: string;
|
||||||
actorNodeDomain: string;
|
actorNodeDomain: string;
|
||||||
actorDid: string; // User's DID
|
|
||||||
actorPublicKey: string; // User's public key for verification
|
|
||||||
interactionId: string;
|
interactionId: string;
|
||||||
timestamp: string;
|
timestamp: string;
|
||||||
};
|
};
|
||||||
userSignature: string; // User's cryptographic signature
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SwarmUnlikePayload {
|
export interface SwarmUnlikePayload {
|
||||||
|
|||||||
@@ -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<string | null> {
|
||||||
|
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<PublishedBuildInfo | null> {
|
||||||
|
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<PublishedBuildInfo | null> {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user