From 0fc421f23423acbd55bc5525bee0ec7cbb6339a9 Mon Sep 17 00:00:00 2001 From: Renato Date: Mon, 27 Jul 2026 23:24:57 +0200 Subject: [PATCH] feat: self-contained Docker installer with web setup wizard - settings.ts: persistent settings.json in data volume (survives restarts) - /api/setup: POST to save API_BASE_URL, API_KEY, WEB_PASSWORD, WEBHOOK_SECRET - /setup: web-based setup wizard for first-time configuration - /api/health: JSON health check endpoint for Docker healthcheck - api.ts: dynamic API_BASE_URL/API_KEY resolution from settings - login route: fallback WEB_PASSWORD from settings.json - middleware: /setup and /api/setup are public paths - Dockerfile: HEALTHCHECK instruction - docker-compose.yml: healthcheck + optional .env file - .env.example: full documentation of all env vars - install.sh: single-command VPS installer (clone, build, run) --- .env.example | 20 ++-- Dockerfile | 3 + docker-compose.yml | 9 ++ install.sh | 129 +++++++++++++++++++++++ src/app/api/auth/login/route.ts | 3 +- src/app/api/health/route.ts | 5 + src/app/api/setup/route.ts | 28 +++++ src/app/setup/page.tsx | 180 ++++++++++++++++++++++++++++++++ src/instrumentation.ts | 3 + src/lib/api.ts | 11 +- src/lib/settings.ts | 45 ++++++++ src/middleware.ts | 3 + 12 files changed, 424 insertions(+), 15 deletions(-) create mode 100755 install.sh create mode 100644 src/app/api/health/route.ts create mode 100644 src/app/api/setup/route.ts create mode 100644 src/app/setup/page.tsx create mode 100644 src/lib/settings.ts diff --git a/.env.example b/.env.example index 1165bd6..4125a0f 100644 --- a/.env.example +++ b/.env.example @@ -1,16 +1,18 @@ -# API del pipeline (VPS1) -API_BASE_URL=http://127.0.0.1:8080/api/v1 +# --- worst-scan-web --- +# Copy to .env and adjust. + +# Backend API +API_BASE_URL=http://host.docker.internal:8080/api/v1 API_KEY= -# Auth de la web (vacío = sin auth en dev) +# Web auth WEB_PASSWORD= -# Webhook Secret para sincronización automática desde el pipeline +# Webhook validation WEBHOOK_SECRET= -# Puerto -PORT=3000 +# DB path +DB_PATH=/app/data/worst-scan.db -# Persistencia (SQLite + covers en data/) -DB_PATH=data/worst-scan.db -COVERS_DIR=data/covers +# Covers dir +COVERS_DIR=/app/data/covers diff --git a/Dockerfile b/Dockerfile index 05dba1f..e0bdc14 100644 --- a/Dockerfile +++ b/Dockerfile @@ -31,4 +31,7 @@ ENV COVERS_DIR=/app/data/covers VOLUME ["/app/data"] +HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \ + CMD wget --no-verbose --tries=1 --spider http://127.0.0.1:3000/api/health || exit 1 + CMD ["node", "server.js"] diff --git a/docker-compose.yml b/docker-compose.yml index 677b2d2..1cba799 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -4,6 +4,9 @@ services: container_name: worst-scan-web expose: - "3000" + env_file: + - path: .env + required: false environment: - API_BASE_URL=${API_BASE_URL:-http://host.docker.internal:8080/api/v1} - API_KEY=${API_KEY:-} @@ -17,6 +20,12 @@ services: - caddy - default restart: unless-stopped + healthcheck: + test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://127.0.0.1:3000/api/health"] + interval: 30s + timeout: 5s + start_period: 15s + retries: 3 networks: caddy: diff --git a/install.sh b/install.sh new file mode 100755 index 0000000..a372a96 --- /dev/null +++ b/install.sh @@ -0,0 +1,129 @@ +#!/usr/bin/env bash +set -euo pipefail + +# ────────────────────────────────────────────── +# worst-scan-web — Docker auto-installer +# Usage: bash <(curl -fsSL https://gitea.cbcren.online/renato97/worst-scan-web/raw/branch/main/install.sh) +# Or: curl -fsSL https://gitea.cbcren.online/renato97/worst-scan-web/raw/branch/main/install.sh -o install.sh && bash install.sh +# ────────────────────────────────────────────── + +REPO_URL="https://gitea.cbcren.online/renato97/worst-scan-web.git" +INSTALL_DIR="${HOME}/worst-scan-web" +BRANCH="main" + +# Colors +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +CYAN='\033[0;36m' +NC='\033[0m' + +log() { printf "${GREEN}✓${NC} %s\n" "$1"; } +warn() { printf "${YELLOW}⚠${NC} %s\n" "$1"; } +err() { printf "${RED}✗${NC} %s\n" "$1"; } +info() { printf "${CYAN}→${NC} %s\n" "$1"; } + +# ── Prerequisites ──────────────────────────────── +prereqs=("git" "docker") +missing=() +for cmd in "${prereqs[@]}"; do + if ! command -v "$cmd" &>/dev/null; then + missing+=("$cmd") + fi +done + +if ! docker compose version &>/dev/null && ! docker-compose --version &>/dev/null; then + missing+=("docker compose plugin") +fi + +if [ ${#missing[@]} -gt 0 ]; then + err "Missing prerequisites: ${missing[*]}" + info "Install them and re-run:" + info " Ubuntu/Debian: sudo apt update && sudo apt install -y git docker.io docker-compose-v2" + info " Arch: sudo pacman -S git docker docker-compose" + info " Fedora: sudo dnf install -y git docker docker-compose" + exit 1 +fi +log "Prerequisites satisfied" + +# Check docker is running +if ! docker info &>/dev/null; then + err "Docker daemon is not running. Start it with: sudo systemctl start docker" + exit 1 +fi +log "Docker daemon is running" + +# ── Clone / Pull ───────────────────────────────── +if [ -d "$INSTALL_DIR" ]; then + info "Directory $INSTALL_DIR already exists — pulling latest" + cd "$INSTALL_DIR" + git fetch origin "$BRANCH" + git reset --hard "origin/$BRANCH" + log "Updated to latest commit" +else + info "Cloning repository..." + git clone --depth=1 -b "$BRANCH" "$REPO_URL" "$INSTALL_DIR" + cd "$INSTALL_DIR" + log "Repository cloned" +fi + +INSTALL_DIR="$(cd "$INSTALL_DIR" && pwd)" + +# ── .env ───────────────────────────────────────── +if [ -f "$INSTALL_DIR/.env" ]; then + warn ".env already exists — not overwriting" + info "Edit $INSTALL_DIR/.env if you need to change settings" +else + cp "$INSTALL_DIR/.env.example" "$INSTALL_DIR/.env" + log "Created .env from .env.example" + info "Open $INSTALL_DIR/.env and set up your API_BASE_URL, API_KEY, WEB_PASSWORD" + info "Then run the web setup wizard at http://YOUR_IP:3000/setup after starting" +fi + +# ── Data directory ──────────────────────────────── +mkdir -p "$INSTALL_DIR/data" +log "Data directory ready" + +# ── Build & Start ──────────────────────────────── +info "Building Docker image (this may take a few minutes)..." +docker compose build web +log "Build complete" + +info "Starting containers..." +docker compose up -d +log "Containers are running" + +# ── Wait for health ────────────────────────────── +info "Waiting for web service to be healthy..." +for i in $(seq 1 30); do + if docker compose exec -T web wget --no-verbose --tries=1 --spider http://127.0.0.1:3000/api/health &>/dev/null; then + log "Web service is healthy" + break + fi + if [ "$i" -eq 30 ]; then + warn "Timed out waiting for health check — check logs with: docker compose logs web" + fi + sleep 2 +done + +# ── Get IP ────────────────────────────────────── +IP=$(curl -fsSL http://checkip.amazonaws.com 2>/dev/null || curl -fsSL https://api.ipify.org 2>/dev/null || echo "localhost") + +# ── Summary ────────────────────────────────────── +printf "\n" +printf "╔══════════════════════════════════════════════╗\n" +printf "║ ${GREEN}worst-scan-web installed!${NC} ║\n" +printf "╠══════════════════════════════════════════════╣\n" +printf "║ Web: http://%s:3000 ║\n" "$IP" +printf "║ Config: %s/.env ║\n" "$INSTALL_DIR" +printf "║ Logs: docker compose logs -f ║\n" +printf "║ Restart: docker compose restart ║\n" +printf "║ Stop: docker compose down ║\n" +printf "║ Update: bash %s/install.sh ║\n" "$INSTALL_DIR" +printf "╚══════════════════════════════════════════════╝\n" +printf "\n" +info "Next steps:" +printf " 1. Open http://%s:3000/setup in your browser\n" "$IP" +printf " 2. Complete the web setup wizard\n" +printf " 3. Log in and start using worst-scan-web\n" +printf "\n" diff --git a/src/app/api/auth/login/route.ts b/src/app/api/auth/login/route.ts index ede70a6..ab16e3b 100644 --- a/src/app/api/auth/login/route.ts +++ b/src/app/api/auth/login/route.ts @@ -1,8 +1,9 @@ import { cookies } from "next/headers" import { createSession, sessionCookieOptions } from "@/lib/auth" +import { get } from "@/lib/settings" export async function POST(req: Request) { - const webPassword = process.env.WEB_PASSWORD + const webPassword = process.env.WEB_PASSWORD || get("WEB_PASSWORD") if (!webPassword) { const cookieStore = await cookies() const opts = sessionCookieOptions() diff --git a/src/app/api/health/route.ts b/src/app/api/health/route.ts new file mode 100644 index 0000000..4e2397c --- /dev/null +++ b/src/app/api/health/route.ts @@ -0,0 +1,5 @@ +export const dynamic = "force-dynamic" + +export async function GET() { + return Response.json({ status: "ok", uptime: process.uptime() }) +} diff --git a/src/app/api/setup/route.ts b/src/app/api/setup/route.ts new file mode 100644 index 0000000..d9e46f7 --- /dev/null +++ b/src/app/api/setup/route.ts @@ -0,0 +1,28 @@ +import { saveSettings, isConfigured, allSettings } from "@/lib/settings" + +export async function GET() { + const configured = isConfigured() + return Response.json({ configured }) +} + +export async function POST(req: Request) { + const body = await req.json() + + const allowed = [ + "API_BASE_URL", + "API_KEY", + "WEB_PASSWORD", + "WEBHOOK_SECRET", + ] + + const toSave: Record = {} + for (const key of allowed) { + if (body[key] !== undefined) { + toSave[key] = String(body[key]) + } + } + + saveSettings(toSave) + + return Response.json({ ok: true, configured: isConfigured() }) +} diff --git a/src/app/setup/page.tsx b/src/app/setup/page.tsx new file mode 100644 index 0000000..75266ea --- /dev/null +++ b/src/app/setup/page.tsx @@ -0,0 +1,180 @@ +"use client" + +import { useEffect, useState, FormEvent } from "react" +import { useRouter } from "next/navigation" +import { CheckCircle, Image, Loader2, Save } from "lucide-react" + +export default function SetupPage() { + const router = useRouter() + const [loading, setLoading] = useState(true) + const [saving, setSaving] = useState(false) + const [done, setDone] = useState(false) + const [error, setError] = useState("") + const [form, setForm] = useState({ + API_BASE_URL: "http://127.0.0.1:8080/api/v1", + API_KEY: "", + WEB_PASSWORD: "", + WEBHOOK_SECRET: "", + }) + + useEffect(() => { + fetch("/api/setup") + .then((r) => r.json()) + .then((data) => { + if (data.configured) { + router.push("/feed") + } else { + setLoading(false) + } + }) + .catch(() => setLoading(false)) + }, [router]) + + async function handleSubmit(e: FormEvent) { + e.preventDefault() + setSaving(true) + setError("") + + const res = await fetch("/api/setup", { + method: "POST", + body: JSON.stringify(form), + }) + + if (!res.ok) { + setError("Error al guardar configuración") + setSaving(false) + return + } + + setDone(true) + } + + if (loading) { + return ( +
+ +
+ ) + } + + if (done) { + return ( +
+
+
+
+
+ +
+
+

¡Configurado!

+

+ La configuración se ha guardado. Ahora puedes iniciar sesión. +

+ +
+
+
+ ) + } + + return ( +
+
+
+
+
+ +
+

Configuración inicial

+

+ worst-scan-web · configura tus endpoints y credenciales +

+
+ +
+
+ + setForm({ ...form, API_BASE_URL: e.target.value })} + className="input" + /> +

