feat: HTTP panel on :8000 with first admin setup

This commit is contained in:
orohi
2026-06-17 04:40:34 +03:00
parent f214aaa48b
commit 0f31c24bf9
14 changed files with 502 additions and 5 deletions
+118
View File
@@ -0,0 +1,118 @@
package handler
import (
"net/http"
"github.com/panelhosting/panel/internal/setup"
)
func Health(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(`{"status":"ok"}`))
}
func IndexPage(svc *setup.Service) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
required, err := svc.Status(r.Context())
if err != nil {
writeError(w, http.StatusInternalServerError, "internal error")
return
}
index(required)(w, r)
}
}
func index(setupRequired bool) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if setupRequired {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(setupPageHTML))
return
}
writeJSON(w, http.StatusOK, map[string]string{
"name": "PanelHosting",
"version": "0.1.0",
"status": "running",
})
}
}
const setupPageHTML = `<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>PanelHosting — первичная настройка</title>
<style>
* { box-sizing: border-box; }
body { font-family: system-ui, sans-serif; background: #0f172a; color: #e2e8f0; margin: 0; min-height: 100vh; display: flex; align-items: center; justify-content: center; padding: 1rem; }
.card { background: #1e293b; border-radius: 12px; padding: 2rem; width: 100%; max-width: 420px; box-shadow: 0 20px 40px rgba(0,0,0,.3); }
h1 { margin: 0 0 .5rem; font-size: 1.5rem; }
p { color: #94a3b8; margin: 0 0 1.5rem; font-size: .95rem; }
label { display: block; margin-bottom: .35rem; font-size: .85rem; color: #cbd5e1; }
input { width: 100%; padding: .65rem .75rem; margin-bottom: 1rem; border: 1px solid #334155; border-radius: 8px; background: #0f172a; color: #f8fafc; font-size: 1rem; }
input:focus { outline: 2px solid #3b82f6; border-color: transparent; }
button { width: 100%; padding: .75rem; background: #3b82f6; color: #fff; border: none; border-radius: 8px; font-size: 1rem; font-weight: 600; cursor: pointer; }
button:hover { background: #2563eb; }
button:disabled { opacity: .6; cursor: not-allowed; }
.msg { margin-top: 1rem; padding: .75rem; border-radius: 8px; font-size: .9rem; display: none; }
.msg.error { display: block; background: #7f1d1d; color: #fecaca; }
.msg.ok { display: block; background: #14532d; color: #bbf7d0; }
</style>
</head>
<body>
<div class="card">
<h1>PanelHosting</h1>
<p>Создайте учётную запись суперадминистратора</p>
<form id="form">
<label for="email">Email</label>
<input id="email" name="email" type="email" required autocomplete="email">
<label for="username">Логин</label>
<input id="username" name="username" type="text" required pattern="[a-zA-Z0-9_]{3,64}" autocomplete="username">
<label for="password">Пароль (мин. 8 символов)</label>
<input id="password" name="password" type="password" required minlength="8" autocomplete="new-password">
<button type="submit" id="btn">Создать администратора</button>
</form>
<div id="msg" class="msg"></div>
</div>
<script>
const form = document.getElementById('form');
const msg = document.getElementById('msg');
const btn = document.getElementById('btn');
form.addEventListener('submit', async (e) => {
e.preventDefault();
btn.disabled = true;
msg.className = 'msg';
msg.textContent = '';
const body = {
email: form.email.value.trim(),
username: form.username.value.trim(),
password: form.password.value
};
try {
const res = await fetch('/api/v1/setup/register', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body)
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
msg.className = 'msg error';
msg.textContent = data.error || 'Ошибка регистрации';
btn.disabled = false;
return;
}
msg.className = 'msg ok';
msg.textContent = 'Администратор создан. Панель готова к работе.';
form.reset();
} catch {
msg.className = 'msg error';
msg.textContent = 'Сетевая ошибка';
btn.disabled = false;
}
});
</script>
</body>
</html>`
+65
View File
@@ -0,0 +1,65 @@
package handler
import (
"encoding/json"
"errors"
"net/http"
"github.com/panelhosting/panel/internal/auth"
"github.com/panelhosting/panel/internal/setup"
)
type SetupHandler struct {
svc *setup.Service
}
func NewSetupHandler(svc *setup.Service) *SetupHandler {
return &SetupHandler{svc: svc}
}
func (h *SetupHandler) Status(w http.ResponseWriter, r *http.Request) {
required, err := h.svc.Status(r.Context())
if err != nil {
writeError(w, http.StatusInternalServerError, "internal error")
return
}
writeJSON(w, http.StatusOK, map[string]bool{"setup_required": required})
}
func (h *SetupHandler) Register(w http.ResponseWriter, r *http.Request) {
var in setup.RegisterInput
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
writeError(w, http.StatusBadRequest, "invalid json")
return
}
user, err := h.svc.RegisterFirstAdmin(r.Context(), in)
if err != nil {
switch {
case errors.Is(err, setup.ErrSetupCompleted):
writeError(w, http.StatusConflict, "setup already completed")
case errors.Is(err, auth.ErrPasswordTooShort),
errors.Is(err, auth.ErrInvalidEmail),
errors.Is(err, auth.ErrInvalidUsername):
writeError(w, http.StatusBadRequest, err.Error())
default:
writeError(w, http.StatusInternalServerError, "registration failed")
}
return
}
writeJSON(w, http.StatusCreated, map[string]any{
"message": "admin created",
"user": user,
})
}
func writeJSON(w http.ResponseWriter, status int, v any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(v)
}
func writeError(w http.ResponseWriter, status int, msg string) {
writeJSON(w, status, map[string]string{"error": msg})
}