From b3e3a068580ab1b657423c780e9424428ddb73ad Mon Sep 17 00:00:00 2001 From: shop Date: Sat, 16 May 2026 17:31:56 +0300 Subject: [PATCH] =?UTF-8?q?Release=20v0.20:=20=D1=80=D0=B5=D0=B3=D0=B8?= =?UTF-8?q?=D1=81=D1=82=D1=80=D0=B0=D1=86=D0=B8=D1=8F,=20=D0=B0=D0=B2?= =?UTF-8?q?=D1=82=D0=BE=D1=80=D0=B8=D0=B7=D0=B0=D1=86=D0=B8=D1=8F,=20?= =?UTF-8?q?=D0=BB=D0=B8=D1=87=D0=BD=D1=8B=D0=B9=20=D0=BA=D0=B0=D0=B1=D0=B8?= =?UTF-8?q?=D0=BD=D0=B5=D1=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Cursor --- .env.example | 2 + CHANGELOG.md | 10 +- README.md | 21 ++- cmd/server/main.go | 14 +- go.mod | 6 +- internal/auth/cookies.go | 3 + internal/auth/password.go | 27 ++++ internal/auth/service.go | 137 +++++++++++++++++ internal/config/config.go | 21 ++- internal/handlers/account.go | 66 ++++++++ internal/handlers/auth.go | 131 ++++++++++++++++ internal/handlers/home.go | 19 +-- internal/handlers/page.go | 53 +++++++ internal/models/user.go | 10 ++ internal/repository/sessions.go | 44 ++++++ internal/repository/users.go | 80 ++++++++++ internal/version/version.go | 2 +- internal/web/static/css/style.css | 216 +++++++++++++++++++++++++++ internal/web/templates/account.html | 46 ++++++ internal/web/templates/layout.html | 21 ++- internal/web/templates/login.html | 28 ++++ internal/web/templates/register.html | 34 +++++ postgres/init/02_users.sql | 17 +++ 23 files changed, 981 insertions(+), 27 deletions(-) create mode 100644 internal/auth/cookies.go create mode 100644 internal/auth/password.go create mode 100644 internal/auth/service.go create mode 100644 internal/handlers/account.go create mode 100644 internal/handlers/auth.go create mode 100644 internal/handlers/page.go create mode 100644 internal/models/user.go create mode 100644 internal/repository/sessions.go create mode 100644 internal/repository/users.go create mode 100644 internal/web/templates/account.html create mode 100644 internal/web/templates/login.html create mode 100644 internal/web/templates/register.html create mode 100644 postgres/init/02_users.sql 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"}} + +{{end}} diff --git a/internal/web/templates/layout.html b/internal/web/templates/layout.html index 712a30b..c292063 100644 --- a/internal/web/templates/layout.html +++ b/internal/web/templates/layout.html @@ -15,16 +15,27 @@
- - + {{if .User}} + {{.User.Name}} +
+ +
+ {{else}} + Вход + Регистрация + {{end}}
+ {{if .Success}}

{{.Success}}

{{end}} + {{if .Error}}

{{.Error}}

{{end}}
{{template "content" .}}
diff --git a/internal/web/templates/login.html b/internal/web/templates/login.html new file mode 100644 index 0000000..1008c53 --- /dev/null +++ b/internal/web/templates/login.html @@ -0,0 +1,28 @@ +{{define "login.html"}} +{{template "layout" .}} +{{end}} + +{{define "content"}} +
+
+
+

Вход

+

Войдите в личный кабинет

+
+ + + + +
+ +
+
+ +
+{{end}} diff --git a/internal/web/templates/register.html b/internal/web/templates/register.html new file mode 100644 index 0000000..72213d8 --- /dev/null +++ b/internal/web/templates/register.html @@ -0,0 +1,34 @@ +{{define "register.html"}} +{{template "layout" .}} +{{end}} + +{{define "content"}} +
+
+
+

Регистрация

+

Создайте аккаунт для заказов и личного кабинета

+
+ + + + + +
+ +
+
+
+{{end}} diff --git a/postgres/init/02_users.sql b/postgres/init/02_users.sql new file mode 100644 index 0000000..56c1ab0 --- /dev/null +++ b/postgres/init/02_users.sql @@ -0,0 +1,17 @@ +CREATE TABLE IF NOT EXISTS users ( + id SERIAL PRIMARY KEY, + email VARCHAR(255) NOT NULL UNIQUE, + password_hash TEXT NOT NULL, + name VARCHAR(120) NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS sessions ( + id VARCHAR(64) PRIMARY KEY, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + expires_at TIMESTAMPTZ NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_sessions_user_id ON sessions (user_id); +CREATE INDEX IF NOT EXISTS idx_sessions_expires_at ON sessions (expires_at);