feat: auth, site creation with PHP version selection
This commit is contained in:
@@ -11,3 +11,8 @@ DATABASE_URL_DOCKER=postgres://panel:panel_secret@postgres:5432/panel?sslmode=di
|
|||||||
|
|
||||||
# HTTP API панели
|
# HTTP API панели
|
||||||
HTTP_PORT=8000
|
HTTP_PORT=8000
|
||||||
|
|
||||||
|
# Локальный сервер (создаётся при первичной настройке)
|
||||||
|
DEFAULT_SERVER_NAME=local
|
||||||
|
DEFAULT_SERVER_HOSTNAME=server1
|
||||||
|
DEFAULT_SERVER_IP=127.0.0.1
|
||||||
|
|||||||
@@ -67,21 +67,35 @@ export HTTP_PORT=8000
|
|||||||
go run ./cmd/panel
|
go run ./cmd/panel
|
||||||
```
|
```
|
||||||
|
|
||||||
Откройте `http://localhost:8000` — если админ ещё не создан, появится форма регистрации.
|
Откройте `http://localhost:8000` — если админ ещё не создан, появится форма регистрации. После входа — панель управления сайтами.
|
||||||
|
|
||||||
API:
|
### API
|
||||||
- `GET /health` — healthcheck
|
|
||||||
- `GET /api/v1/setup/status` — `{"setup_required": true|false}`
|
**Авторизация**
|
||||||
- `POST /api/v1/setup/register` — создание первого суперадмина (только пока нет пользователей)
|
- `POST /api/v1/auth/login` — `{"username":"admin","password":"..."}` → cookie `panel_session`
|
||||||
|
- `POST /api/v1/auth/logout` — выход (требует сессию)
|
||||||
|
- `GET /api/v1/auth/me` — текущий пользователь
|
||||||
|
|
||||||
|
**Сайты** (требуют авторизацию)
|
||||||
|
- `GET /api/v1/sites` — список сайтов
|
||||||
|
- `POST /api/v1/sites` — создать сайт
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"email": "admin@example.com",
|
"name": "mysite",
|
||||||
"username": "admin",
|
"domain": "example.com",
|
||||||
"password": "secret123"
|
"php_version": "8.3"
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
**PHP**
|
||||||
|
- `GET /api/v1/php-versions` — доступные версии PHP
|
||||||
|
|
||||||
|
**Setup**
|
||||||
|
- `GET /health` — healthcheck
|
||||||
|
- `GET /api/v1/setup/status` — `{"setup_required": true|false}`
|
||||||
|
- `POST /api/v1/setup/register` — создание первого суперадмина (только пока нет пользователей)
|
||||||
|
|
||||||
## Структура проекта
|
## Структура проекта
|
||||||
|
|
||||||
```
|
```
|
||||||
|
|||||||
+1
-3
@@ -3,7 +3,6 @@ package main
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
|
||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
@@ -31,8 +30,7 @@ func main() {
|
|||||||
}
|
}
|
||||||
defer pool.Close()
|
defer pool.Close()
|
||||||
|
|
||||||
addr := fmt.Sprintf(":%s", cfg.HTTPPort)
|
srv, err := server.New(pool, cfg)
|
||||||
srv, err := server.New(pool, addr)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatal(err)
|
log.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -43,6 +43,9 @@ services:
|
|||||||
environment:
|
environment:
|
||||||
DATABASE_URL: ${DATABASE_URL_DOCKER:-postgres://panel:panel_secret@postgres:5432/panel?sslmode=disable}
|
DATABASE_URL: ${DATABASE_URL_DOCKER:-postgres://panel:panel_secret@postgres:5432/panel?sslmode=disable}
|
||||||
HTTP_PORT: "8000"
|
HTTP_PORT: "8000"
|
||||||
|
DEFAULT_SERVER_NAME: ${DEFAULT_SERVER_NAME:-local}
|
||||||
|
DEFAULT_SERVER_HOSTNAME: ${DEFAULT_SERVER_HOSTNAME:-server1}
|
||||||
|
DEFAULT_SERVER_IP: ${DEFAULT_SERVER_IP:-127.0.0.1}
|
||||||
ports:
|
ports:
|
||||||
- "${HTTP_PORT:-8000}:8000"
|
- "${HTTP_PORT:-8000}:8000"
|
||||||
depends_on:
|
depends_on:
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
|
"errors"
|
||||||
|
|
||||||
|
"golang.org/x/crypto/bcrypt"
|
||||||
|
)
|
||||||
|
|
||||||
|
var ErrInvalidCredentials = errors.New("invalid credentials")
|
||||||
|
|
||||||
|
func CheckPassword(hash, password string) error {
|
||||||
|
if err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)); err != nil {
|
||||||
|
return ErrInvalidCredentials
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewSessionToken() (raw string, hash string, err error) {
|
||||||
|
b := make([]byte, 32)
|
||||||
|
if _, err = rand.Read(b); err != nil {
|
||||||
|
return "", "", err
|
||||||
|
}
|
||||||
|
raw = hex.EncodeToString(b)
|
||||||
|
hash = HashToken(raw)
|
||||||
|
return raw, hash, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func HashToken(raw string) string {
|
||||||
|
sum := sha256.Sum256([]byte(raw))
|
||||||
|
return hex.EncodeToString(sum[:])
|
||||||
|
}
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
package authsvc
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/panelhosting/panel/internal/auth"
|
||||||
|
"github.com/panelhosting/panel/internal/models"
|
||||||
|
"github.com/panelhosting/panel/internal/repository"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Service struct {
|
||||||
|
users *repository.UserRepository
|
||||||
|
sessions *repository.SessionRepository
|
||||||
|
ttl time.Duration
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewService(users *repository.UserRepository, sessions *repository.SessionRepository, ttlHours int) *Service {
|
||||||
|
return &Service{
|
||||||
|
users: users,
|
||||||
|
sessions: sessions,
|
||||||
|
ttl: time.Duration(ttlHours) * time.Hour,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type LoginInput struct {
|
||||||
|
Username string
|
||||||
|
Password string
|
||||||
|
IP string
|
||||||
|
UserAgent string
|
||||||
|
}
|
||||||
|
|
||||||
|
type LoginResult struct {
|
||||||
|
Token string
|
||||||
|
User *models.User
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) Login(ctx context.Context, in LoginInput) (*LoginResult, error) {
|
||||||
|
user, err := s.users.GetByUsername(ctx, in.Username)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, repository.ErrUserNotFound) {
|
||||||
|
return nil, auth.ErrInvalidCredentials
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if user.Status != models.UserStatusActive {
|
||||||
|
return nil, auth.ErrInvalidCredentials
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := auth.CheckPassword(user.PasswordHash, in.Password); err != nil {
|
||||||
|
return nil, auth.ErrInvalidCredentials
|
||||||
|
}
|
||||||
|
|
||||||
|
raw, hash, err := auth.NewSessionToken()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
expires := time.Now().Add(s.ttl)
|
||||||
|
if err := s.sessions.Create(ctx, user.ID, hash, in.IP, in.UserAgent, expires); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
_ = s.users.UpdateLastLogin(ctx, user.ID)
|
||||||
|
user.PasswordHash = ""
|
||||||
|
|
||||||
|
return &LoginResult{Token: raw, User: user}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) Logout(ctx context.Context, token string) error {
|
||||||
|
if token == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return s.sessions.DeleteByToken(ctx, auth.HashToken(token))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) UserByToken(ctx context.Context, token string) (*models.User, error) {
|
||||||
|
if token == "" {
|
||||||
|
return nil, repository.ErrUserNotFound
|
||||||
|
}
|
||||||
|
userID, err := s.sessions.GetUserIDByToken(ctx, auth.HashToken(token))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
user, err := s.users.GetByID(ctx, userID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if user.Status != models.UserStatusActive {
|
||||||
|
return nil, repository.ErrUserNotFound
|
||||||
|
}
|
||||||
|
user.PasswordHash = ""
|
||||||
|
return user, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) IsAdmin(user *models.User) bool {
|
||||||
|
return user.Role == models.RoleSuperAdmin || user.Role == models.RoleAdmin
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
package bootstrap
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"github.com/panelhosting/panel/internal/config"
|
||||||
|
"github.com/panelhosting/panel/internal/repository"
|
||||||
|
)
|
||||||
|
|
||||||
|
func EnsureDefaultServer(ctx context.Context, servers *repository.ServerRepository, cfg *config.Config) error {
|
||||||
|
count, err := servers.Count(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if count > 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
_, err = servers.CreateDefault(ctx, cfg.DefaultServerName, cfg.DefaultServerHost, cfg.DefaultServerIP)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func GrantServerAccess(ctx context.Context, servers *repository.ServerRepository, userID int64) error {
|
||||||
|
srv, err := servers.GetFirst(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return servers.GrantAccess(ctx, userID, srv.ID, true)
|
||||||
|
}
|
||||||
@@ -8,6 +8,11 @@ import (
|
|||||||
type Config struct {
|
type Config struct {
|
||||||
DatabaseURL string
|
DatabaseURL string
|
||||||
HTTPPort string
|
HTTPPort string
|
||||||
|
DefaultServerName string
|
||||||
|
DefaultServerHost string
|
||||||
|
DefaultServerIP string
|
||||||
|
SessionCookieName string
|
||||||
|
SessionTTLHours int
|
||||||
}
|
}
|
||||||
|
|
||||||
func Load() (*Config, error) {
|
func Load() (*Config, error) {
|
||||||
@@ -21,8 +26,33 @@ func Load() (*Config, error) {
|
|||||||
port = "8000"
|
port = "8000"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
serverName := os.Getenv("DEFAULT_SERVER_NAME")
|
||||||
|
if serverName == "" {
|
||||||
|
serverName = "local"
|
||||||
|
}
|
||||||
|
|
||||||
|
serverHost := os.Getenv("DEFAULT_SERVER_HOSTNAME")
|
||||||
|
if serverHost == "" {
|
||||||
|
serverHost = "localhost"
|
||||||
|
}
|
||||||
|
|
||||||
|
serverIP := os.Getenv("DEFAULT_SERVER_IP")
|
||||||
|
if serverIP == "" {
|
||||||
|
serverIP = "127.0.0.1"
|
||||||
|
}
|
||||||
|
|
||||||
|
cookieName := os.Getenv("SESSION_COOKIE_NAME")
|
||||||
|
if cookieName == "" {
|
||||||
|
cookieName = "panel_session"
|
||||||
|
}
|
||||||
|
|
||||||
return &Config{
|
return &Config{
|
||||||
DatabaseURL: url,
|
DatabaseURL: url,
|
||||||
HTTPPort: port,
|
HTTPPort: port,
|
||||||
|
DefaultServerName: serverName,
|
||||||
|
DefaultServerHost: serverHost,
|
||||||
|
DefaultServerIP: serverIP,
|
||||||
|
SessionCookieName: cookieName,
|
||||||
|
SessionTTLHours: 168, // 7 days
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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})
|
||||||
|
}
|
||||||
+106
-82
@@ -1,8 +1,10 @@
|
|||||||
package handler
|
package handler
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/panelhosting/panel/internal/middleware"
|
||||||
"github.com/panelhosting/panel/internal/setup"
|
"github.com/panelhosting/panel/internal/setup"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -12,107 +14,129 @@ func Health(w http.ResponseWriter, _ *http.Request) {
|
|||||||
_, _ = w.Write([]byte(`{"status":"ok"}`))
|
_, _ = 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) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
required, err := svc.Status(r.Context())
|
required, err := setupSvc.Status(r.Context())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeError(w, http.StatusInternalServerError, "internal error")
|
writeError(w, http.StatusInternalServerError, "internal error")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
index(required)(w, r)
|
if required {
|
||||||
}
|
serveHTML(w, setupPageHTML)
|
||||||
}
|
|
||||||
|
|
||||||
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
|
return
|
||||||
}
|
}
|
||||||
writeJSON(w, http.StatusOK, map[string]string{
|
if user, ok := middleware.UserFromContext(r.Context()); ok {
|
||||||
"name": "PanelHosting",
|
serveHTML(w, dashboardPageHTML(user.Username))
|
||||||
"version": "0.1.0",
|
return
|
||||||
"status": "running",
|
}
|
||||||
})
|
serveHTML(w, loginPageHTML)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const setupPageHTML = `<!DOCTYPE html>
|
func serveHTML(w http.ResponseWriter, html string) {
|
||||||
<html lang="ru">
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
<head>
|
w.WriteHeader(http.StatusOK)
|
||||||
<meta charset="utf-8">
|
_, _ = w.Write([]byte(html))
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
}
|
||||||
<title>PanelHosting — первичная настройка</title>
|
|
||||||
<style>
|
const baseCSS = `
|
||||||
* { box-sizing: border-box; }
|
* { 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; }
|
body { font-family: system-ui, sans-serif; background: #0f172a; color: #e2e8f0; margin: 0; min-height: 100vh; }
|
||||||
.card { background: #1e293b; border-radius: 12px; padding: 2rem; width: 100%; max-width: 420px; box-shadow: 0 20px 40px rgba(0,0,0,.3); }
|
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; }
|
h1 { margin: 0 0 .5rem; font-size: 1.5rem; }
|
||||||
p { color: #94a3b8; margin: 0 0 1.5rem; font-size: .95rem; }
|
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; }
|
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, 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 { outline: 2px solid #3b82f6; border-color: transparent; }
|
input:focus, select: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 { 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:hover { background: #2563eb; }
|
||||||
button:disabled { opacity: .6; cursor: not-allowed; }
|
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 { margin-top: 1rem; padding: .75rem; border-radius: 8px; font-size: .9rem; display: none; }
|
||||||
.msg.error { display: block; background: #7f1d1d; color: #fecaca; }
|
.msg.error { display: block; background: #7f1d1d; color: #fecaca; }
|
||||||
.msg.ok { display: block; background: #14532d; color: #bbf7d0; }
|
.msg.ok { display: block; background: #14532d; color: #bbf7d0; }
|
||||||
</style>
|
.topbar { display: flex; justify-content: space-between; align-items: center; margin-bottom: 1.5rem; }
|
||||||
</head>
|
table { width: 100%; border-collapse: collapse; font-size: .9rem; }
|
||||||
<body>
|
th, td { text-align: left; padding: .6rem .5rem; border-bottom: 1px solid #334155; }
|
||||||
<div class="card">
|
th { color: #94a3b8; font-weight: 500; }
|
||||||
<h1>PanelHosting</h1>
|
.badge { display: inline-block; padding: .15rem .5rem; border-radius: 999px; font-size: .75rem; background: #14532d; color: #bbf7d0; }
|
||||||
<p>Создайте учётную запись суперадминистратора</p>
|
.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>` + baseCSS + `</style></head>
|
||||||
|
<body><div class="center-card"><div class="card">
|
||||||
|
<h1>PanelHosting</h1><p class="sub">Создайте суперадминистратора</p>
|
||||||
<form id="form">
|
<form id="form">
|
||||||
<label for="email">Email</label>
|
<label>Email</label><input id="email" type="email" required>
|
||||||
<input id="email" name="email" type="email" required autocomplete="email">
|
<label>Логин</label><input id="username" type="text" required pattern="[a-zA-Z0-9_]{3,64}">
|
||||||
<label for="username">Логин</label>
|
<label>Пароль</label><input id="password" type="password" required minlength="8">
|
||||||
<input id="username" name="username" type="text" required pattern="[a-zA-Z0-9_]{3,64}" autocomplete="username">
|
<button type="submit" id="btn">Создать</button>
|
||||||
<label for="password">Пароль (мин. 8 символов)</label>
|
</form><div id="msg" class="msg"></div>
|
||||||
<input id="password" name="password" type="password" required minlength="8" autocomplete="new-password">
|
</div></div>
|
||||||
<button type="submit" id="btn">Создать администратора</button>
|
<script>
|
||||||
</form>
|
document.getElementById('form').onsubmit=async e=>{e.preventDefault();const btn=document.getElementById('btn'),msg=document.getElementById('msg');btn.disabled=true;msg.className='msg';
|
||||||
<div id="msg" class="msg"></div>
|
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>
|
</div>
|
||||||
<script>
|
<script>
|
||||||
const form = document.getElementById('form');
|
async function loadPHP(){const r=await fetch('/api/v1/php-versions');const d=await r.json();const s=document.getElementById('php_version');s.innerHTML='';
|
||||||
const msg = document.getElementById('msg');
|
(d.versions||[]).forEach(v=>{const o=document.createElement('option');o.value=v;o.textContent='PHP '+v;s.appendChild(o);});}
|
||||||
const btn = document.getElementById('btn');
|
async function loadSites(){const r=await fetch('/api/v1/sites');const d=await r.json();const tb=document.getElementById('sites');tb.innerHTML='';
|
||||||
form.addEventListener('submit', async (e) => {
|
(d.sites||[]).forEach(site=>{const tr=document.createElement('tr');
|
||||||
e.preventDefault();
|
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>';
|
||||||
btn.disabled = true;
|
tb.appendChild(tr);});}
|
||||||
msg.className = 'msg';
|
document.getElementById('logout').onclick=async()=>{await fetch('/api/v1/auth/logout',{method:'POST'});location.reload();};
|
||||||
msg.textContent = '';
|
document.getElementById('siteForm').onsubmit=async e=>{e.preventDefault();const btn=document.getElementById('createBtn'),msg=document.getElementById('formMsg');btn.disabled=true;msg.className='msg';
|
||||||
const body = {
|
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})});
|
||||||
email: form.email.value.trim(),
|
const d=await res.json().catch(()=>({}));if(!res.ok){msg.className='msg error';msg.textContent=d.error||'Ошибка';btn.disabled=false;return;}
|
||||||
username: form.username.value.trim(),
|
msg.className='msg ok';msg.textContent='Сайт создан';siteForm.reset();btn.disabled=false;await loadPHP();loadSites();};
|
||||||
password: form.password.value
|
loadPHP();loadSites();
|
||||||
};
|
</script></body></html>`, baseCSS, username)
|
||||||
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>`
|
|
||||||
|
|||||||
@@ -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})
|
||||||
|
}
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
package middleware
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/panelhosting/panel/internal/authsvc"
|
||||||
|
"github.com/panelhosting/panel/internal/config"
|
||||||
|
"github.com/panelhosting/panel/internal/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ctxKey int
|
||||||
|
|
||||||
|
const userKey ctxKey = 1
|
||||||
|
|
||||||
|
func UserFromContext(ctx context.Context) (*models.User, bool) {
|
||||||
|
u, ok := ctx.Value(userKey).(*models.User)
|
||||||
|
return u, ok
|
||||||
|
}
|
||||||
|
|
||||||
|
type Auth struct {
|
||||||
|
auth *authsvc.Service
|
||||||
|
cfg *config.Config
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewAuth(auth *authsvc.Service, cfg *config.Config) *Auth {
|
||||||
|
return &Auth{auth: auth, cfg: cfg}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *Auth) Require(next http.HandlerFunc) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
token := tokenFromRequest(r, a.cfg.SessionCookieName)
|
||||||
|
user, err := a.auth.UserByToken(r.Context(), token)
|
||||||
|
if err != nil {
|
||||||
|
writeUnauthorized(w)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ctx := context.WithValue(r.Context(), userKey, user)
|
||||||
|
next(w, r.WithContext(ctx))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *Auth) Optional(next http.HandlerFunc) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
token := tokenFromRequest(r, a.cfg.SessionCookieName)
|
||||||
|
user, err := a.auth.UserByToken(r.Context(), token)
|
||||||
|
if err == nil {
|
||||||
|
ctx := context.WithValue(r.Context(), userKey, user)
|
||||||
|
r = r.WithContext(ctx)
|
||||||
|
}
|
||||||
|
next(w, r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func tokenFromRequest(r *http.Request, cookieName string) string {
|
||||||
|
if c, err := r.Cookie(cookieName); err == nil && c.Value != "" {
|
||||||
|
return c.Value
|
||||||
|
}
|
||||||
|
h := r.Header.Get("Authorization")
|
||||||
|
if strings.HasPrefix(h, "Bearer ") {
|
||||||
|
return strings.TrimPrefix(h, "Bearer ")
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeUnauthorized(w http.ResponseWriter) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(http.StatusUnauthorized)
|
||||||
|
_, _ = w.Write([]byte(`{"error":"unauthorized"}`))
|
||||||
|
}
|
||||||
|
|
||||||
|
func IsAdmin(user *models.User) bool {
|
||||||
|
return user.Role == models.RoleSuperAdmin || user.Role == models.RoleAdmin
|
||||||
|
}
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
package repository
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
"github.com/panelhosting/panel/internal/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
var ErrServerNotFound = errors.New("server not found")
|
||||||
|
|
||||||
|
type ServerRepository struct {
|
||||||
|
pool *pgxpool.Pool
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewServerRepository(pool *pgxpool.Pool) *ServerRepository {
|
||||||
|
return &ServerRepository{pool: pool}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *ServerRepository) Count(ctx context.Context) (int64, error) {
|
||||||
|
var count int64
|
||||||
|
err := r.pool.QueryRow(ctx, `SELECT COUNT(*) FROM servers`).Scan(&count)
|
||||||
|
return count, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *ServerRepository) CreateDefault(ctx context.Context, name, hostname, ip string) (*models.Server, error) {
|
||||||
|
const q = `
|
||||||
|
INSERT INTO servers (name, hostname, ip_address, status)
|
||||||
|
VALUES ($1, $2, $3::inet, 'online')
|
||||||
|
RETURNING id, uuid, name, hostname, ip_address::text, ssh_port, status,
|
||||||
|
agent_version, os_info, resources, settings, last_seen_at, created_at, updated_at
|
||||||
|
`
|
||||||
|
var s models.Server
|
||||||
|
err := r.pool.QueryRow(ctx, q, name, hostname, ip).Scan(
|
||||||
|
&s.ID, &s.UUID, &s.Name, &s.Hostname, &s.IPAddress, &s.SSHPort, &s.Status,
|
||||||
|
&s.AgentVersion, &s.OSInfo, &s.Resources, &s.Settings, &s.LastSeenAt, &s.CreatedAt, &s.UpdatedAt,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("create server: %w", err)
|
||||||
|
}
|
||||||
|
return &s, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *ServerRepository) GetFirst(ctx context.Context) (*models.Server, error) {
|
||||||
|
const q = `
|
||||||
|
SELECT id, uuid, name, hostname, ip_address::text, ssh_port, status,
|
||||||
|
agent_version, os_info, resources, settings, last_seen_at, created_at, updated_at
|
||||||
|
FROM servers ORDER BY id LIMIT 1
|
||||||
|
`
|
||||||
|
var s models.Server
|
||||||
|
err := r.pool.QueryRow(ctx, q).Scan(
|
||||||
|
&s.ID, &s.UUID, &s.Name, &s.Hostname, &s.IPAddress, &s.SSHPort, &s.Status,
|
||||||
|
&s.AgentVersion, &s.OSInfo, &s.Resources, &s.Settings, &s.LastSeenAt, &s.CreatedAt, &s.UpdatedAt,
|
||||||
|
)
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return nil, ErrServerNotFound
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("get first server: %w", err)
|
||||||
|
}
|
||||||
|
return &s, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *ServerRepository) GetByID(ctx context.Context, id int64) (*models.Server, error) {
|
||||||
|
const q = `
|
||||||
|
SELECT id, uuid, name, hostname, ip_address::text, ssh_port, status,
|
||||||
|
agent_version, os_info, resources, settings, last_seen_at, created_at, updated_at
|
||||||
|
FROM servers WHERE id = $1
|
||||||
|
`
|
||||||
|
var s models.Server
|
||||||
|
err := r.pool.QueryRow(ctx, q, id).Scan(
|
||||||
|
&s.ID, &s.UUID, &s.Name, &s.Hostname, &s.IPAddress, &s.SSHPort, &s.Status,
|
||||||
|
&s.AgentVersion, &s.OSInfo, &s.Resources, &s.Settings, &s.LastSeenAt, &s.CreatedAt, &s.UpdatedAt,
|
||||||
|
)
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return nil, ErrServerNotFound
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("get server: %w", err)
|
||||||
|
}
|
||||||
|
return &s, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *ServerRepository) GrantAccess(ctx context.Context, userID, serverID int64, canManage bool) error {
|
||||||
|
const q = `
|
||||||
|
INSERT INTO server_access (user_id, server_id, can_manage)
|
||||||
|
VALUES ($1, $2, $3)
|
||||||
|
ON CONFLICT (user_id, server_id) DO NOTHING
|
||||||
|
`
|
||||||
|
_, err := r.pool.Exec(ctx, q, userID, serverID, canManage)
|
||||||
|
return err
|
||||||
|
}
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
package repository
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
"github.com/panelhosting/panel/internal/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
var ErrUserNotFound = errors.New("user not found")
|
||||||
|
|
||||||
|
func (r *UserRepository) GetByUsername(ctx context.Context, username string) (*models.User, error) {
|
||||||
|
const q = `
|
||||||
|
SELECT id, uuid, email, username, password_hash, role, status, locale, timezone, last_login_at, created_at, updated_at
|
||||||
|
FROM users WHERE username = $1
|
||||||
|
`
|
||||||
|
var u models.User
|
||||||
|
err := r.pool.QueryRow(ctx, q, username).Scan(
|
||||||
|
&u.ID, &u.UUID, &u.Email, &u.Username, &u.PasswordHash, &u.Role, &u.Status,
|
||||||
|
&u.Locale, &u.Timezone, &u.LastLoginAt, &u.CreatedAt, &u.UpdatedAt,
|
||||||
|
)
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return nil, ErrUserNotFound
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("get user by username: %w", err)
|
||||||
|
}
|
||||||
|
return &u, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *UserRepository) GetByID(ctx context.Context, id int64) (*models.User, error) {
|
||||||
|
const q = `
|
||||||
|
SELECT id, uuid, email, username, password_hash, role, status, locale, timezone, last_login_at, created_at, updated_at
|
||||||
|
FROM users WHERE id = $1
|
||||||
|
`
|
||||||
|
var u models.User
|
||||||
|
err := r.pool.QueryRow(ctx, q, id).Scan(
|
||||||
|
&u.ID, &u.UUID, &u.Email, &u.Username, &u.PasswordHash, &u.Role, &u.Status,
|
||||||
|
&u.Locale, &u.Timezone, &u.LastLoginAt, &u.CreatedAt, &u.UpdatedAt,
|
||||||
|
)
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return nil, ErrUserNotFound
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("get user by id: %w", err)
|
||||||
|
}
|
||||||
|
return &u, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *UserRepository) UpdateLastLogin(ctx context.Context, id int64) error {
|
||||||
|
_, err := r.pool.Exec(ctx, `UPDATE users SET last_login_at = now() WHERE id = $1`, id)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
type SessionRepository struct {
|
||||||
|
pool *pgxpool.Pool
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewSessionRepository(pool *pgxpool.Pool) *SessionRepository {
|
||||||
|
return &SessionRepository{pool: pool}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *SessionRepository) Create(ctx context.Context, userID int64, tokenHash string, ip, userAgent string, expiresAt time.Time) error {
|
||||||
|
const q = `
|
||||||
|
INSERT INTO sessions (user_id, token_hash, ip_address, user_agent, expires_at)
|
||||||
|
VALUES ($1, $2, NULLIF($3, '')::inet, NULLIF($4, ''), $5)
|
||||||
|
`
|
||||||
|
_, err := r.pool.Exec(ctx, q, userID, tokenHash, ip, userAgent, expiresAt)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("create session: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *SessionRepository) GetUserIDByToken(ctx context.Context, tokenHash string) (int64, error) {
|
||||||
|
const q = `
|
||||||
|
SELECT user_id FROM sessions
|
||||||
|
WHERE token_hash = $1 AND expires_at > now()
|
||||||
|
`
|
||||||
|
var userID int64
|
||||||
|
err := r.pool.QueryRow(ctx, q, tokenHash).Scan(&userID)
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return 0, ErrUserNotFound
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("get session: %w", err)
|
||||||
|
}
|
||||||
|
return userID, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *SessionRepository) DeleteByToken(ctx context.Context, tokenHash string) error {
|
||||||
|
_, err := r.pool.Exec(ctx, `DELETE FROM sessions WHERE token_hash = $1`, tokenHash)
|
||||||
|
return err
|
||||||
|
}
|
||||||
@@ -0,0 +1,156 @@
|
|||||||
|
package repository
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
"github.com/panelhosting/panel/internal/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
type SiteRepository struct {
|
||||||
|
pool *pgxpool.Pool
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewSiteRepository(pool *pgxpool.Pool) *SiteRepository {
|
||||||
|
return &SiteRepository{pool: pool}
|
||||||
|
}
|
||||||
|
|
||||||
|
type SiteWithDomain struct {
|
||||||
|
models.Site
|
||||||
|
PrimaryDomain string `json:"primary_domain"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *SiteRepository) Create(ctx context.Context, serverID, ownerID int64, name, documentRoot, phpVersion, domain string) (*SiteWithDomain, error) {
|
||||||
|
tx, err := r.pool.Begin(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer tx.Rollback(ctx)
|
||||||
|
|
||||||
|
const siteQ = `
|
||||||
|
INSERT INTO sites (server_id, owner_id, name, document_root, php_version, status)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, 'active')
|
||||||
|
RETURNING id, uuid, server_id, owner_id, name, document_root, php_version, status,
|
||||||
|
settings, disk_quota_mb, created_at, updated_at
|
||||||
|
`
|
||||||
|
var s models.Site
|
||||||
|
var phpVer *string
|
||||||
|
if phpVersion != "" {
|
||||||
|
phpVer = &phpVersion
|
||||||
|
}
|
||||||
|
err = tx.QueryRow(ctx, siteQ, serverID, ownerID, name, documentRoot, phpVer).Scan(
|
||||||
|
&s.ID, &s.UUID, &s.ServerID, &s.OwnerID, &s.Name, &s.DocumentRoot, &s.PHPVersion, &s.Status,
|
||||||
|
&s.Settings, &s.DiskQuotaMB, &s.CreatedAt, &s.UpdatedAt,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("create site: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
const domainQ = `
|
||||||
|
INSERT INTO domains (site_id, domain, is_primary, ssl_enabled)
|
||||||
|
VALUES ($1, $2, true, true)
|
||||||
|
`
|
||||||
|
if _, err = tx.Exec(ctx, domainQ, s.ID, domain); err != nil {
|
||||||
|
return nil, fmt.Errorf("create domain: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err = tx.Commit(ctx); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return &SiteWithDomain{Site: s, PrimaryDomain: domain}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *SiteRepository) ListForUser(ctx context.Context, userID int64, isAdmin bool) ([]SiteWithDomain, error) {
|
||||||
|
var q string
|
||||||
|
var args []any
|
||||||
|
if isAdmin {
|
||||||
|
q = siteListQuery + ` ORDER BY s.created_at DESC`
|
||||||
|
} else {
|
||||||
|
q = siteListQuery + ` WHERE s.owner_id = $1 ORDER BY s.created_at DESC`
|
||||||
|
args = append(args, userID)
|
||||||
|
}
|
||||||
|
|
||||||
|
rows, err := r.pool.Query(ctx, q, args...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("list sites: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
return scanSiteList(rows)
|
||||||
|
}
|
||||||
|
|
||||||
|
const siteListQuery = `
|
||||||
|
SELECT s.id, s.uuid, s.server_id, s.owner_id, s.name, s.document_root, s.php_version, s.status,
|
||||||
|
s.settings, s.disk_quota_mb, s.created_at, s.updated_at,
|
||||||
|
COALESCE(d.domain::text, '')
|
||||||
|
FROM sites s
|
||||||
|
LEFT JOIN domains d ON d.site_id = s.id AND d.is_primary = true
|
||||||
|
`
|
||||||
|
|
||||||
|
func scanSiteList(rows pgx.Rows) ([]SiteWithDomain, error) {
|
||||||
|
var list []SiteWithDomain
|
||||||
|
for rows.Next() {
|
||||||
|
var item SiteWithDomain
|
||||||
|
err := rows.Scan(
|
||||||
|
&item.ID, &item.UUID, &item.ServerID, &item.OwnerID, &item.Name, &item.DocumentRoot,
|
||||||
|
&item.PHPVersion, &item.Status, &item.Settings, &item.DiskQuotaMB,
|
||||||
|
&item.CreatedAt, &item.UpdatedAt, &item.PrimaryDomain,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
list = append(list, item)
|
||||||
|
}
|
||||||
|
return list, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
type PHPVersionRepository struct {
|
||||||
|
pool *pgxpool.Pool
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewPHPVersionRepository(pool *pgxpool.Pool) *PHPVersionRepository {
|
||||||
|
return &PHPVersionRepository{pool: pool}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *PHPVersionRepository) ListActive(ctx context.Context) ([]string, error) {
|
||||||
|
rows, err := r.pool.Query(ctx, `
|
||||||
|
SELECT version FROM php_versions WHERE is_active = true ORDER BY sort_order
|
||||||
|
`)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return defaultPHPVersions(), nil
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var versions []string
|
||||||
|
for rows.Next() {
|
||||||
|
var v string
|
||||||
|
if err := rows.Scan(&v); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
versions = append(versions, v)
|
||||||
|
}
|
||||||
|
if len(versions) == 0 {
|
||||||
|
return defaultPHPVersions(), nil
|
||||||
|
}
|
||||||
|
return versions, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *PHPVersionRepository) IsActive(ctx context.Context, version string) (bool, error) {
|
||||||
|
var active bool
|
||||||
|
err := r.pool.QueryRow(ctx, `SELECT is_active FROM php_versions WHERE version = $1`, version).Scan(&active)
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
return active, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func defaultPHPVersions() []string {
|
||||||
|
return []string{"8.1", "8.2", "8.3", "8.4"}
|
||||||
|
}
|
||||||
@@ -8,19 +8,35 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/jackc/pgx/v5/pgxpool"
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
"github.com/panelhosting/panel/internal/authsvc"
|
||||||
|
"github.com/panelhosting/panel/internal/bootstrap"
|
||||||
|
"github.com/panelhosting/panel/internal/config"
|
||||||
"github.com/panelhosting/panel/internal/handler"
|
"github.com/panelhosting/panel/internal/handler"
|
||||||
|
"github.com/panelhosting/panel/internal/middleware"
|
||||||
"github.com/panelhosting/panel/internal/repository"
|
"github.com/panelhosting/panel/internal/repository"
|
||||||
"github.com/panelhosting/panel/internal/setup"
|
"github.com/panelhosting/panel/internal/setup"
|
||||||
|
"github.com/panelhosting/panel/internal/sitesvc"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Server struct {
|
type Server struct {
|
||||||
httpServer *http.Server
|
httpServer *http.Server
|
||||||
}
|
}
|
||||||
|
|
||||||
func New(pool *pgxpool.Pool, addr string) (*Server, error) {
|
func New(pool *pgxpool.Pool, cfg *config.Config) (*Server, error) {
|
||||||
users := repository.NewUserRepository(pool)
|
users := repository.NewUserRepository(pool)
|
||||||
setupSvc := setup.NewService(users)
|
servers := repository.NewServerRepository(pool)
|
||||||
|
sessions := repository.NewSessionRepository(pool)
|
||||||
|
sites := repository.NewSiteRepository(pool)
|
||||||
|
phpVers := repository.NewPHPVersionRepository(pool)
|
||||||
|
|
||||||
|
setupSvc := setup.NewService(users, servers, cfg)
|
||||||
|
authSvc := authsvc.NewService(users, sessions, cfg.SessionTTLHours)
|
||||||
|
siteSvc := sitesvc.NewService(sites, servers, phpVers)
|
||||||
|
|
||||||
setupHandler := handler.NewSetupHandler(setupSvc)
|
setupHandler := handler.NewSetupHandler(setupSvc)
|
||||||
|
authHandler := handler.NewAuthHandler(authSvc, cfg)
|
||||||
|
siteHandler := handler.NewSiteHandler(siteSvc)
|
||||||
|
authMW := middleware.NewAuth(authSvc, cfg)
|
||||||
|
|
||||||
setupRequired, err := setupSvc.Status(context.Background())
|
setupRequired, err := setupSvc.Status(context.Background())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -30,12 +46,27 @@ func New(pool *pgxpool.Pool, addr string) (*Server, error) {
|
|||||||
log.Println("setup required: open / to create first admin")
|
log.Println("setup required: open / to create first admin")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if err := bootstrap.EnsureDefaultServer(context.Background(), servers, cfg); err != nil {
|
||||||
|
return nil, fmt.Errorf("bootstrap server: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
mux := http.NewServeMux()
|
mux := http.NewServeMux()
|
||||||
mux.HandleFunc("GET /health", handler.Health)
|
mux.HandleFunc("GET /health", handler.Health)
|
||||||
|
|
||||||
mux.HandleFunc("GET /api/v1/setup/status", setupHandler.Status)
|
mux.HandleFunc("GET /api/v1/setup/status", setupHandler.Status)
|
||||||
mux.HandleFunc("POST /api/v1/setup/register", setupHandler.Register)
|
mux.HandleFunc("POST /api/v1/setup/register", setupHandler.Register)
|
||||||
mux.HandleFunc("GET /{$}", handler.IndexPage(setupSvc))
|
|
||||||
|
|
||||||
|
mux.HandleFunc("POST /api/v1/auth/login", authHandler.Login)
|
||||||
|
mux.HandleFunc("POST /api/v1/auth/logout", authMW.Require(authHandler.Logout))
|
||||||
|
mux.HandleFunc("GET /api/v1/auth/me", authMW.Require(authHandler.Me))
|
||||||
|
|
||||||
|
mux.HandleFunc("GET /api/v1/php-versions", siteHandler.PHPVersions)
|
||||||
|
mux.HandleFunc("GET /api/v1/sites", authMW.Require(siteHandler.List))
|
||||||
|
mux.HandleFunc("POST /api/v1/sites", authMW.Require(siteHandler.Create))
|
||||||
|
|
||||||
|
mux.HandleFunc("GET /{$}", authMW.Optional(handler.IndexPage(setupSvc)))
|
||||||
|
|
||||||
|
addr := fmt.Sprintf(":%s", cfg.HTTPPort)
|
||||||
return &Server{
|
return &Server{
|
||||||
httpServer: &http.Server{
|
httpServer: &http.Server{
|
||||||
Addr: addr,
|
Addr: addr,
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
|
|
||||||
"github.com/panelhosting/panel/internal/auth"
|
"github.com/panelhosting/panel/internal/auth"
|
||||||
|
"github.com/panelhosting/panel/internal/bootstrap"
|
||||||
|
"github.com/panelhosting/panel/internal/config"
|
||||||
"github.com/panelhosting/panel/internal/models"
|
"github.com/panelhosting/panel/internal/models"
|
||||||
"github.com/panelhosting/panel/internal/repository"
|
"github.com/panelhosting/panel/internal/repository"
|
||||||
)
|
)
|
||||||
@@ -14,10 +16,12 @@ var ErrSetupCompleted = errors.New("setup already completed")
|
|||||||
|
|
||||||
type Service struct {
|
type Service struct {
|
||||||
users *repository.UserRepository
|
users *repository.UserRepository
|
||||||
|
servers *repository.ServerRepository
|
||||||
|
cfg *config.Config
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewService(users *repository.UserRepository) *Service {
|
func NewService(users *repository.UserRepository, servers *repository.ServerRepository, cfg *config.Config) *Service {
|
||||||
return &Service{users: users}
|
return &Service{users: users, servers: servers, cfg: cfg}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Service) Status(ctx context.Context) (bool, error) {
|
func (s *Service) Status(ctx context.Context) (bool, error) {
|
||||||
@@ -59,5 +63,13 @@ func (s *Service) RegisterFirstAdmin(ctx context.Context, in RegisterInput) (*mo
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("register admin: %w", err)
|
return nil, fmt.Errorf("register admin: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if err := bootstrap.EnsureDefaultServer(ctx, s.servers, s.cfg); err != nil {
|
||||||
|
return nil, fmt.Errorf("bootstrap server: %w", err)
|
||||||
|
}
|
||||||
|
if err := bootstrap.GrantServerAccess(ctx, s.servers, user.ID); err != nil {
|
||||||
|
return nil, fmt.Errorf("grant server access: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
return user, nil
|
return user, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,90 @@
|
|||||||
|
package sitesvc
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/panelhosting/panel/internal/models"
|
||||||
|
"github.com/panelhosting/panel/internal/repository"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
ErrInvalidSiteName = errors.New("site name must be 3-64 chars: lowercase letters, digits, hyphen")
|
||||||
|
ErrInvalidDomain = errors.New("invalid domain")
|
||||||
|
ErrInvalidPHPVersion = errors.New("unsupported php version")
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
siteNameRegex = regexp.MustCompile(`^[a-z0-9][a-z0-9-]{1,62}[a-z0-9]$`)
|
||||||
|
domainRegex = regexp.MustCompile(`^(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,}$`)
|
||||||
|
)
|
||||||
|
|
||||||
|
type Service struct {
|
||||||
|
sites *repository.SiteRepository
|
||||||
|
servers *repository.ServerRepository
|
||||||
|
phpVers *repository.PHPVersionRepository
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewService(sites *repository.SiteRepository, servers *repository.ServerRepository, php *repository.PHPVersionRepository) *Service {
|
||||||
|
return &Service{sites: sites, servers: servers, phpVers: php}
|
||||||
|
}
|
||||||
|
|
||||||
|
type CreateInput struct {
|
||||||
|
Name string
|
||||||
|
Domain string
|
||||||
|
PHPVersion string
|
||||||
|
ServerID int64
|
||||||
|
OwnerID int64
|
||||||
|
IsAdmin bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) List(ctx context.Context, user *models.User, isAdmin bool) ([]repository.SiteWithDomain, error) {
|
||||||
|
return s.sites.ListForUser(ctx, user.ID, isAdmin)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) ListPHPVersions(ctx context.Context) ([]string, error) {
|
||||||
|
return s.phpVers.ListActive(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) Create(ctx context.Context, in CreateInput) (*repository.SiteWithDomain, error) {
|
||||||
|
name := strings.ToLower(strings.TrimSpace(in.Name))
|
||||||
|
domain := strings.ToLower(strings.TrimSpace(in.Domain))
|
||||||
|
phpVersion := strings.TrimSpace(in.PHPVersion)
|
||||||
|
|
||||||
|
if !siteNameRegex.MatchString(name) {
|
||||||
|
return nil, ErrInvalidSiteName
|
||||||
|
}
|
||||||
|
if !domainRegex.MatchString(domain) {
|
||||||
|
return nil, ErrInvalidDomain
|
||||||
|
}
|
||||||
|
if phpVersion == "" {
|
||||||
|
return nil, ErrInvalidPHPVersion
|
||||||
|
}
|
||||||
|
|
||||||
|
active, err := s.phpVers.IsActive(ctx, phpVersion)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if !active {
|
||||||
|
return nil, ErrInvalidPHPVersion
|
||||||
|
}
|
||||||
|
|
||||||
|
serverID := in.ServerID
|
||||||
|
if serverID == 0 {
|
||||||
|
srv, err := s.servers.GetFirst(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("no server available: %w", err)
|
||||||
|
}
|
||||||
|
serverID = srv.ID
|
||||||
|
} else {
|
||||||
|
if _, err := s.servers.GetByID(ctx, serverID); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
documentRoot := fmt.Sprintf("/var/www/%s/public", name)
|
||||||
|
return s.sites.Create(ctx, serverID, in.OwnerID, name, documentRoot, phpVersion, domain)
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
DROP TABLE IF EXISTS php_versions;
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
-- PHP versions available for sites
|
||||||
|
|
||||||
|
CREATE TABLE php_versions (
|
||||||
|
version VARCHAR(16) PRIMARY KEY,
|
||||||
|
is_active BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
sort_order INT NOT NULL DEFAULT 0
|
||||||
|
);
|
||||||
|
|
||||||
|
INSERT INTO php_versions (version, is_active, sort_order) VALUES
|
||||||
|
('7.4', false, 1),
|
||||||
|
('8.0', false, 2),
|
||||||
|
('8.1', true, 3),
|
||||||
|
('8.2', true, 4),
|
||||||
|
('8.3', true, 5),
|
||||||
|
('8.4', true, 6);
|
||||||
Reference in New Issue
Block a user