diff --git a/.env.example b/.env.example index 391b24c..23bb369 100644 --- a/.env.example +++ b/.env.example @@ -8,3 +8,6 @@ DATABASE_URL=postgres://panel:panel_secret@localhost:5432/panel?sslmode=disable # Для docker compose migrate (внутри docker-сети) DATABASE_URL_DOCKER=postgres://panel:panel_secret@postgres:5432/panel?sslmode=disable + +# HTTP API панели +HTTP_PORT=8000 diff --git a/Dockerfile.panel b/Dockerfile.panel new file mode 100644 index 0000000..5671dc4 --- /dev/null +++ b/Dockerfile.panel @@ -0,0 +1,19 @@ +FROM golang:1.23-alpine AS builder + +WORKDIR /app + +COPY go.mod go.sum ./ +RUN go mod download + +COPY . . +RUN CGO_ENABLED=0 go build -o /panel ./cmd/panel + +FROM alpine:3.20 + +RUN apk add --no-cache ca-certificates + +COPY --from=builder /panel /panel + +EXPOSE 8000 + +ENTRYPOINT ["/panel"] diff --git a/README.md b/README.md index c707024..0a30c16 100644 --- a/README.md +++ b/README.md @@ -51,12 +51,37 @@ export DATABASE_URL=postgres://panel:panel_secret@localhost:5432/panel?sslmode=d go run ./cmd/migrate ``` -### 4. Проверка подключения +### 4. Запуск панели + +Через Docker (порт **8000**): ```bash +docker compose up -d panel +``` + +Или локально: + +```bash +export DATABASE_URL=postgres://panel:panel_secret@localhost:5432/panel?sslmode=disable +export HTTP_PORT=8000 go run ./cmd/panel ``` +Откройте `http://localhost:8000` — если админ ещё не создан, появится форма регистрации. + +API: +- `GET /health` — healthcheck +- `GET /api/v1/setup/status` — `{"setup_required": true|false}` +- `POST /api/v1/setup/register` — создание первого суперадмина (только пока нет пользователей) + +```json +{ + "email": "admin@example.com", + "username": "admin", + "password": "secret123" +} +``` + ## Структура проекта ``` diff --git a/cmd/panel/main.go b/cmd/panel/main.go index e4cabb1..71521c2 100644 --- a/cmd/panel/main.go +++ b/cmd/panel/main.go @@ -2,13 +2,18 @@ package main import ( "context" + "errors" + "fmt" "log" + "net/http" "os" "os/signal" "syscall" + "time" "github.com/panelhosting/panel/internal/config" "github.com/panelhosting/panel/internal/database" + "github.com/panelhosting/panel/internal/server" ) func main() { @@ -26,6 +31,24 @@ func main() { } defer pool.Close() - log.Println("panel database ready (API coming soon)") + addr := fmt.Sprintf(":%s", cfg.HTTPPort) + srv, err := server.New(pool, addr) + if err != nil { + log.Fatal(err) + } + + go func() { + if err := srv.Start(); err != nil && !errors.Is(err, http.ErrServerClosed) { + log.Fatal(err) + } + }() + <-ctx.Done() + log.Println("shutting down...") + + shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + if err := srv.Shutdown(shutdownCtx); err != nil { + log.Fatal(err) + } } diff --git a/docker-compose.yml b/docker-compose.yml index fc5a4e3..f3b1e14 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -33,5 +33,21 @@ services: profiles: - migrate + panel: + build: + context: . + dockerfile: Dockerfile.panel + container_name: panel-app + restart: unless-stopped + env_file: .env + environment: + DATABASE_URL: ${DATABASE_URL_DOCKER:-postgres://panel:panel_secret@postgres:5432/panel?sslmode=disable} + HTTP_PORT: "8000" + ports: + - "${HTTP_PORT:-8000}:8000" + depends_on: + postgres: + condition: service_healthy + volumes: postgres_data: diff --git a/go.mod b/go.mod index e6b8d26..8bd69ee 100644 --- a/go.mod +++ b/go.mod @@ -6,6 +6,7 @@ require ( github.com/golang-migrate/migrate/v4 v4.18.2 github.com/google/uuid v1.6.0 github.com/jackc/pgx/v5 v5.7.2 + golang.org/x/crypto v0.31.0 ) require ( @@ -16,7 +17,6 @@ require ( github.com/jackc/puddle/v2 v2.2.2 // indirect github.com/lib/pq v1.10.9 // indirect go.uber.org/atomic v1.7.0 // indirect - golang.org/x/crypto v0.31.0 // indirect golang.org/x/sync v0.10.0 // indirect golang.org/x/text v0.21.0 // indirect ) diff --git a/internal/auth/password.go b/internal/auth/password.go new file mode 100644 index 0000000..5d7fdd0 --- /dev/null +++ b/internal/auth/password.go @@ -0,0 +1,45 @@ +package auth + +import ( + "errors" + "regexp" + "unicode/utf8" + + "golang.org/x/crypto/bcrypt" +) + +var ( + ErrPasswordTooShort = errors.New("password must be at least 8 characters") + ErrInvalidEmail = errors.New("invalid email") + ErrInvalidUsername = errors.New("username must be 3-64 characters: letters, digits, underscore") +) + +var ( + emailRegex = regexp.MustCompile(`^[^\s@]+@[^\s@]+\.[^\s@]+$`) + usernameRegex = regexp.MustCompile(`^[a-zA-Z0-9_]{3,64}$`) +) + +func HashPassword(password string) (string, error) { + if utf8.RuneCountInString(password) < 8 { + return "", ErrPasswordTooShort + } + hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) + if err != nil { + return "", err + } + return string(hash), nil +} + +func ValidateEmail(email string) error { + if !emailRegex.MatchString(email) { + return ErrInvalidEmail + } + return nil +} + +func ValidateUsername(username string) error { + if !usernameRegex.MatchString(username) { + return ErrInvalidUsername + } + return nil +} diff --git a/internal/config/config.go b/internal/config/config.go index 935ab12..8a37c19 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -7,6 +7,7 @@ import ( type Config struct { DatabaseURL string + HTTPPort string } func Load() (*Config, error) { @@ -14,5 +15,14 @@ func Load() (*Config, error) { if url == "" { return nil, fmt.Errorf("DATABASE_URL is required") } - return &Config{DatabaseURL: url}, nil + + port := os.Getenv("HTTP_PORT") + if port == "" { + port = "8000" + } + + return &Config{ + DatabaseURL: url, + HTTPPort: port, + }, nil } diff --git a/internal/handler/health.go b/internal/handler/health.go new file mode 100644 index 0000000..f6d78e9 --- /dev/null +++ b/internal/handler/health.go @@ -0,0 +1,118 @@ +package handler + +import ( + "net/http" + + "github.com/panelhosting/panel/internal/setup" +) + +func Health(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"status":"ok"}`)) +} + +func IndexPage(svc *setup.Service) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + required, err := svc.Status(r.Context()) + if err != nil { + writeError(w, http.StatusInternalServerError, "internal error") + return + } + index(required)(w, r) + } +} + +func index(setupRequired bool) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if setupRequired { + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(setupPageHTML)) + return + } + writeJSON(w, http.StatusOK, map[string]string{ + "name": "PanelHosting", + "version": "0.1.0", + "status": "running", + }) + } +} + +const setupPageHTML = ` + + + + + PanelHosting — первичная настройка + + + +
+

