diff --git a/.env.example b/.env.example
index 23bb369..99fb310 100644
--- a/.env.example
+++ b/.env.example
@@ -11,3 +11,8 @@ DATABASE_URL_DOCKER=postgres://panel:panel_secret@postgres:5432/panel?sslmode=di
# HTTP API панели
HTTP_PORT=8000
+
+# Локальный сервер (создаётся при первичной настройке)
+DEFAULT_SERVER_NAME=local
+DEFAULT_SERVER_HOSTNAME=server1
+DEFAULT_SERVER_IP=127.0.0.1
diff --git a/README.md b/README.md
index 0a30c16..0866676 100644
--- a/README.md
+++ b/README.md
@@ -67,21 +67,35 @@ export HTTP_PORT=8000
go run ./cmd/panel
```
-Откройте `http://localhost:8000` — если админ ещё не создан, появится форма регистрации.
+Откройте `http://localhost:8000` — если админ ещё не создан, появится форма регистрации. После входа — панель управления сайтами.
-API:
-- `GET /health` — healthcheck
-- `GET /api/v1/setup/status` — `{"setup_required": true|false}`
-- `POST /api/v1/setup/register` — создание первого суперадмина (только пока нет пользователей)
+### API
+
+**Авторизация**
+- `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
{
- "email": "admin@example.com",
- "username": "admin",
- "password": "secret123"
+ "name": "mysite",
+ "domain": "example.com",
+ "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` — создание первого суперадмина (только пока нет пользователей)
+
## Структура проекта
```
diff --git a/cmd/panel/main.go b/cmd/panel/main.go
index 71521c2..a6e8f19 100644
--- a/cmd/panel/main.go
+++ b/cmd/panel/main.go
@@ -3,7 +3,6 @@ package main
import (
"context"
"errors"
- "fmt"
"log"
"net/http"
"os"
@@ -31,8 +30,7 @@ func main() {
}
defer pool.Close()
- addr := fmt.Sprintf(":%s", cfg.HTTPPort)
- srv, err := server.New(pool, addr)
+ srv, err := server.New(pool, cfg)
if err != nil {
log.Fatal(err)
}
diff --git a/docker-compose.yml b/docker-compose.yml
index f3b1e14..2863e44 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -43,6 +43,9 @@ services:
environment:
DATABASE_URL: ${DATABASE_URL_DOCKER:-postgres://panel:panel_secret@postgres:5432/panel?sslmode=disable}
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:
- "${HTTP_PORT:-8000}:8000"
depends_on:
diff --git a/internal/auth/session.go b/internal/auth/session.go
new file mode 100644
index 0000000..c797377
--- /dev/null
+++ b/internal/auth/session.go
@@ -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[:])
+}
diff --git a/internal/authsvc/service.go b/internal/authsvc/service.go
new file mode 100644
index 0000000..2477ade
--- /dev/null
+++ b/internal/authsvc/service.go
@@ -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
+}
diff --git a/internal/bootstrap/server.go b/internal/bootstrap/server.go
new file mode 100644
index 0000000..99287b8
--- /dev/null
+++ b/internal/bootstrap/server.go
@@ -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)
+}
diff --git a/internal/config/config.go b/internal/config/config.go
index 8a37c19..ff673b1 100644
--- a/internal/config/config.go
+++ b/internal/config/config.go
@@ -6,8 +6,13 @@ import (
)
type Config struct {
- DatabaseURL string
- HTTPPort string
+ DatabaseURL string
+ HTTPPort string
+ DefaultServerName string
+ DefaultServerHost string
+ DefaultServerIP string
+ SessionCookieName string
+ SessionTTLHours int
}
func Load() (*Config, error) {
@@ -21,8 +26,33 @@ func Load() (*Config, error) {
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{
- DatabaseURL: url,
- HTTPPort: port,
+ DatabaseURL: url,
+ HTTPPort: port,
+ DefaultServerName: serverName,
+ DefaultServerHost: serverHost,
+ DefaultServerIP: serverIP,
+ SessionCookieName: cookieName,
+ SessionTTLHours: 168, // 7 days
}, nil
}
diff --git a/internal/handler/auth.go b/internal/handler/auth.go
new file mode 100644
index 0000000..e5542c1
--- /dev/null
+++ b/internal/handler/auth.go
@@ -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})
+}
diff --git a/internal/handler/health.go b/internal/handler/health.go
index f6d78e9..b88ab73 100644
--- a/internal/handler/health.go
+++ b/internal/handler/health.go
@@ -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 = `
-
-
-
-
- PanelHosting — первичная настройка
-
-
-
-
-
PanelHosting
-
Создайте учётную запись суперадминистратора
-
-
-
-
-
-`
+
+PanelHosting — настройка
+
+
PanelHosting
Создайте суперадминистратора
+
+
+`
+
+const loginPageHTML = `
+
+PanelHosting — вход
+
+
PanelHosting
Войдите в панель
+
+
+`
+
+func dashboardPageHTML(username string) string {
+ return fmt.Sprintf(`
+
+PanelHosting
+
+`, baseCSS, username)
+}
diff --git a/internal/handler/site.go b/internal/handler/site.go
new file mode 100644
index 0000000..1fc1a51
--- /dev/null
+++ b/internal/handler/site.go
@@ -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})
+}
diff --git a/internal/middleware/auth.go b/internal/middleware/auth.go
new file mode 100644
index 0000000..ec3676b
--- /dev/null
+++ b/internal/middleware/auth.go
@@ -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
+}
diff --git a/internal/repository/server.go b/internal/repository/server.go
new file mode 100644
index 0000000..1fdc769
--- /dev/null
+++ b/internal/repository/server.go
@@ -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
+}
diff --git a/internal/repository/session.go b/internal/repository/session.go
new file mode 100644
index 0000000..fdb51f6
--- /dev/null
+++ b/internal/repository/session.go
@@ -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
+}
diff --git a/internal/repository/site.go b/internal/repository/site.go
new file mode 100644
index 0000000..da70521
--- /dev/null
+++ b/internal/repository/site.go
@@ -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"}
+}
diff --git a/internal/server/server.go b/internal/server/server.go
index 0d30085..0921672 100644
--- a/internal/server/server.go
+++ b/internal/server/server.go
@@ -8,19 +8,35 @@ import (
"time"
"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/middleware"
"github.com/panelhosting/panel/internal/repository"
"github.com/panelhosting/panel/internal/setup"
+ "github.com/panelhosting/panel/internal/sitesvc"
)
type Server struct {
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)
- 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)
+ authHandler := handler.NewAuthHandler(authSvc, cfg)
+ siteHandler := handler.NewSiteHandler(siteSvc)
+ authMW := middleware.NewAuth(authSvc, cfg)
setupRequired, err := setupSvc.Status(context.Background())
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")
}
+ if err := bootstrap.EnsureDefaultServer(context.Background(), servers, cfg); err != nil {
+ return nil, fmt.Errorf("bootstrap server: %w", err)
+ }
+
mux := http.NewServeMux()
mux.HandleFunc("GET /health", handler.Health)
+
mux.HandleFunc("GET /api/v1/setup/status", setupHandler.Status)
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{
httpServer: &http.Server{
Addr: addr,
diff --git a/internal/setup/service.go b/internal/setup/service.go
index 1a2b839..576dee3 100644
--- a/internal/setup/service.go
+++ b/internal/setup/service.go
@@ -6,6 +6,8 @@ import (
"fmt"
"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/repository"
)
@@ -13,11 +15,13 @@ import (
var ErrSetupCompleted = errors.New("setup already completed")
type Service struct {
- users *repository.UserRepository
+ users *repository.UserRepository
+ servers *repository.ServerRepository
+ cfg *config.Config
}
-func NewService(users *repository.UserRepository) *Service {
- return &Service{users: users}
+func NewService(users *repository.UserRepository, servers *repository.ServerRepository, cfg *config.Config) *Service {
+ return &Service{users: users, servers: servers, cfg: cfg}
}
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 {
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
}
diff --git a/internal/sitesvc/service.go b/internal/sitesvc/service.go
new file mode 100644
index 0000000..e9a08ed
--- /dev/null
+++ b/internal/sitesvc/service.go
@@ -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)
+}
diff --git a/migrations/000002_php_versions.down.sql b/migrations/000002_php_versions.down.sql
new file mode 100644
index 0000000..8707df5
--- /dev/null
+++ b/migrations/000002_php_versions.down.sql
@@ -0,0 +1 @@
+DROP TABLE IF EXISTS php_versions;
diff --git a/migrations/000002_php_versions.up.sql b/migrations/000002_php_versions.up.sql
new file mode 100644
index 0000000..51f1ade
--- /dev/null
+++ b/migrations/000002_php_versions.up.sql
@@ -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);