diff --git a/.env.example b/.env.example
index 6860d98..200886d 100644
--- a/.env.example
+++ b/.env.example
@@ -11,6 +11,8 @@ POSTGRES_DB=shopdb
DATABASE_URL=postgres://shop:shop_secret_change_me@postgres:5432/shopdb?sslmode=require
APP_PORT=8080
+SESSION_TTL_HOURS=168
+COOKIE_SECURE=false
DB_HOST=postgres
DB_PORT=5432
diff --git a/CHANGELOG.md b/CHANGELOG.md
index e6e2c62..a580583 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,9 +1,17 @@
# Changelog
-## [0.10-beta] — 2026-05-16
+## [0.20] — 2026-05-16
### Добавлено
+- Регистрация (`/register`), вход (`/login`), выход
+- Личный кабинет (`/account`) с редактированием профиля
+- Сессии в cookie, хеширование паролей bcrypt
+- Таблицы `users` и `sessions` в PostgreSQL
+
+## [0.10-beta] — 2026-05-16
+### Добавлено
+
- Главная страница интернет-магазина (Go, HTML/CSS)
- PostgreSQL 17 с SSL, Docker Compose, Caddy
- Интерактивный установщик (`install.sh` / `install.ps1`) — домен и база данных
diff --git a/README.md b/README.md
index 671e210..98bc694 100644
--- a/README.md
+++ b/README.md
@@ -1,6 +1,6 @@
# ShopNova — интернет-магазин (Go)
-**Версия:** `0.10-beta` · [Релизы](https://git.evilfox.cc/test/shop3/releases)
+**Версия:** `0.20` · [Релизы](https://git.evilfox.cc/test/shop3/releases)
Главная страница интернет-магазина на Go с PostgreSQL 17 (SSL), reverse proxy Caddy и Docker Compose.
@@ -9,7 +9,7 @@
Клонировать конкретную версию:
```bash
-git clone --branch v0.10-beta https://git.evilfox.cc/test/shop3.git
+git clone --branch v0.20 https://git.evilfox.cc/test/shop3.git
```
## Быстрая установка на сервере
@@ -92,6 +92,23 @@ docker compose up --build -d
- `GET /health` — статус и проверки
- `GET /version` — версии приложения, Go и PostgreSQL
+## Регистрация и личный кабинет
+
+| URL | Описание |
+|-----|----------|
+| `/register` | Регистрация |
+| `/login` | Вход |
+| `/account` | Личный кабинет (только для авторизованных) |
+| `POST /logout` | Выход |
+
+Сессии в cookie `shop_session`, пароли — bcrypt.
+
+Если БД уже была создана до обновления, примените миграцию:
+
+```bash
+docker compose exec -T postgres psql -U shop -d shopdb < postgres/init/02_users.sql
+```
+
## Локальная разработка
```bash
diff --git a/cmd/server/main.go b/cmd/server/main.go
index ef7eed4..06c00f6 100644
--- a/cmd/server/main.go
+++ b/cmd/server/main.go
@@ -11,6 +11,7 @@ import (
"syscall"
"time"
+ "shop/internal/auth"
"shop/internal/check"
"shop/internal/config"
"shop/internal/database"
@@ -49,9 +50,16 @@ func main() {
log.Fatalf("templates: %v", err)
}
+ users := repository.NewUserRepository(pool)
+ sessions := repository.NewSessionRepository(pool)
+ authSvc := auth.NewService(users, sessions, cfg.SessionTTL, cfg.CookieSecure)
+
+ pages := handlers.NewPages(tmpl, authSvc)
products := repository.NewProductRepository(pool)
- home := handlers.NewHomeHandler(products, tmpl)
+ home := handlers.NewHomeHandler(products, pages)
health := handlers.NewHealthHandler(pool)
+ authH := handlers.NewAuthHandler(pages, authSvc)
+ account := handlers.NewAccountHandler(pages, authSvc)
staticSub, err := fs.Sub(web.StaticFS, "static")
if err != nil {
@@ -63,6 +71,10 @@ func main() {
mux.HandleFunc("GET /health", health.Health)
mux.HandleFunc("GET /version", health.Version)
mux.Handle("GET /", home)
+ mux.HandleFunc("/register", authH.Register)
+ mux.HandleFunc("/login", authH.Login)
+ mux.HandleFunc("POST /logout", authH.Logout)
+ mux.HandleFunc("/account", account.Account)
srv := &http.Server{
Addr: cfg.HTTPAddr,
diff --git a/go.mod b/go.mod
index b821f29..a613f61 100644
--- a/go.mod
+++ b/go.mod
@@ -2,13 +2,15 @@ module shop
go 1.22
-require github.com/jackc/pgx/v5 v5.7.2
+require (
+ github.com/jackc/pgx/v5 v5.7.2
+ golang.org/x/crypto v0.31.0
+)
require (
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/puddle/v2 v2.2.2 // 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/cookies.go b/internal/auth/cookies.go
new file mode 100644
index 0000000..f51cdf2
--- /dev/null
+++ b/internal/auth/cookies.go
@@ -0,0 +1,3 @@
+package auth
+
+const SessionCookieName = "shop_session"
diff --git a/internal/auth/password.go b/internal/auth/password.go
new file mode 100644
index 0000000..680639b
--- /dev/null
+++ b/internal/auth/password.go
@@ -0,0 +1,27 @@
+package auth
+
+import (
+ "errors"
+ "unicode/utf8"
+
+ "golang.org/x/crypto/bcrypt"
+)
+
+const (
+ minPasswordLen = 8
+ bcryptCost = 12
+)
+
+var ErrInvalidCredentials = errors.New("неверный email или пароль")
+
+func HashPassword(password string) (string, error) {
+ if utf8.RuneCountInString(password) < minPasswordLen {
+ return "", errors.New("пароль должен быть не короче 8 символов")
+ }
+ b, err := bcrypt.GenerateFromPassword([]byte(password), bcryptCost)
+ return string(b), err
+}
+
+func CheckPassword(hash, password string) bool {
+ return bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) == nil
+}
diff --git a/internal/auth/service.go b/internal/auth/service.go
new file mode 100644
index 0000000..ef4adbd
--- /dev/null
+++ b/internal/auth/service.go
@@ -0,0 +1,137 @@
+package auth
+
+import (
+ "context"
+ "crypto/rand"
+ "encoding/hex"
+ "errors"
+ "fmt"
+ "net/http"
+ "regexp"
+ "strings"
+ "time"
+
+ "shop/internal/models"
+ "shop/internal/repository"
+)
+
+var (
+ ErrEmailTaken = errors.New("этот email уже зарегистрирован")
+ ErrInvalidEmail = errors.New("некорректный email")
+ ErrInvalidName = errors.New("имя должно быть не короче 2 символов")
+ ErrNotAuthenticated = errors.New("требуется авторизация")
+)
+
+var emailRe = regexp.MustCompile(`^[^\s@]+@[^\s@]+\.[^\s@]+$`)
+
+type Service struct {
+ users *repository.UserRepository
+ sessions *repository.SessionRepository
+ ttl time.Duration
+ secure bool
+}
+
+func NewService(users *repository.UserRepository, sessions *repository.SessionRepository, ttl time.Duration, cookieSecure bool) *Service {
+ return &Service{users: users, sessions: sessions, ttl: ttl, secure: cookieSecure}
+}
+
+func (s *Service) Register(ctx context.Context, email, password, name string) (*models.User, error) {
+ email = strings.TrimSpace(strings.ToLower(email))
+ name = strings.TrimSpace(name)
+ if !emailRe.MatchString(email) {
+ return nil, ErrInvalidEmail
+ }
+ if len([]rune(name)) < 2 {
+ return nil, ErrInvalidName
+ }
+ hash, err := HashPassword(password)
+ if err != nil {
+ return nil, err
+ }
+ user, err := s.users.Create(ctx, email, hash, name)
+ if err != nil {
+ if repository.IsUniqueViolation(err) {
+ return nil, ErrEmailTaken
+ }
+ return nil, err
+ }
+ return user, nil
+}
+
+func (s *Service) Login(ctx context.Context, w http.ResponseWriter, email, password string) error {
+ email = strings.TrimSpace(strings.ToLower(email))
+ user, hash, err := s.users.ByEmailWithHash(ctx, email)
+ if err != nil || !CheckPassword(hash, password) {
+ return ErrInvalidCredentials
+ }
+ return s.setSession(ctx, w, user.ID)
+}
+
+func (s *Service) Logout(ctx context.Context, w http.ResponseWriter, r *http.Request) {
+ c, err := r.Cookie(SessionCookieName)
+ if err == nil && c.Value != "" {
+ _ = s.sessions.Delete(ctx, c.Value)
+ }
+ s.clearCookie(w)
+}
+
+func (s *Service) UserFromRequest(ctx context.Context, r *http.Request) (*models.User, error) {
+ c, err := r.Cookie(SessionCookieName)
+ if err != nil || c.Value == "" {
+ return nil, nil
+ }
+ userID, err := s.sessions.UserID(ctx, c.Value)
+ if err != nil || userID == 0 {
+ return nil, nil
+ }
+ return s.users.ByID(ctx, userID)
+}
+
+func (s *Service) UpdateName(ctx context.Context, userID int, name string) error {
+ name = strings.TrimSpace(name)
+ if len([]rune(name)) < 2 {
+ return ErrInvalidName
+ }
+ return s.users.UpdateName(ctx, userID, name)
+}
+
+func (s *Service) setSession(ctx context.Context, w http.ResponseWriter, userID int) error {
+ token, err := newToken()
+ if err != nil {
+ return err
+ }
+ expires := time.Now().Add(s.ttl)
+ if err := s.sessions.Create(ctx, token, userID, expires); err != nil {
+ return err
+ }
+ http.SetCookie(w, &http.Cookie{
+ Name: SessionCookieName,
+ Value: token,
+ Path: "/",
+ Expires: expires,
+ HttpOnly: true,
+ SameSite: http.SameSiteLaxMode,
+ Secure: s.secure,
+ })
+ return nil
+}
+
+func (s *Service) clearCookie(w http.ResponseWriter) {
+ http.SetCookie(w, &http.Cookie{
+ Name: SessionCookieName,
+ Value: "",
+ Path: "/",
+ MaxAge: -1,
+ HttpOnly: true,
+ SameSite: http.SameSiteLaxMode,
+ Secure: s.secure,
+ })
+}
+
+func newToken() (string, error) {
+ b := make([]byte, 32)
+ if _, err := rand.Read(b); err != nil {
+ return "", fmt.Errorf("session token: %w", err)
+ }
+ return hex.EncodeToString(b), nil
+}
diff --git a/internal/config/config.go b/internal/config/config.go
index d1d2913..517b7f6 100644
--- a/internal/config/config.go
+++ b/internal/config/config.go
@@ -8,10 +8,12 @@ import (
)
type Config struct {
- HTTPAddr string
- DatabaseURL string
- ReadTimeout time.Duration
- WriteTimeout time.Duration
+ HTTPAddr string
+ DatabaseURL string
+ ReadTimeout time.Duration
+ WriteTimeout time.Duration
+ SessionTTL time.Duration
+ CookieSecure bool
}
func Load() (Config, error) {
@@ -21,6 +23,8 @@ func Load() (Config, error) {
DatabaseURL: os.Getenv("DATABASE_URL"),
ReadTimeout: durationEnv("HTTP_READ_TIMEOUT", 10*time.Second),
WriteTimeout: durationEnv("HTTP_WRITE_TIMEOUT", 30*time.Second),
+ SessionTTL: sessionTTL(),
+ CookieSecure: env("COOKIE_SECURE", "false") == "true",
}
if cfg.DatabaseURL == "" {
return cfg, fmt.Errorf("DATABASE_URL is required")
@@ -35,6 +39,15 @@ func env(key, fallback string) string {
return fallback
}
+func sessionTTL() time.Duration {
+ if v := os.Getenv("SESSION_TTL_HOURS"); v != "" {
+ if h, err := strconv.Atoi(v); err == nil && h > 0 {
+ return time.Duration(h) * time.Hour
+ }
+ }
+ return 168 * time.Hour
+}
+
func durationEnv(key string, fallback time.Duration) time.Duration {
v := os.Getenv(key)
if v == "" {
diff --git a/internal/handlers/account.go b/internal/handlers/account.go
new file mode 100644
index 0000000..e65d272
--- /dev/null
+++ b/internal/handlers/account.go
@@ -0,0 +1,66 @@
+package handlers
+
+import (
+ "net/http"
+
+ "shop/internal/auth"
+)
+
+type AccountHandler struct {
+ pages *Pages
+ auth *auth.Service
+}
+
+func NewAccountHandler(pages *Pages, authSvc *auth.Service) *AccountHandler {
+ return &AccountHandler{pages: pages, auth: authSvc}
+}
+
+type accountPageData struct {
+ Layout
+ Name string
+}
+
+func (h *AccountHandler) Account(w http.ResponseWriter, r *http.Request) {
+ user, err := h.auth.UserFromRequest(r.Context(), r)
+ if err != nil || user == nil {
+ http.Redirect(w, r, "/login?next=/account", http.StatusSeeOther)
+ return
+ }
+
+ switch r.Method {
+ case http.MethodGet:
+ data := accountPageData{
+ Layout: h.pages.layout(r, "Личный кабинет", "account"),
+ Name: user.Name,
+ }
+ data.Success = flashMsg(r, "ok")
+ h.pages.render(w, "account.html", data)
+ case http.MethodPost:
+ h.updateProfile(w, r)
+ default:
+ http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
+ }
+}
+
+func (h *AccountHandler) updateProfile(w http.ResponseWriter, r *http.Request) {
+ user, _ := h.auth.UserFromRequest(r.Context(), r)
+ if user == nil {
+ http.Redirect(w, r, "/login", http.StatusSeeOther)
+ return
+ }
+ if err := r.ParseForm(); err != nil {
+ http.Redirect(w, r, "/account", http.StatusSeeOther)
+ return
+ }
+ name := r.FormValue("name")
+ if err := h.auth.UpdateName(r.Context(), user.ID, name); err != nil {
+ data := accountPageData{
+ Layout: h.pages.layout(r, "Личный кабинет", "account"),
+ Name: name,
+ }
+ data.Error = err.Error()
+ h.pages.render(w, "account.html", data)
+ return
+ }
+ http.Redirect(w, r, "/account?ok=profile", http.StatusSeeOther)
+}
diff --git a/internal/handlers/auth.go b/internal/handlers/auth.go
new file mode 100644
index 0000000..3857057
--- /dev/null
+++ b/internal/handlers/auth.go
@@ -0,0 +1,131 @@
+package handlers
+
+import (
+ "errors"
+ "net/http"
+ "strings"
+
+ "shop/internal/auth"
+)
+
+type AuthHandler struct {
+ pages *Pages
+ auth *auth.Service
+}
+
+func NewAuthHandler(pages *Pages, authSvc *auth.Service) *AuthHandler {
+ return &AuthHandler{pages: pages, auth: authSvc}
+}
+
+type authPageData struct {
+ Layout
+ Email string
+ Name string
+ Next string
+}
+
+func (h *AuthHandler) Register(w http.ResponseWriter, r *http.Request) {
+ switch r.Method {
+ case http.MethodGet:
+ h.showRegister(w, r, "", "")
+ case http.MethodPost:
+ h.postRegister(w, r)
+ default:
+ http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
+ }
+}
+
+func (h *AuthHandler) showRegister(w http.ResponseWriter, r *http.Request, errMsg string, email string) {
+ data := authPageData{
+ Layout: h.pages.layout(r, "Регистрация", "register"),
+ Email: email,
+ }
+ data.Error = errMsg
+ if msg := flashMsg(r, "ok"); msg != "" {
+ data.Success = msg
+ }
+ h.pages.render(w, "register.html", data)
+}
+
+func (h *AuthHandler) postRegister(w http.ResponseWriter, r *http.Request) {
+ if err := r.ParseForm(); err != nil {
+ h.showRegister(w, r, "Неверные данные формы", "")
+ return
+ }
+ email := r.FormValue("email")
+ name := r.FormValue("name")
+ password := r.FormValue("password")
+ password2 := r.FormValue("password_confirm")
+ if password != password2 {
+ h.showRegister(w, r, "Пароли не совпадают", email)
+ return
+ }
+ _, err := h.auth.Register(r.Context(), email, password, name)
+ if err != nil {
+ h.showRegister(w, r, err.Error(), email)
+ return
+ }
+ http.Redirect(w, r, "/login?ok=registered", http.StatusSeeOther)
+}
+
+func (h *AuthHandler) Login(w http.ResponseWriter, r *http.Request) {
+ switch r.Method {
+ case http.MethodGet:
+ h.showLogin(w, r, "", "")
+ case http.MethodPost:
+ h.postLogin(w, r)
+ default:
+ http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
+ }
+}
+
+func (h *AuthHandler) showLogin(w http.ResponseWriter, r *http.Request, errMsg, email string) {
+ data := authPageData{
+ Layout: h.pages.layout(r, "Вход", "login"),
+ Email: email,
+ Next: safeNext(r.URL.Query().Get("next")),
+ }
+ data.Error = errMsg
+ data.Success = flashMsg(r, "ok")
+ if data.Layout.User != nil {
+ http.Redirect(w, r, "/account", http.StatusSeeOther)
+ return
+ }
+ h.pages.render(w, "login.html", data)
+}
+
+func (h *AuthHandler) postLogin(w http.ResponseWriter, r *http.Request) {
+ if err := r.ParseForm(); err != nil {
+ h.showLogin(w, r, "Неверные данные формы", "")
+ return
+ }
+ email := r.FormValue("email")
+ password := r.FormValue("password")
+ if err := h.auth.Login(r.Context(), w, email, password); err != nil {
+ msg := err.Error()
+ if errors.Is(err, auth.ErrInvalidCredentials) {
+ msg = "Неверный email или пароль"
+ }
+ h.showLogin(w, r, msg, email)
+ return
+ }
+ next := safeNext(r.FormValue("next"))
+ http.Redirect(w, r, next+"?ok=login", http.StatusSeeOther)
+}
+
+func (h *AuthHandler) Logout(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodPost {
+ http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
+ return
+ }
+ h.auth.Logout(r.Context(), w, r)
+ http.Redirect(w, r, "/?ok=logout", http.StatusSeeOther)
+}
+
+func safeNext(next string) string {
+ next = strings.TrimSpace(next)
+ if next == "" || !strings.HasPrefix(next, "/") || strings.HasPrefix(next, "//") {
+ return "/account"
+ }
+ return next
+}
diff --git a/internal/handlers/home.go b/internal/handlers/home.go
index e3756ff..73b0228 100644
--- a/internal/handlers/home.go
+++ b/internal/handlers/home.go
@@ -1,7 +1,6 @@
package handlers
import (
- "html/template"
"log"
"net/http"
@@ -10,16 +9,16 @@ import (
)
type HomeHandler struct {
- products *repository.ProductRepository
- templates *template.Template
+ products *repository.ProductRepository
+ pages *Pages
}
-func NewHomeHandler(products *repository.ProductRepository, templates *template.Template) *HomeHandler {
- return &HomeHandler{products: products, templates: templates}
+func NewHomeHandler(products *repository.ProductRepository, pages *Pages) *HomeHandler {
+ return &HomeHandler{products: products, pages: pages}
}
type homePageData struct {
- Title string
+ Layout
Products []models.Product
Categories []string
TotalItems int
@@ -52,14 +51,12 @@ func (h *HomeHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
}
data := homePageData{
- Title: "Главная",
+ Layout: h.pages.layout(r, "Главная", "home"),
Products: featured,
Categories: categories,
TotalItems: total,
}
+ data.Success = flashMsg(r, "ok")
- w.Header().Set("Content-Type", "text/html; charset=utf-8")
- if err := h.templates.ExecuteTemplate(w, "home.html", data); err != nil {
- log.Printf("render home: %v", err)
- }
+ h.pages.render(w, "home.html", data)
}
diff --git a/internal/handlers/page.go b/internal/handlers/page.go
new file mode 100644
index 0000000..4a641f7
--- /dev/null
+++ b/internal/handlers/page.go
@@ -0,0 +1,53 @@
+package handlers
+
+import (
+ "html/template"
+ "net/http"
+
+ "shop/internal/auth"
+ "shop/internal/models"
+)
+
+type Layout struct {
+ Title string
+ Nav string
+ User *models.User
+ Error string
+ Success string
+}
+
+type Pages struct {
+ tmpl *template.Template
+ auth *auth.Service
+}
+
+func NewPages(tmpl *template.Template, authSvc *auth.Service) *Pages {
+ return &Pages{tmpl: tmpl, auth: authSvc}
+}
+
+func (p *Pages) layout(r *http.Request, title, nav string) Layout {
+ user, _ := p.auth.UserFromRequest(r.Context(), r)
+ return Layout{Title: title, Nav: nav, User: user}
+}
+
+func (p *Pages) render(w http.ResponseWriter, name string, data any) {
+ w.Header().Set("Content-Type", "text/html; charset=utf-8")
+ if err := p.tmpl.ExecuteTemplate(w, name, data); err != nil {
+ http.Error(w, "ошибка шаблона", http.StatusInternalServerError)
+ }
+}
+
+func flashMsg(r *http.Request, key string) string {
+ switch r.URL.Query().Get(key) {
+ case "registered":
+ return "Регистрация успешна. Войдите в аккаунт."
+ case "login":
+ return "Вы успешно вошли."
+ case "logout":
+ return "Вы вышли из аккаунта."
+ case "profile":
+ return "Профиль обновлён."
+ default:
+ return ""
+ }
+}
diff --git a/internal/models/user.go b/internal/models/user.go
new file mode 100644
index 0000000..73902e7
--- /dev/null
+++ b/internal/models/user.go
@@ -0,0 +1,10 @@
+package models
+
+import "time"
+
+type User struct {
+ ID int
+ Email string
+ Name string
+ CreatedAt time.Time
+}
diff --git a/internal/repository/sessions.go b/internal/repository/sessions.go
new file mode 100644
index 0000000..389e47d
--- /dev/null
+++ b/internal/repository/sessions.go
@@ -0,0 +1,44 @@
+package repository
+
+import (
+ "context"
+ "errors"
+ "time"
+
+ "github.com/jackc/pgx/v5"
+ "github.com/jackc/pgx/v5/pgxpool"
+)
+
+type SessionRepository struct {
+ pool *pgxpool.Pool
+}
+
+func NewSessionRepository(pool *pgxpool.Pool) *SessionRepository {
+ return &SessionRepository{pool: pool}
+}
+
+func (r *SessionRepository) Create(ctx context.Context, token string, userID int, expires time.Time) error {
+ _, err := r.pool.Exec(ctx, `
+ INSERT INTO sessions (id, user_id, expires_at) VALUES ($1, $2, $3)`,
+ token, userID, expires)
+ return err
+}
+
+func (r *SessionRepository) UserID(ctx context.Context, token string) (int, error) {
+ var userID int
+ err := r.pool.QueryRow(ctx, `
+ SELECT user_id FROM sessions
+ WHERE id = $1 AND expires_at > NOW()`, token).Scan(&userID)
+ if errors.Is(err, pgx.ErrNoRows) {
+ return 0, nil
+ }
+ if err != nil {
+ return 0, err
+ }
+ return userID, nil
+}
+
+func (r *SessionRepository) Delete(ctx context.Context, token string) error {
+ _, err := r.pool.Exec(ctx, `DELETE FROM sessions WHERE id = $1`, token)
+ return err
+}
diff --git a/internal/repository/users.go b/internal/repository/users.go
new file mode 100644
index 0000000..b004a65
--- /dev/null
+++ b/internal/repository/users.go
@@ -0,0 +1,80 @@
+package repository
+
+import (
+ "context"
+ "errors"
+
+ "github.com/jackc/pgx/v5"
+ "github.com/jackc/pgx/v5/pgconn"
+ "github.com/jackc/pgx/v5/pgxpool"
+
+ "shop/internal/models"
+)
+
+type UserRepository struct {
+ pool *pgxpool.Pool
+}
+
+func NewUserRepository(pool *pgxpool.Pool) *UserRepository {
+ return &UserRepository{pool: pool}
+}
+
+func (r *UserRepository) Create(ctx context.Context, email, passwordHash, name string) (*models.User, error) {
+ var u models.User
+ err := r.pool.QueryRow(ctx, `
+ INSERT INTO users (email, password_hash, name)
+ VALUES ($1, $2, $3)
+ RETURNING id, email, name, created_at`,
+ email, passwordHash, name,
+ ).Scan(&u.ID, &u.Email, &u.Name, &u.CreatedAt)
+ if err != nil {
+ return nil, err
+ }
+ return &u, nil
+}
+
+func (r *UserRepository) ByID(ctx context.Context, id int) (*models.User, error) {
+ var u models.User
+ err := r.pool.QueryRow(ctx, `
+ SELECT id, email, name, created_at FROM users WHERE id = $1`, id,
+ ).Scan(&u.ID, &u.Email, &u.Name, &u.CreatedAt)
+ if errors.Is(err, pgx.ErrNoRows) {
+ return nil, nil
+ }
+ if err != nil {
+ return nil, err
+ }
+ return &u, nil
+}
+
+func (r *UserRepository) ByEmailWithHash(ctx context.Context, email string) (*models.User, string, error) {
+ var u models.User
+ var hash string
+ err := r.pool.QueryRow(ctx, `
+ SELECT id, email, name, created_at, password_hash
+ FROM users WHERE email = $1`, email,
+ ).Scan(&u.ID, &u.Email, &u.Name, &u.CreatedAt, &hash)
+ if errors.Is(err, pgx.ErrNoRows) {
+ return nil, "", nil
+ }
+ if err != nil {
+ return nil, "", err
+ }
+ return &u, hash, nil
+}
+
+func (r *UserRepository) UpdateName(ctx context.Context, userID int, name string) error {
+ _, err := r.pool.Exec(ctx, `UPDATE users SET name = $1 WHERE id = $2`, name, userID)
+ return err
+}
+
+func (r *UserRepository) Count(ctx context.Context) (int, error) {
+ var n int
+ err := r.pool.QueryRow(ctx, `SELECT COUNT(*) FROM users`).Scan(&n)
+ return n, err
+}
+
+func IsUniqueViolation(err error) bool {
+ var pgErr *pgconn.PgError
+ return errors.As(err, &pgErr) && pgErr.Code == "23505"
+}
diff --git a/internal/version/version.go b/internal/version/version.go
index 2f79ce6..bac2489 100644
--- a/internal/version/version.go
+++ b/internal/version/version.go
@@ -3,7 +3,7 @@ package version
import "runtime"
const (
- AppVersion = "0.10-beta"
+ AppVersion = "0.20"
ExpectedPostgresMajor = 17
MinGoVersion = "1.22"
)
diff --git a/internal/web/static/css/style.css b/internal/web/static/css/style.css
index f464a21..5c9ef0e 100644
--- a/internal/web/static/css/style.css
+++ b/internal/web/static/css/style.css
@@ -396,4 +396,220 @@ a {
.features-grid {
grid-template-columns: 1fr;
}
+
+ .account-grid {
+ grid-template-columns: 1fr;
+ }
+
+ .user-greeting {
+ display: none;
+ }
+}
+
+/* Auth */
+.inline-form {
+ display: inline;
+ margin: 0;
+}
+
+.user-greeting {
+ font-size: 0.9rem;
+ color: var(--text-muted);
+ margin-right: 0.25rem;
+}
+
+.alert {
+ margin: 1rem 0 0;
+ padding: 0.75rem 1rem;
+ border-radius: var(--radius);
+ font-size: 0.95rem;
+}
+
+.alert-success {
+ background: rgba(72, 187, 120, 0.15);
+ border: 1px solid rgba(72, 187, 120, 0.35);
+ color: #9ae6b4;
+}
+
+.alert-error {
+ background: rgba(245, 101, 101, 0.12);
+ border: 1px solid rgba(245, 101, 101, 0.35);
+ color: #feb2b2;
+}
+
+.auth-section {
+ padding: 3rem 0 5rem;
+}
+
+.auth-container {
+ display: flex;
+ justify-content: center;
+}
+
+.auth-card {
+ width: min(420px, 100%);
+ background: var(--bg-elevated);
+ border: 1px solid var(--border);
+ border-radius: var(--radius);
+ padding: 2rem;
+}
+
+.auth-title {
+ font-family: var(--font-display);
+ font-size: 2rem;
+ font-weight: 400;
+ margin: 0 0 0.35rem;
+}
+
+.auth-sub {
+ color: var(--text-muted);
+ margin: 0 0 1.5rem;
+}
+
+.auth-form {
+ display: flex;
+ flex-direction: column;
+ gap: 1rem;
+}
+
+.form-field {
+ display: flex;
+ flex-direction: column;
+ gap: 0.35rem;
+}
+
+.form-field span {
+ font-size: 0.85rem;
+ color: var(--text-muted);
+}
+
+.form-field input {
+ padding: 0.65rem 0.85rem;
+ border-radius: 10px;
+ border: 1px solid var(--border);
+ background: var(--surface);
+ color: var(--text);
+ font-family: inherit;
+ font-size: 1rem;
+}
+
+.form-field input:focus {
+ outline: none;
+ border-color: var(--accent);
+}
+
+.form-field input:disabled {
+ opacity: 0.6;
+ cursor: not-allowed;
+}
+
+.btn-block {
+ width: 100%;
+ margin-top: 0.5rem;
+}
+
+.auth-footer {
+ margin: 1.25rem 0 0;
+ text-align: center;
+ color: var(--text-muted);
+ font-size: 0.95rem;
+}
+
+.auth-footer a {
+ color: var(--accent);
+}
+
+.auth-footer a:hover {
+ color: var(--accent-hover);
+}
+
+/* Account */
+.account-section {
+ padding: 2.5rem 0 4rem;
+}
+
+.account-grid {
+ display: grid;
+ grid-template-columns: 260px 1fr;
+ gap: 2rem;
+ margin-top: 1.5rem;
+}
+
+.account-user-card {
+ background: var(--bg-elevated);
+ border: 1px solid var(--border);
+ border-radius: var(--radius);
+ padding: 1.25rem;
+}
+
+.account-label {
+ font-size: 0.75rem;
+ text-transform: uppercase;
+ letter-spacing: 0.08em;
+ color: var(--accent);
+ margin: 0 0 0.5rem;
+}
+
+.account-name {
+ font-size: 1.25rem;
+ font-weight: 600;
+ margin: 0 0 0.25rem;
+}
+
+.account-email {
+ color: var(--text-muted);
+ font-size: 0.9rem;
+ margin: 0 0 0.75rem;
+ word-break: break-all;
+}
+
+.account-meta {
+ font-size: 0.82rem;
+ color: var(--text-muted);
+ margin: 0;
+}
+
+.account-nav {
+ display: flex;
+ flex-direction: column;
+ gap: 0.35rem;
+ margin-top: 1rem;
+}
+
+.account-nav-item {
+ display: block;
+ padding: 0.55rem 0.85rem;
+ border-radius: 8px;
+ font-size: 0.95rem;
+ color: var(--text-muted);
+}
+
+.account-nav-item.active {
+ background: var(--surface);
+ color: var(--text);
+}
+
+.account-nav-item:hover:not(.active) {
+ background: rgba(255, 255, 255, 0.04);
+ color: var(--text);
+}
+
+.account-main {
+ display: flex;
+ flex-direction: column;
+ gap: 1.25rem;
+}
+
+.account-card h2 {
+ margin: 0 0 1rem;
+ font-size: 1.15rem;
+}
+
+.account-hint h3 {
+ margin: 0 0 0.5rem;
+}
+
+.text-muted {
+ color: var(--text-muted);
+ font-size: 0.95rem;
}
diff --git a/internal/web/templates/account.html b/internal/web/templates/account.html
new file mode 100644
index 0000000..735e60d
--- /dev/null
+++ b/internal/web/templates/account.html
@@ -0,0 +1,46 @@
+{{define "account.html"}}
+{{template "layout" .}}
+{{end}}
+
+{{define "content"}}
+ История заказов появится в следующих версиях. Пока вы можете просматривать каталог и добавлять товары в корзину.Личный кабинет
+ Настройки профиля
+
+ Заказы
+
{{.Success}}
{{.Error}}
Войдите в личный кабинет
+ + +Создайте аккаунт для заказов и личного кабинета
+ + +