Release v0.20: регистрация, авторизация, личный кабинет
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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
|
||||
|
||||
+9
-1
@@ -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`) — домен и база данных
|
||||
|
||||
@@ -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
|
||||
|
||||
+13
-1
@@ -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,
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
package auth
|
||||
|
||||
const SessionCookieName = "shop_session"
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -12,6 +12,8 @@ type Config struct {
|
||||
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 == "" {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"html/template"
|
||||
"log"
|
||||
"net/http"
|
||||
|
||||
@@ -11,15 +10,15 @@ import (
|
||||
|
||||
type HomeHandler struct {
|
||||
products *repository.ProductRepository
|
||||
templates *template.Template
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -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 ""
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
package models
|
||||
|
||||
import "time"
|
||||
|
||||
type User struct {
|
||||
ID int
|
||||
Email string
|
||||
Name string
|
||||
CreatedAt time.Time
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -3,7 +3,7 @@ package version
|
||||
import "runtime"
|
||||
|
||||
const (
|
||||
AppVersion = "0.10-beta"
|
||||
AppVersion = "0.20"
|
||||
ExpectedPostgresMajor = 17
|
||||
MinGoVersion = "1.22"
|
||||
)
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
{{define "account.html"}}
|
||||
{{template "layout" .}}
|
||||
{{end}}
|
||||
|
||||
{{define "content"}}
|
||||
<section class="section account-section">
|
||||
<div class="container">
|
||||
<h1 class="section-title">Личный кабинет</h1>
|
||||
<div class="account-grid">
|
||||
<aside class="account-sidebar">
|
||||
<div class="account-user-card">
|
||||
<p class="account-label">Аккаунт</p>
|
||||
<p class="account-name">{{.User.Name}}</p>
|
||||
<p class="account-email">{{.User.Email}}</p>
|
||||
<p class="account-meta">С нами с {{.User.CreatedAt.Format "02.01.2006"}}</p>
|
||||
</div>
|
||||
<nav class="account-nav">
|
||||
<span class="account-nav-item active">Профиль</span>
|
||||
<a href="/#catalog" class="account-nav-item">Каталог</a>
|
||||
</nav>
|
||||
</aside>
|
||||
<div class="account-main">
|
||||
<div class="auth-card account-card">
|
||||
<h2>Настройки профиля</h2>
|
||||
<form method="POST" action="/account" class="auth-form">
|
||||
<label class="form-field">
|
||||
<span>Имя</span>
|
||||
<input type="text" name="name" value="{{.Name}}" required minlength="2">
|
||||
</label>
|
||||
<label class="form-field">
|
||||
<span>Email</span>
|
||||
<input type="email" value="{{.User.Email}}" disabled>
|
||||
</label>
|
||||
<button type="submit" class="btn btn-primary">Сохранить</button>
|
||||
</form>
|
||||
</div>
|
||||
<div class="account-hint auth-card">
|
||||
<h3>Заказы</h3>
|
||||
<p class="text-muted">История заказов появится в следующих версиях. Пока вы можете просматривать каталог и добавлять товары в корзину.</p>
|
||||
<a href="/#catalog" class="btn btn-ghost">Перейти в каталог</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
{{end}}
|
||||
@@ -15,16 +15,27 @@
|
||||
<div class="container header-inner">
|
||||
<a href="/" class="logo">Shop<span>Nova</span></a>
|
||||
<nav class="nav">
|
||||
<a href="/" class="nav-link active">Главная</a>
|
||||
<a href="#catalog" class="nav-link">Каталог</a>
|
||||
<a href="#categories" class="nav-link">Категории</a>
|
||||
<a href="/" class="nav-link{{if eq .Nav "home"}} active{{end}}">Главная</a>
|
||||
<a href="/#catalog" class="nav-link">Каталог</a>
|
||||
{{if .User}}
|
||||
<a href="/account" class="nav-link{{if eq .Nav "account"}} active{{end}}">Личный кабинет</a>
|
||||
{{end}}
|
||||
</nav>
|
||||
<div class="header-actions">
|
||||
<button type="button" class="btn btn-ghost" aria-label="Поиск">Поиск</button>
|
||||
<button type="button" class="btn btn-primary">Корзина</button>
|
||||
{{if .User}}
|
||||
<span class="user-greeting">{{.User.Name}}</span>
|
||||
<form method="POST" action="/logout" class="inline-form">
|
||||
<button type="submit" class="btn btn-ghost">Выйти</button>
|
||||
</form>
|
||||
{{else}}
|
||||
<a href="/login" class="btn btn-ghost">Вход</a>
|
||||
<a href="/register" class="btn btn-primary">Регистрация</a>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
{{if .Success}}<div class="container"><p class="alert alert-success">{{.Success}}</p></div>{{end}}
|
||||
{{if .Error}}<div class="container"><p class="alert alert-error">{{.Error}}</p></div>{{end}}
|
||||
<main>
|
||||
{{template "content" .}}
|
||||
</main>
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
{{define "login.html"}}
|
||||
{{template "layout" .}}
|
||||
{{end}}
|
||||
|
||||
{{define "content"}}
|
||||
<section class="section auth-section">
|
||||
<div class="container auth-container">
|
||||
<div class="auth-card">
|
||||
<h1 class="auth-title">Вход</h1>
|
||||
<p class="auth-sub">Войдите в личный кабинет</p>
|
||||
<form method="POST" action="/login" class="auth-form">
|
||||
<input type="hidden" name="next" value="{{.Next}}">
|
||||
<label class="form-field">
|
||||
<span>Email</span>
|
||||
<input type="email" name="email" value="{{.Email}}" required autocomplete="email">
|
||||
</label>
|
||||
<label class="form-field">
|
||||
<span>Пароль</span>
|
||||
<input type="password" name="password" required autocomplete="current-password">
|
||||
</label>
|
||||
<button type="submit" class="btn btn-primary btn-lg btn-block">Войти</button>
|
||||
</form>
|
||||
<p class="auth-footer">Нет аккаунта? <a href="/register">Регистрация</a></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
{{end}}
|
||||
@@ -0,0 +1,34 @@
|
||||
{{define "register.html"}}
|
||||
{{template "layout" .}}
|
||||
{{end}}
|
||||
|
||||
{{define "content"}}
|
||||
<section class="section auth-section">
|
||||
<div class="container auth-container">
|
||||
<div class="auth-card">
|
||||
<h1 class="auth-title">Регистрация</h1>
|
||||
<p class="auth-sub">Создайте аккаунт для заказов и личного кабинета</p>
|
||||
<form method="POST" action="/register" class="auth-form">
|
||||
<label class="form-field">
|
||||
<span>Имя</span>
|
||||
<input type="text" name="name" value="{{.Name}}" required minlength="2" autocomplete="name">
|
||||
</label>
|
||||
<label class="form-field">
|
||||
<span>Email</span>
|
||||
<input type="email" name="email" value="{{.Email}}" required autocomplete="email">
|
||||
</label>
|
||||
<label class="form-field">
|
||||
<span>Пароль</span>
|
||||
<input type="password" name="password" required minlength="8" autocomplete="new-password">
|
||||
</label>
|
||||
<label class="form-field">
|
||||
<span>Повторите пароль</span>
|
||||
<input type="password" name="password_confirm" required minlength="8" autocomplete="new-password">
|
||||
</label>
|
||||
<button type="submit" class="btn btn-primary btn-lg btn-block">Зарегистрироваться</button>
|
||||
</form>
|
||||
<p class="auth-footer">Уже есть аккаунт? <a href="/login">Войти</a></p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
{{end}}
|
||||
@@ -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);
|
||||
Reference in New Issue
Block a user