+ URL base de la API REST de worst-scan +

+
+ +
+ + setForm({ ...form, API_KEY: e.target.value })} + className="input" + /> +

+ API key para autenticación (X-API-Key) +

+
+ +
+ + setForm({ ...form, WEB_PASSWORD: e.target.value })} + className="input" + minLength={6} + /> +

+ Protege el panel de control con contraseña +

+
+ +
+ + setForm({ ...form, WEBHOOK_SECRET: e.target.value })} + className="input" + /> +

+ Secreto para validar webhooks entrantes +

+
+ + {error && ( +

{error}

+ )} + + +
+
+ +

+ worst-scan · traducción automática de manga +

+
+
+ ) +} diff --git a/src/instrumentation.ts b/src/instrumentation.ts index 58fc76e..85283c0 100644 --- a/src/instrumentation.ts +++ b/src/instrumentation.ts @@ -1,5 +1,8 @@ export async function register() { if (process.env.NEXT_RUNTIME === "nodejs") { + const { initSettings } = await import("./lib/settings") + initSettings() + const { startPoller } = await import("./lib/poller") startPoller() } diff --git a/src/lib/api.ts b/src/lib/api.ts index be3bf98..1bdda8f 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -11,19 +11,20 @@ import type { SystemStatus, } from "./types" -const API_BASE = process.env.API_BASE_URL || "http://127.0.0.1:8080/api/v1" -const API_KEY = process.env.API_KEY || "" +import { get } from "./settings" async function fetchApi( path: string, init?: RequestInit, ): Promise { - const url = `${API_BASE}${path}` + const apiBase = get("API_BASE_URL", "http://127.0.0.1:8080/api/v1") + const apiKey = get("API_KEY") + const url = `${apiBase}${path}` const headers: Record = { ...(init?.headers as Record), } - if (API_KEY) { - headers["X-API-Key"] = API_KEY + if (apiKey) { + headers["X-API-Key"] = apiKey } if (init?.body && typeof init.body === "string" && !(headers["Content-Type"])) { headers["Content-Type"] = "application/json" diff --git a/src/lib/settings.ts b/src/lib/settings.ts new file mode 100644 index 0000000..dda7767 --- /dev/null +++ b/src/lib/settings.ts @@ -0,0 +1,45 @@ +import "server-only" + +import fs from "fs" +import path from "path" + +const DATA_DIR = process.env.DB_PATH + ? path.dirname(process.env.DB_PATH) + : path.join(process.cwd(), "data") + +const SETTINGS_PATH = path.join(DATA_DIR, "settings.json") + +let settingsCache: Record = {} + +export function initSettings(): void { + try { + if (!fs.existsSync(SETTINGS_PATH)) { + settingsCache = {} + return + } + const raw = fs.readFileSync(SETTINGS_PATH, "utf-8") + settingsCache = JSON.parse(raw) + } catch { + settingsCache = {} + } +} + +export function get(key: string, fallback = ""): string { + return settingsCache[key] || process.env[key] || fallback +} + +export function isConfigured(): boolean { + return Boolean(get("API_BASE_URL", "")) +} + +export function saveSettings( + values: Record, +): void { + fs.mkdirSync(DATA_DIR, { recursive: true }) + Object.assign(settingsCache, values) + fs.writeFileSync(SETTINGS_PATH, JSON.stringify(settingsCache, null, 2) + "\n") +} + +export function allSettings(): Record { + return { ...settingsCache } +} diff --git a/src/middleware.ts b/src/middleware.ts index ab46eb1..b9b76a4 100644 --- a/src/middleware.ts +++ b/src/middleware.ts @@ -5,10 +5,13 @@ const publicPaths = [ "/login", "/api/auth/login", "/api/auth/logout", + "/api/health", "/api/proxy/health", "/api/posts", "/api/cover", "/api/cron", + "/api/setup", + "/setup", "/_next", "/favicon.ico", "/fonts",