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
+2 -1
View File
@@ -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()
+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() {
if (process.env.NEXT_RUNTIME === "nodejs") {
const { initSettings } = await import("./lib/settings")
initSettings()
const { startPoller } = await import("./lib/poller")
startPoller()
}
+6 -5
View File
@@ -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<T>(
path: string,
init?: RequestInit,
): 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> = {
...(init?.headers as Record<string, string>),
}
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"
+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",
"/api/auth/login",
"/api/auth/logout",
"/api/health",
"/api/proxy/health",
"/api/posts",
"/api/cover",
"/api/cron",
"/api/setup",
"/setup",
"/_next",
"/favicon.ico",
"/fonts",