feat: auth, site creation with PHP version selection

This commit is contained in:
orohi
2026-06-17 04:46:44 +03:00
parent 0f31c24bf9
commit b7753505b8
20 changed files with 1097 additions and 114 deletions
+89
View File
@@ -0,0 +1,89 @@
package handler
import (
"encoding/json"
"errors"
"net/http"
"time"
"github.com/panelhosting/panel/internal/auth"
"github.com/panelhosting/panel/internal/authsvc"
"github.com/panelhosting/panel/internal/config"
"github.com/panelhosting/panel/internal/middleware"
)
type AuthHandler struct {
svc *authsvc.Service
cfg *config.Config
}
func NewAuthHandler(svc *authsvc.Service, cfg *config.Config) *AuthHandler {
return &AuthHandler{svc: svc, cfg: cfg}
}
type loginRequest struct {
Username string `json:"username"`
Password string `json:"password"`
}
func (h *AuthHandler) Login(w http.ResponseWriter, r *http.Request) {
var req loginRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid json")
return
}
result, err := h.svc.Login(r.Context(), authsvc.LoginInput{
Username: req.Username,
Password: req.Password,
IP: r.RemoteAddr,
UserAgent: r.UserAgent(),
})
if err != nil {
if errors.Is(err, auth.ErrInvalidCredentials) {
writeError(w, http.StatusUnauthorized, "invalid credentials")
return
}
writeError(w, http.StatusInternalServerError, "login failed")
return
}
http.SetCookie(w, &http.Cookie{
Name: h.cfg.SessionCookieName,
Value: result.Token,
Path: "/",
HttpOnly: true,
SameSite: http.SameSiteLaxMode,
MaxAge: h.cfg.SessionTTLHours * 3600,
})
writeJSON(w, http.StatusOK, map[string]any{"user": result.User})
}
func (h *AuthHandler) Logout(w http.ResponseWriter, r *http.Request) {
token := ""
if c, err := r.Cookie(h.cfg.SessionCookieName); err == nil {
token = c.Value
}
_ = h.svc.Logout(r.Context(), token)
http.SetCookie(w, &http.Cookie{
Name: h.cfg.SessionCookieName,
Value: "",
Path: "/",
HttpOnly: true,
MaxAge: -1,
Expires: time.Unix(0, 0),
})
writeJSON(w, http.StatusOK, map[string]string{"message": "logged out"})
}
func (h *AuthHandler) Me(w http.ResponseWriter, r *http.Request) {
user, ok := middleware.UserFromContext(r.Context())
if !ok {
writeError(w, http.StatusUnauthorized, "unauthorized")
return
}
writeJSON(w, http.StatusOK, map[string]any{"user": user})
}
+117 -93
View File
@@ -1,8 +1,10 @@
package handler
import (
"fmt"
"net/http"
"github.com/panelhosting/panel/internal/middleware"
"github.com/panelhosting/panel/internal/setup"
)
@@ -12,107 +14,129 @@ func Health(w http.ResponseWriter, _ *http.Request) {
_, _ = w.Write([]byte(`{"status":"ok"}`))
}
func IndexPage(svc *setup.Service) http.HandlerFunc {
func IndexPage(setupSvc *setup.Service) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
required, err := svc.Status(r.Context())
required, err := setupSvc.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))
if required {
serveHTML(w, setupPageHTML)
return
}
writeJSON(w, http.StatusOK, map[string]string{
"name": "PanelHosting",
"version": "0.1.0",
"status": "running",
})
if user, ok := middleware.UserFromContext(r.Context()); ok {
serveHTML(w, dashboardPageHTML(user.Username))
return
}
serveHTML(w, loginPageHTML)
}
}
func serveHTML(w http.ResponseWriter, html string) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(html))
}
const baseCSS = `
* { box-sizing: border-box; }
body { font-family: system-ui, sans-serif; background: #0f172a; color: #e2e8f0; margin: 0; min-height: 100vh; }
a { color: #60a5fa; }
.wrap { max-width: 960px; margin: 0 auto; padding: 1.5rem; }
.card { background: #1e293b; border-radius: 12px; padding: 1.5rem; box-shadow: 0 12px 32px rgba(0,0,0,.25); margin-bottom: 1rem; }
h1 { margin: 0 0 .5rem; font-size: 1.5rem; }
h2 { margin: 0 0 1rem; font-size: 1.1rem; color: #cbd5e1; }
p.sub { color: #94a3b8; margin: 0 0 1.25rem; }
label { display: block; margin-bottom: .35rem; font-size: .85rem; color: #cbd5e1; }
input, select { width: 100%; padding: .65rem .75rem; margin-bottom: 1rem; border: 1px solid #334155; border-radius: 8px; background: #0f172a; color: #f8fafc; font-size: 1rem; }
input:focus, select:focus { outline: 2px solid #3b82f6; border-color: transparent; }
button { padding: .65rem 1rem; background: #3b82f6; color: #fff; border: none; border-radius: 8px; font-size: .95rem; font-weight: 600; cursor: pointer; }
button:hover { background: #2563eb; }
button:disabled { opacity: .6; cursor: not-allowed; }
button.secondary { background: #334155; }
button.secondary:hover { background: #475569; }
.row { display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; }
@media (max-width: 640px) { .row { grid-template-columns: 1fr; } }
.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; }
.topbar { display: flex; justify-content: space-between; align-items: center; margin-bottom: 1.5rem; }
table { width: 100%; border-collapse: collapse; font-size: .9rem; }
th, td { text-align: left; padding: .6rem .5rem; border-bottom: 1px solid #334155; }
th { color: #94a3b8; font-weight: 500; }
.badge { display: inline-block; padding: .15rem .5rem; border-radius: 999px; font-size: .75rem; background: #14532d; color: #bbf7d0; }
.center-card { min-height: 100vh; display: flex; align-items: center; justify-content: center; padding: 1rem; }
.center-card .card { width: 100%; max-width: 420px; }
`
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>`
<html lang="ru"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
<title>PanelHosting — настройка</title><style>` + baseCSS + `</style></head>
<body><div class="center-card"><div class="card">
<h1>PanelHosting</h1><p class="sub">Создайте суперадминистратора</p>
<form id="form">
<label>Email</label><input id="email" type="email" required>
<label>Логин</label><input id="username" type="text" required pattern="[a-zA-Z0-9_]{3,64}">
<label>Пароль</label><input id="password" type="password" required minlength="8">
<button type="submit" id="btn">Создать</button>
</form><div id="msg" class="msg"></div>
</div></div>
<script>
document.getElementById('form').onsubmit=async e=>{e.preventDefault();const btn=document.getElementById('btn'),msg=document.getElementById('msg');btn.disabled=true;msg.className='msg';
const res=await fetch('/api/v1/setup/register',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({email:email.value.trim(),username:username.value.trim(),password:password.value})});
const d=await res.json().catch(()=>({}));if(!res.ok){msg.className='msg error';msg.textContent=d.error||'Ошибка';btn.disabled=false;return;}
location.reload();};
</script></body></html>`
const loginPageHTML = `<!DOCTYPE html>
<html lang="ru"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
<title>PanelHosting — вход</title><style>` + baseCSS + `</style></head>
<body><div class="center-card"><div class="card">
<h1>PanelHosting</h1><p class="sub">Войдите в панель</p>
<form id="form">
<label>Логин</label><input id="username" type="text" required autocomplete="username">
<label>Пароль</label><input id="password" type="password" required autocomplete="current-password">
<button type="submit" id="btn">Войти</button>
</form><div id="msg" class="msg"></div>
</div></div>
<script>
document.getElementById('form').onsubmit=async e=>{e.preventDefault();const btn=document.getElementById('btn'),msg=document.getElementById('msg');btn.disabled=true;msg.className='msg';
const res=await fetch('/api/v1/auth/login',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({username:username.value.trim(),password:password.value})});
const d=await res.json().catch(()=>({}));if(!res.ok){msg.className='msg error';msg.textContent=d.error||'Неверный логин или пароль';btn.disabled=false;return;}
location.reload();};
</script></body></html>`
func dashboardPageHTML(username string) string {
return fmt.Sprintf(`<!DOCTYPE html>
<html lang="ru"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
<title>PanelHosting</title><style>%s</style></head>
<body><div class="wrap">
<div class="topbar"><div><h1>PanelHosting</h1><p class="sub">%s</p></div>
<button class="secondary" id="logout">Выйти</button></div>
<div class="card"><h2>Новый сайт</h2>
<form id="siteForm"><div class="row">
<div><label>Имя сайта</label><input id="name" required pattern="[a-z0-9][a-z0-9-]{1,62}[a-z0-9]" placeholder="mysite"></div>
<div><label>Домен</label><input id="domain" required placeholder="example.com"></div>
</div><label>PHP версия</label><select id="php_version" required></select>
<button type="submit" id="createBtn">Создать сайт</button></form>
<div id="formMsg" class="msg"></div></div>
<div class="card"><h2>Сайты</h2>
<table><thead><tr><th>Имя</th><th>Домен</th><th>PHP</th><th>Статус</th><th>Путь</th></tr></thead>
<tbody id="sites"></tbody></table></div>
</div>
<script>
async function loadPHP(){const r=await fetch('/api/v1/php-versions');const d=await r.json();const s=document.getElementById('php_version');s.innerHTML='';
(d.versions||[]).forEach(v=>{const o=document.createElement('option');o.value=v;o.textContent='PHP '+v;s.appendChild(o);});}
async function loadSites(){const r=await fetch('/api/v1/sites');const d=await r.json();const tb=document.getElementById('sites');tb.innerHTML='';
(d.sites||[]).forEach(site=>{const tr=document.createElement('tr');
tr.innerHTML='<td>'+site.name+'</td><td>'+site.primary_domain+'</td><td>'+(site.php_version||'-')+'</td><td><span class="badge">'+site.status+'</span></td><td><code>'+site.document_root+'</code></td>';
tb.appendChild(tr);});}
document.getElementById('logout').onclick=async()=>{await fetch('/api/v1/auth/logout',{method:'POST'});location.reload();};
document.getElementById('siteForm').onsubmit=async e=>{e.preventDefault();const btn=document.getElementById('createBtn'),msg=document.getElementById('formMsg');btn.disabled=true;msg.className='msg';
const res=await fetch('/api/v1/sites',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({name:name.value.trim().toLowerCase(),domain:domain.value.trim().toLowerCase(),php_version:php_version.value})});
const d=await res.json().catch(()=>({}));if(!res.ok){msg.className='msg error';msg.textContent=d.error||'Ошибка';btn.disabled=false;return;}
msg.className='msg ok';msg.textContent='Сайт создан';siteForm.reset();btn.disabled=false;await loadPHP();loadSites();};
loadPHP();loadSites();
</script></body></html>`, baseCSS, username)
}
+85
View File
@@ -0,0 +1,85 @@
package handler
import (
"encoding/json"
"errors"
"net/http"
"github.com/panelhosting/panel/internal/middleware"
"github.com/panelhosting/panel/internal/sitesvc"
)
type SiteHandler struct {
svc *sitesvc.Service
}
func NewSiteHandler(svc *sitesvc.Service) *SiteHandler {
return &SiteHandler{svc: svc}
}
type createSiteRequest struct {
Name string `json:"name"`
Domain string `json:"domain"`
PHPVersion string `json:"php_version"`
ServerID int64 `json:"server_id,omitempty"`
}
func (h *SiteHandler) List(w http.ResponseWriter, r *http.Request) {
user, ok := middleware.UserFromContext(r.Context())
if !ok {
writeError(w, http.StatusUnauthorized, "unauthorized")
return
}
sites, err := h.svc.List(r.Context(), user, middleware.IsAdmin(user))
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to list sites")
return
}
writeJSON(w, http.StatusOK, map[string]any{"sites": sites})
}
func (h *SiteHandler) Create(w http.ResponseWriter, r *http.Request) {
user, ok := middleware.UserFromContext(r.Context())
if !ok {
writeError(w, http.StatusUnauthorized, "unauthorized")
return
}
var req createSiteRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid json")
return
}
site, err := h.svc.Create(r.Context(), sitesvc.CreateInput{
Name: req.Name,
Domain: req.Domain,
PHPVersion: req.PHPVersion,
ServerID: req.ServerID,
OwnerID: user.ID,
IsAdmin: middleware.IsAdmin(user),
})
if err != nil {
switch {
case errors.Is(err, sitesvc.ErrInvalidSiteName),
errors.Is(err, sitesvc.ErrInvalidDomain),
errors.Is(err, sitesvc.ErrInvalidPHPVersion):
writeError(w, http.StatusBadRequest, err.Error())
default:
writeError(w, http.StatusInternalServerError, "failed to create site")
}
return
}
writeJSON(w, http.StatusCreated, map[string]any{"site": site})
}
func (h *SiteHandler) PHPVersions(w http.ResponseWriter, r *http.Request) {
versions, err := h.svc.ListPHPVersions(r.Context())
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to list php versions")
return
}
writeJSON(w, http.StatusOK, map[string]any{"versions": versions})
}