PanelHosting

+

Создайте учётную запись суперадминистратора

+
+ + + + + + + +
+
+
+ + +` diff --git a/internal/handler/setup.go b/internal/handler/setup.go new file mode 100644 index 0000000..de324c6 --- /dev/null +++ b/internal/handler/setup.go @@ -0,0 +1,65 @@ +package handler + +import ( + "encoding/json" + "errors" + "net/http" + + "github.com/panelhosting/panel/internal/auth" + "github.com/panelhosting/panel/internal/setup" +) + +type SetupHandler struct { + svc *setup.Service +} + +func NewSetupHandler(svc *setup.Service) *SetupHandler { + return &SetupHandler{svc: svc} +} + +func (h *SetupHandler) Status(w http.ResponseWriter, r *http.Request) { + required, err := h.svc.Status(r.Context()) + if err != nil { + writeError(w, http.StatusInternalServerError, "internal error") + return + } + writeJSON(w, http.StatusOK, map[string]bool{"setup_required": required}) +} + +func (h *SetupHandler) Register(w http.ResponseWriter, r *http.Request) { + var in setup.RegisterInput + if err := json.NewDecoder(r.Body).Decode(&in); err != nil { + writeError(w, http.StatusBadRequest, "invalid json") + return + } + + user, err := h.svc.RegisterFirstAdmin(r.Context(), in) + if err != nil { + switch { + case errors.Is(err, setup.ErrSetupCompleted): + writeError(w, http.StatusConflict, "setup already completed") + case errors.Is(err, auth.ErrPasswordTooShort), + errors.Is(err, auth.ErrInvalidEmail), + errors.Is(err, auth.ErrInvalidUsername): + writeError(w, http.StatusBadRequest, err.Error()) + default: + writeError(w, http.StatusInternalServerError, "registration failed") + } + return + } + + writeJSON(w, http.StatusCreated, map[string]any{ + "message": "admin created", + "user": user, + }) +} + +func writeJSON(w http.ResponseWriter, status int, v any) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(v) +} + +func writeError(w http.ResponseWriter, status int, msg string) { + writeJSON(w, status, map[string]string{"error": msg}) +} diff --git a/internal/repository/user.go b/internal/repository/user.go new file mode 100644 index 0000000..e60d86e --- /dev/null +++ b/internal/repository/user.go @@ -0,0 +1,44 @@ +package repository + +import ( + "context" + "fmt" + + "github.com/jackc/pgx/v5/pgxpool" + "github.com/panelhosting/panel/internal/models" +) + +type UserRepository struct { + pool *pgxpool.Pool +} + +func NewUserRepository(pool *pgxpool.Pool) *UserRepository { + return &UserRepository{pool: pool} +} + +func (r *UserRepository) Count(ctx context.Context) (int64, error) { + var count int64 + err := r.pool.QueryRow(ctx, `SELECT COUNT(*) FROM users`).Scan(&count) + if err != nil { + return 0, fmt.Errorf("count users: %w", err) + } + return count, nil +} + +func (r *UserRepository) Create(ctx context.Context, email, username, passwordHash string, role models.UserRole, status models.UserStatus) (*models.User, error) { + const q = ` + INSERT INTO users (email, username, password_hash, role, status) + VALUES ($1, $2, $3, $4, $5) + RETURNING id, uuid, email, username, role, status, locale, timezone, last_login_at, created_at, updated_at + ` + + var u models.User + err := r.pool.QueryRow(ctx, q, email, username, passwordHash, role, status).Scan( + &u.ID, &u.UUID, &u.Email, &u.Username, &u.Role, &u.Status, + &u.Locale, &u.Timezone, &u.LastLoginAt, &u.CreatedAt, &u.UpdatedAt, + ) + if err != nil { + return nil, fmt.Errorf("create user: %w", err) + } + return &u, nil +} diff --git a/internal/server/server.go b/internal/server/server.go new file mode 100644 index 0000000..0d30085 --- /dev/null +++ b/internal/server/server.go @@ -0,0 +1,65 @@ +package server + +import ( + "context" + "fmt" + "log" + "net/http" + "time" + + "github.com/jackc/pgx/v5/pgxpool" + "github.com/panelhosting/panel/internal/handler" + "github.com/panelhosting/panel/internal/repository" + "github.com/panelhosting/panel/internal/setup" +) + +type Server struct { + httpServer *http.Server +} + +func New(pool *pgxpool.Pool, addr string) (*Server, error) { + users := repository.NewUserRepository(pool) + setupSvc := setup.NewService(users) + setupHandler := handler.NewSetupHandler(setupSvc) + + setupRequired, err := setupSvc.Status(context.Background()) + if err != nil { + return nil, fmt.Errorf("check setup status: %w", err) + } + if setupRequired { + log.Println("setup required: open / to create first admin") + } + + 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)) + + return &Server{ + httpServer: &http.Server{ + Addr: addr, + Handler: loggingMiddleware(mux), + ReadTimeout: 15 * time.Second, + WriteTimeout: 15 * time.Second, + IdleTimeout: 60 * time.Second, + }, + }, nil +} + +func (s *Server) Start() error { + log.Printf("panel listening on %s", s.httpServer.Addr) + return s.httpServer.ListenAndServe() +} + +func (s *Server) Shutdown(ctx context.Context) error { + return s.httpServer.Shutdown(ctx) +} + +func loggingMiddleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + start := time.Now() + next.ServeHTTP(w, r) + log.Printf("%s %s %s", r.Method, r.URL.Path, time.Since(start).Round(time.Millisecond)) + }) +} diff --git a/internal/setup/service.go b/internal/setup/service.go new file mode 100644 index 0000000..1a2b839 --- /dev/null +++ b/internal/setup/service.go @@ -0,0 +1,63 @@ +package setup + +import ( + "context" + "errors" + "fmt" + + "github.com/panelhosting/panel/internal/auth" + "github.com/panelhosting/panel/internal/models" + "github.com/panelhosting/panel/internal/repository" +) + +var ErrSetupCompleted = errors.New("setup already completed") + +type Service struct { + users *repository.UserRepository +} + +func NewService(users *repository.UserRepository) *Service { + return &Service{users: users} +} + +func (s *Service) Status(ctx context.Context) (bool, error) { + count, err := s.users.Count(ctx) + if err != nil { + return false, err + } + return count == 0, nil +} + +type RegisterInput struct { + Email string `json:"email"` + Username string `json:"username"` + Password string `json:"password"` +} + +func (s *Service) RegisterFirstAdmin(ctx context.Context, in RegisterInput) (*models.User, error) { + required, err := s.Status(ctx) + if err != nil { + return nil, err + } + if !required { + return nil, ErrSetupCompleted + } + + if err := auth.ValidateEmail(in.Email); err != nil { + return nil, err + } + if err := auth.ValidateUsername(in.Username); err != nil { + return nil, err + } + + hash, err := auth.HashPassword(in.Password) + if err != nil { + return nil, err + } + + user, err := s.users.Create(ctx, in.Email, in.Username, hash, models.RoleSuperAdmin, models.UserStatusActive) + if err != nil { + return nil, fmt.Errorf("register admin: %w", err) + } + return user, nil +} diff --git a/scripts/server-setup.sh b/scripts/server-setup.sh index 5aafda7..129ddd8 100644 --- a/scripts/server-setup.sh +++ b/scripts/server-setup.sh @@ -20,5 +20,6 @@ fi docker compose up -d postgres docker compose --profile migrate run --rm migrate +docker compose up -d panel -echo "Готово. PostgreSQL запущен, миграции применены." +echo "Готово. Панель: http://$(hostname -I | awk '{print $1}'):8000"