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)
This commit is contained in:
Renato
2026-07-27 23:24:57 +02:00
parent 571b6d1a7c
commit 0fc421f234
12 changed files with 424 additions and 15 deletions
+11 -9
View File
@@ -1,16 +1,18 @@
# API del pipeline (VPS1) # --- worst-scan-web ---
API_BASE_URL=http://127.0.0.1:8080/api/v1 # Copy to .env and adjust.
# Backend API
API_BASE_URL=http://host.docker.internal:8080/api/v1
API_KEY= API_KEY=
# Auth de la web (vacío = sin auth en dev) # Web auth
WEB_PASSWORD= WEB_PASSWORD=
# Webhook Secret para sincronización automática desde el pipeline # Webhook validation
WEBHOOK_SECRET= WEBHOOK_SECRET=
# Puerto # DB path
PORT=3000 DB_PATH=/app/data/worst-scan.db
# Persistencia (SQLite + covers en data/) # Covers dir
DB_PATH=data/worst-scan.db COVERS_DIR=/app/data/covers
COVERS_DIR=data/covers
+3
View File
@@ -31,4 +31,7 @@ ENV COVERS_DIR=/app/data/covers
VOLUME ["/app/data"] 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"] CMD ["node", "server.js"]
+9
View File
@@ -4,6 +4,9 @@ services:
container_name: worst-scan-web container_name: worst-scan-web
expose: expose:
- "3000" - "3000"
env_file:
- path: .env
required: false
environment: environment:
- API_BASE_URL=${API_BASE_URL:-http://host.docker.internal:8080/api/v1} - API_BASE_URL=${API_BASE_URL:-http://host.docker.internal:8080/api/v1}
- API_KEY=${API_KEY:-} - API_KEY=${API_KEY:-}
@@ -17,6 +20,12 @@ services:
- caddy - caddy
- default - default
restart: unless-stopped 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: networks:
caddy: caddy:
Executable
+129
View File
@@ -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"
+2 -1
View File
@@ -1,8 +1,9 @@
import { cookies } from "next/headers" import { cookies } from "next/headers"
import { createSession, sessionCookieOptions } from "@/lib/auth" import { createSession, sessionCookieOptions } from "@/lib/auth"
import { get } from "@/lib/settings"
export async function POST(req: Request) { export async function POST(req: Request) {
const webPassword = process.env.WEB_PASSWORD const webPassword = process.env.WEB_PASSWORD || get("WEB_PASSWORD")
if (!webPassword) { if (!webPassword) {
const cookieStore = await cookies() const cookieStore = await cookies()
const opts = sessionCookieOptions() const opts = sessionCookieOptions()
+5
View File
@@ -0,0 +1,5 @@
export const dynamic = "force-dynamic"
export async function GET() {
return Response.json({ status: "ok", uptime: process.uptime() })
}
+28
View File
@@ -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<string, string> = {}
for (const key of allowed) {
if (body[key] !== undefined) {
toSave[key] = String(body[key])
}
}
saveSettings(toSave)
return Response.json({ ok: true, configured: isConfigured() })
}
+180
View File
@@ -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 (
<div className="flex min-h-screen items-center justify-center bg-[var(--background)]">
<Loader2 className="h-6 w-6 animate-spin text-[var(--muted)]" />
</div>
)
}
if (done) {
return (
<div className="flex min-h-screen items-center justify-center bg-[var(--background)]">
<div className="w-full max-w-sm">
<div className="card p-8 text-center">
<div className="mb-3 flex justify-center">
<div className="flex h-12 w-12 items-center justify-center rounded-xl bg-green-500/10">
<CheckCircle className="h-6 w-6 text-green-500" />
</div>
</div>
<h1 className="text-xl font-bold tracking-tight">¡Configurado!</h1>
<p className="mt-2 text-sm text-[var(--muted)]">
La configuración se ha guardado. Ahora puedes iniciar sesión.
</p>
<button onClick={() => router.push("/login")} className="btn-primary mt-6 w-full">
Ir al login
</button>
</div>
</div>
</div>
)
}
return (
<div className="flex min-h-screen items-center justify-center bg-[var(--background)] p-4">
<div className="w-full max-w-lg">
<div className="card p-8">
<div className="mb-6 flex flex-col items-center text-center">
<div className="mb-3 flex h-12 w-12 items-center justify-center rounded-xl bg-[var(--accent-subtle)]">
<Image className="h-6 w-6 text-[var(--accent)]" />
</div>
<h1 className="text-xl font-bold tracking-tight">Configuración inicial</h1>
<p className="mt-1 text-sm text-[var(--muted)]">
worst-scan-web &middot; configura tus endpoints y credenciales
</p>
</div>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="mb-1 block text-xs font-medium text-[var(--muted)] uppercase tracking-wide">
API Base URL
</label>
<input
type="url"
placeholder="http://127.0.0.1:8080/api/v1"
value={form.API_BASE_URL}
onChange={(e) => setForm({ ...form, API_BASE_URL: e.target.value })}
className="input"
/>
<p className="mt-1 text-xs text-[var(--muted)]">
URL base de la API REST de worst-scan
</p>
</div>
<div>
<label className="mb-1 block text-xs font-medium text-[var(--muted)] uppercase tracking-wide">
API Key
</label>
<input
type="password"
placeholder="opcional"
value={form.API_KEY}
onChange={(e) => setForm({ ...form, API_KEY: e.target.value })}
className="input"
/>
<p className="mt-1 text-xs text-[var(--muted)]">
API key para autenticación (X-API-Key)
</p>
</div>
<div>
<label className="mb-1 block text-xs font-medium text-[var(--muted)] uppercase tracking-wide">
Contraseña del panel
</label>
<input
type="password"
placeholder="min 6 caracteres"
value={form.WEB_PASSWORD}
onChange={(e) => setForm({ ...form, WEB_PASSWORD: e.target.value })}
className="input"
minLength={6}
/>
<p className="mt-1 text-xs text-[var(--muted)]">
Protege el panel de control con contraseña
</p>
</div>
<div>
<label className="mb-1 block text-xs font-medium text-[var(--muted)] uppercase tracking-wide">
Webhook Secret
</label>
<input
type="password"
placeholder="opcional"
value={form.WEBHOOK_SECRET}
onChange={(e) => setForm({ ...form, WEBHOOK_SECRET: e.target.value })}
className="input"
/>
<p className="mt-1 text-xs text-[var(--muted)]">
Secreto para validar webhooks entrantes
</p>
</div>
{error && (
<p className="text-sm text-[var(--error)] bg-[var(--error-subtle)] rounded-lg px-3 py-2">{error}</p>
)}
<button type="submit" disabled={saving} className="btn-primary w-full">
<Save className="h-4 w-4" />
{saving ? "Guardando..." : "Guardar configuración"}
</button>
</form>
</div>
<p className="mt-6 text-center text-xs text-[var(--muted)]">
worst-scan &middot; traducción automática de manga
</p>
</div>
</div>
)
}
+3
View File
@@ -1,5 +1,8 @@
export async function register() { export async function register() {
if (process.env.NEXT_RUNTIME === "nodejs") { if (process.env.NEXT_RUNTIME === "nodejs") {
const { initSettings } = await import("./lib/settings")
initSettings()
const { startPoller } = await import("./lib/poller") const { startPoller } = await import("./lib/poller")
startPoller() startPoller()
} }
+6 -5
View File
@@ -11,19 +11,20 @@ import type {
SystemStatus, SystemStatus,
} from "./types" } from "./types"
const API_BASE = process.env.API_BASE_URL || "http://127.0.0.1:8080/api/v1" import { get } from "./settings"
const API_KEY = process.env.API_KEY || ""
async function fetchApi<T>( async function fetchApi<T>(
path: string, path: string,
init?: RequestInit, init?: RequestInit,
): Promise<T> { ): Promise<T> {
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<string, string> = { const headers: Record<string, string> = {
...(init?.headers as Record<string, string>), ...(init?.headers as Record<string, string>),
} }
if (API_KEY) { if (apiKey) {
headers["X-API-Key"] = API_KEY headers["X-API-Key"] = apiKey
} }
if (init?.body && typeof init.body === "string" && !(headers["Content-Type"])) { if (init?.body && typeof init.body === "string" && !(headers["Content-Type"])) {
headers["Content-Type"] = "application/json" headers["Content-Type"] = "application/json"
+45
View File
@@ -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<string, string> = {}
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<string, string>,
): 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<string, string> {
return { ...settingsCache }
}
+3
View File
@@ -5,10 +5,13 @@ const publicPaths = [
"/login", "/login",
"/api/auth/login", "/api/auth/login",
"/api/auth/logout", "/api/auth/logout",
"/api/health",
"/api/proxy/health", "/api/proxy/health",
"/api/posts", "/api/posts",
"/api/cover", "/api/cover",
"/api/cron", "/api/cron",
"/api/setup",
"/setup",
"/_next", "/_next",
"/favicon.ico", "/favicon.ico",
"/fonts", "/fonts",