From 323e0a29268f5d432b0fdb93f7de6134482be759 Mon Sep 17 00:00:00 2001 From: shop Date: Sat, 16 May 2026 20:52:15 +0300 Subject: [PATCH] first commit Co-authored-by: Cursor --- .env.example | 21 +- .gitignore | 10 +- CHANGELOG.md | 31 -- Dockerfile | 27 -- README.md | 163 +++---- check.ps1 | 15 - check.sh | 88 ---- cmd/check/main.go | 192 --------- cmd/install/main.go | 27 -- cmd/server/main.go | 163 ------- docker-compose.yml | 94 ---- go.mod | 16 - go.sum | 28 -- install.ps1 | 4 - install.sh | 9 - internal/auth/cookies.go | 3 - internal/auth/password.go | 27 -- internal/auth/service.go | 137 ------ internal/check/check.go | 176 -------- internal/config/config.go | 61 --- internal/database/database.go | 36 -- internal/handlers/account.go | 66 --- internal/handlers/auth.go | 131 ------ internal/handlers/health.go | 57 --- internal/handlers/home.go | 62 --- internal/handlers/page.go | 53 --- internal/models/product.go | 11 - internal/models/user.go | 10 - internal/repository/products.go | 65 --- internal/repository/sessions.go | 44 -- internal/repository/users.go | 80 ---- internal/setup/setup.go | 223 ---------- internal/version/version.go | 13 - internal/web/embed.go | 9 - internal/web/static/css/style.css | 615 --------------------------- internal/web/templates/account.html | 46 -- internal/web/templates/home.html | 82 ---- internal/web/templates/layout.html | 50 --- internal/web/templates/login.html | 28 -- internal/web/templates/register.html | 34 -- package.json | 22 + postgres/init/01_schema.sql | 23 - postgres/init/02_users.sql | 17 - postgres/ssl/generate-certs.sh | 26 -- push-to-gitea.ps1 | 36 -- src/cart.js | 34 ++ src/db.js | 76 ++++ src/middleware/auth.js | 22 + src/public/css/style.css | 581 +++++++++++++++++++++++++ src/routes/auth.js | 116 +++++ src/routes/shop.js | 267 ++++++++++++ src/seed.js | 119 ++++++ src/server.js | 60 +++ src/views/account.ejs | 15 + src/views/cart.ejs | 54 +++ src/views/checkout.ejs | 43 ++ src/views/error.ejs | 9 + src/views/home.ejs | 47 ++ src/views/login.ejs | 21 + src/views/order.ejs | 30 ++ src/views/orders.ejs | 33 ++ src/views/partials/layout-end.ejs | 8 + src/views/partials/layout-start.ejs | 37 ++ src/views/product.ejs | 37 ++ src/views/register.ejs | 28 ++ traefik/dynamic/shop.yml | 16 - traefik/dynamic/shop.yml.example | 16 - 67 files changed, 1723 insertions(+), 3077 deletions(-) delete mode 100644 CHANGELOG.md delete mode 100644 Dockerfile delete mode 100644 check.ps1 delete mode 100755 check.sh delete mode 100644 cmd/check/main.go delete mode 100644 cmd/install/main.go delete mode 100644 cmd/server/main.go delete mode 100644 docker-compose.yml delete mode 100644 go.mod delete mode 100644 go.sum delete mode 100644 install.ps1 delete mode 100755 install.sh delete mode 100644 internal/auth/cookies.go delete mode 100644 internal/auth/password.go delete mode 100644 internal/auth/service.go delete mode 100644 internal/check/check.go delete mode 100644 internal/config/config.go delete mode 100644 internal/database/database.go delete mode 100644 internal/handlers/account.go delete mode 100644 internal/handlers/auth.go delete mode 100644 internal/handlers/health.go delete mode 100644 internal/handlers/home.go delete mode 100644 internal/handlers/page.go delete mode 100644 internal/models/product.go delete mode 100644 internal/models/user.go delete mode 100644 internal/repository/products.go delete mode 100644 internal/repository/sessions.go delete mode 100644 internal/repository/users.go delete mode 100644 internal/setup/setup.go delete mode 100644 internal/version/version.go delete mode 100644 internal/web/embed.go delete mode 100644 internal/web/static/css/style.css delete mode 100644 internal/web/templates/account.html delete mode 100644 internal/web/templates/home.html delete mode 100644 internal/web/templates/layout.html delete mode 100644 internal/web/templates/login.html delete mode 100644 internal/web/templates/register.html create mode 100644 package.json delete mode 100644 postgres/init/01_schema.sql delete mode 100644 postgres/init/02_users.sql delete mode 100755 postgres/ssl/generate-certs.sh delete mode 100644 push-to-gitea.ps1 create mode 100644 src/cart.js create mode 100644 src/db.js create mode 100644 src/middleware/auth.js create mode 100644 src/public/css/style.css create mode 100644 src/routes/auth.js create mode 100644 src/routes/shop.js create mode 100644 src/seed.js create mode 100644 src/server.js create mode 100644 src/views/account.ejs create mode 100644 src/views/cart.ejs create mode 100644 src/views/checkout.ejs create mode 100644 src/views/error.ejs create mode 100644 src/views/home.ejs create mode 100644 src/views/login.ejs create mode 100644 src/views/order.ejs create mode 100644 src/views/orders.ejs create mode 100644 src/views/partials/layout-end.ejs create mode 100644 src/views/partials/layout-start.ejs create mode 100644 src/views/product.ejs create mode 100644 src/views/register.ejs delete mode 100644 traefik/dynamic/shop.yml delete mode 100644 traefik/dynamic/shop.yml.example diff --git a/.env.example b/.env.example index c26a206..e3ea6c4 100644 --- a/.env.example +++ b/.env.example @@ -1,19 +1,2 @@ -# Скопируйте в .env или запустите: ./install.sh - -SITE_DOMAIN=localhost -ACME_EMAIL=admin@localhost -HTTP_PORT=80 -HTTPS_PORT=443 - -POSTGRES_USER=shop -POSTGRES_PASSWORD=shop_secret_change_me -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 -DB_SSLMODE=require +PORT=3000 +SESSION_SECRET=change-me-to-a-long-random-string diff --git a/.gitignore b/.gitignore index 98b209c..dde968d 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,6 @@ +node_modules/ +data/ .env -*.exe -bin/ -vendor/ -.idea/ -.vscode/ +*.db +*.db-journal +sessions.db diff --git a/CHANGELOG.md b/CHANGELOG.md deleted file mode 100644 index e6a70f8..0000000 --- a/CHANGELOG.md +++ /dev/null @@ -1,31 +0,0 @@ -# Changelog - -## [Unreleased] - -### Изменено - -- Caddy заменён на **Traefik v3** (маршруты в `traefik/dynamic/shop.yml`) -- `CADDY_EMAIL` → `ACME_EMAIL` в `.env` - -## [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`) — домен и база данных -- Проверка версий (`check.sh` / `check.ps1`, `/health`, `/version`) -- Инструкция быстрого деплоя на сервер в README - -### Стек - -- Go 1.22, pgx/v5 -- PostgreSQL 17, Caddy 2 diff --git a/Dockerfile b/Dockerfile deleted file mode 100644 index 7c730fb..0000000 --- a/Dockerfile +++ /dev/null @@ -1,27 +0,0 @@ -FROM golang:1.22-alpine AS builder - -WORKDIR /src -RUN apk add --no-cache git ca-certificates - -COPY go.mod go.sum ./ -RUN go mod download - -COPY . . -RUN CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags="-s -w" \ - -o /app/server ./cmd/server - -FROM alpine:3.20 - -RUN apk add --no-cache ca-certificates tzdata wget \ - && adduser -D -u 10001 app - -WORKDIR /app -COPY --from=builder /app/server . - -USER app -EXPOSE 8080 - -HEALTHCHECK --interval=15s --timeout=5s --start-period=20s --retries=3 \ - CMD wget -qO- http://127.0.0.1:8080/health || exit 1 - -CMD ["./server"] diff --git a/README.md b/README.md index 16adb6e..7db3b46 100644 --- a/README.md +++ b/README.md @@ -1,118 +1,69 @@ -# ShopNova — интернет-магазин (Go) +# Shop -**Версия:** `0.20` · [Релизы](https://git.evilfox.cc/test/shop3/releases) +Интернет-магазин на **Node.js** с локальной базой **SQLite**. -Главная страница интернет-магазина на Go с PostgreSQL 17 (SSL), reverse proxy **Traefik** и Docker Compose. +## Возможности -Репозиторий: https://git.evilfox.cc/test/shop3.git +- Каталог товаров с категориями и поиском +- Корзина и оформление заказа +- Регистрация и вход пользователей +- История заказов в личном кабинете -Клонировать конкретную версию: +## Требования + +- [Node.js](https://nodejs.org/) 18 или новее (вместе с npm) + +## Установка и запуск ```bash -git clone --branch v0.20 https://git.evilfox.cc/test/shop3.git +npm install +cp .env.example .env # Windows: copy .env.example .env +npm run dev ``` -## Быстрая установка на сервере +Сайт: [http://localhost:3000](http://localhost:3000) -Требования: **Git**, **Docker**, **Docker Compose** (плагин `docker compose`). +При первом запуске создаётся база `data/shop.db` и загружаются демо-товары. + +## Переменные окружения + +| Переменная | Описание | По умолчанию | +|-------------------|-----------------------------------|--------------| +| `PORT` | Порт HTTP-сервера | `3000` | +| `SESSION_SECRET` | Секрет для подписи сессий | dev-значение | + +## Скрипты + +| Команда | Описание | +|----------------|-----------------------------| +| `npm start` | Запуск сервера | +| `npm run dev` | Запуск с автоперезагрузкой | +| `npm run seed` | Заполнение каталога (если пуст) | + +## База данных + +Данные хранятся локально в каталоге `data/`: + +- `shop.db` — товары, пользователи, заказы +- `sessions.db` — сессии + +Каталог `data/` не попадает в git (см. `.gitignore`). + +## Структура проекта + +``` +src/ + server.js — точка входа + db.js — схема SQLite + seed.js — демо-данные + routes/ — маршруты + views/ — шаблоны EJS + public/css/ — стили +``` + +## Git remote ```bash -# 1. Клонировать -git clone https://git.evilfox.cc/test/shop3.git -cd shop3 - -# 2. Установщик (домен + база данных → .env и traefik/dynamic/shop.yml) -chmod +x install.sh check.sh -./install.sh - -# 3. Запуск -docker compose up --build -d - -# 4. Проверка (после старта контейнеров) -./check.sh --after-start +git remote add origin https://git.evilfox.cc/admin/shop.git +git push -u origin main ``` - -Одной цепочкой (после клона введите ответы установщика): - -```bash -git clone https://git.evilfox.cc/test/shop3.git && cd shop3 && chmod +x install.sh check.sh && ./install.sh && docker compose up --build -d && ./check.sh --after-start -``` - -С Go на сервере вместо `install.sh`: - -```bash -go run ./cmd/install -go run ./cmd/check -``` - -Без Go — установщик сам запустится в контейнере `golang:1.22-alpine`. - -### Обновление на сервере - -```bash -cd shop3 -git pull -docker compose up --build -d -``` - -### Полезные команды - -```bash -docker compose ps # статус контейнеров -docker compose logs -f traefik app # логи прокси и приложения -curl -s http://localhost/health | jq -curl -s http://localhost/version | jq -``` - -Сайт: `http://localhost` или `https://ваш-домен` (если указали в установщике). - ---- - -## Установка на Windows (локально) - -```powershell -git clone https://git.evilfox.cc/test/shop3.git -cd shop3 -.\install.ps1 -.\check.ps1 -docker compose up --build -d -``` - -## Проверка версий - -Проверяет Go, Docker, Docker Compose и PostgreSQL (**ожидается 17.x**): - -```bash -./check.sh -# или: go run ./cmd/check -``` - -После запуска сервера: - -- `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 -go run ./cmd/server -``` - -`DATABASE_URL` задаётся в `.env` (см. `.env.example` или установщик). diff --git a/check.ps1 b/check.ps1 deleted file mode 100644 index d384887..0000000 --- a/check.ps1 +++ /dev/null @@ -1,15 +0,0 @@ -# Проверка версий (до или после docker compose) -param([switch]$AfterStart) - -$ErrorActionPreference = "Stop" -Set-Location $PSScriptRoot - -if ($AfterStart) { - & "$PSScriptRoot\check.sh" --after-start -} else { - & bash "$PSScriptRoot\check.sh" 2>$null - if ($LASTEXITCODE -ne 0) { - $env:CHECK_SKIP_DB = "1" - go run ./cmd/check - } -} diff --git a/check.sh b/check.sh deleted file mode 100755 index 4bbc5a4..0000000 --- a/check.sh +++ /dev/null @@ -1,88 +0,0 @@ -#!/bin/sh -set -e -cd "$(dirname "$0")" - -MODE="${1:-pre}" - -host_docker_check() { - if command -v docker >/dev/null 2>&1; then - if docker version >/dev/null 2>&1; then - echo " ✓ docker: $(docker version --format '{{.Server.Version}}' 2>/dev/null || docker --version)" - else - echo " ! docker: установлен, но демон недоступен (запустите docker)" - fi - if docker compose version >/dev/null 2>&1; then - echo " ✓ docker_compose: $(docker compose version --short 2>/dev/null || docker compose version)" - else - echo " ! docker_compose: не найден" - fi - else - echo " ! docker: не найден на хосте" - echo " ! docker_compose: не найден на хосте" - fi -} - -run_go_check() { - if command -v go >/dev/null 2>&1; then - CHECK_HOST_TOOLS=1 "$@" go run ./cmd/check - else - CHECK_HOST_TOOLS=0 "$@" docker run --rm -v "$(pwd):/app" -w /app golang:1.22-alpine go run ./cmd/check - fi -} - -load_env() { - if [ -f .env ]; then - set -a - # shellcheck disable=SC1091 - . ./.env - set +a - fi -} - -post_start_check() { - echo "=== Проверка после запуска ===" - load_env - host_docker_check - echo "" - - if docker compose ps postgres 2>/dev/null | grep -qi running; then - if docker compose exec -T postgres pg_isready -U "${POSTGRES_USER:-shop}" -d "${POSTGRES_DB:-shopdb}" >/dev/null 2>&1; then - echo " ✓ postgresql: готова" - else - echo " ✗ postgresql: не отвечает" - exit 1 - fi - else - echo " ✗ postgres: контейнер не запущен — docker compose up -d" - exit 1 - fi - - if command -v curl >/dev/null 2>&1; then - if curl -sf "http://127.0.0.1:${HTTP_PORT:-80}/health" >/dev/null 2>&1; then - echo " ✓ /health: OK" - else - echo " ✗ /health: не отвечает — docker compose logs app traefik" - exit 1 - fi - elif docker compose exec -T app wget -qO- http://127.0.0.1:8080/health >/dev/null 2>&1; then - echo " ✓ app /health: OK" - fi - - echo "" - CHECK_SKIP_DB=1 CHECK_HOST_TOOLS=1 run_go_check -} - -case "$MODE" in - --after-start|post) - post_start_check - ;; - *) - echo "=== Проверка перед запуском (БД будет проверена после docker compose) ===" - host_docker_check - echo "" - CHECK_SKIP_DB=1 run_go_check - echo "" - echo "Далее: docker compose up --build -d" - echo "Затем: ./check.sh --after-start" - ;; -esac diff --git a/cmd/check/main.go b/cmd/check/main.go deleted file mode 100644 index 7a4e4b2..0000000 --- a/cmd/check/main.go +++ /dev/null @@ -1,192 +0,0 @@ -package main - -import ( - "context" - "encoding/json" - "fmt" - "net/url" - "os" - "path/filepath" - "strings" - - "github.com/jackc/pgx/v5/pgxpool" - - "shop/internal/check" -) - -func main() { - loadDotEnv() - - skipDB := os.Getenv("CHECK_SKIP_DB") == "1" - hostTools := os.Getenv("CHECK_HOST_TOOLS") != "0" - - ctx := context.Background() - report := check.AppInfo() - if hostTools { - report.Items = append(report.Items, check.ToolVersions(ctx)...) - } - - dbURL := os.Getenv("DATABASE_URL") - if skipDB { - report.Items = append(report.Items, check.Item{ - Name: "database", - Status: check.StatusWarn, - Detail: "проверка отложена — сначала выполните: docker compose up -d, затем ./check.sh --after-start", - }) - } else if dbURL == "" { - report.Items = append(report.Items, check.Item{ - Name: "database", - Status: check.StatusWarn, - Detail: "DATABASE_URL не задан — запустите: ./install.sh", - }) - } else { - appendDBChecks(ctx, &report, dbURL) - } - - enc := json.NewEncoder(os.Stdout) - enc.SetIndent("", " ") - _ = enc.Encode(report) - - fmt.Println() - printSummary(report) - - if !report.Healthy() { - os.Exit(1) - } -} - -func appendDBChecks(ctx context.Context, report *check.Report, dbURL string) { - pool, err := pgxpool.New(ctx, dbURL) - if err != nil { - report.Items = append(report.Items, dbCheckItem(err, dbURL)) - return - } - defer pool.Close() - - dbItems, err := check.Database(ctx, pool) - if err != nil { - report.Items = append(report.Items, check.Item{ - Name: "database", - Status: dbCheckStatus(err, dbURL), - Detail: err.Error(), - }) - return - } - report.Items = append(report.Items, dbItems...) -} - -func dbCheckItem(err error, dbURL string) check.Item { - return check.Item{ - Name: "database", - Status: dbCheckStatus(err, dbURL), - Detail: err.Error(), - } -} - -func dbCheckStatus(err error, dbURL string) check.Status { - if err == nil { - return check.StatusOK - } - msg := strings.ToLower(err.Error()) - if isDockerInternalHost(dbURL) && (strings.Contains(msg, "no such host") || - strings.Contains(msg, "name or service not known") || - strings.Contains(msg, "hostname resolving")) { - return check.StatusWarn - } - return check.StatusError -} - -func isDockerInternalHost(dbURL string) bool { - u, err := url.Parse(dbURL) - if err != nil { - return false - } - host := u.Hostname() - return host == "postgres" || host == "db" || host == "shop-postgres" -} - -func printSummary(r check.Report) { - fmt.Printf("ShopNova %s | %s\n\n", r.AppVersion, r.GoVersion) - for _, it := range r.Items { - mark := "✓" - switch it.Status { - case check.StatusWarn: - mark = "!" - case check.StatusError: - mark = "✗" - } - line := fmt.Sprintf(" %s %-18s %s", mark, it.Name+":", it.Detail) - if it.Expected != "" { - line += " (ожидается " + it.Expected + ")" - } - fmt.Println(line) - } -} - -func loadDotEnv() { - root, _ := os.Getwd() - path := filepath.Join(root, ".env") - data, err := os.ReadFile(path) - if err != nil { - return - } - for _, line := range splitLines(string(data)) { - line = trimComment(line) - if line == "" { - continue - } - k, v, ok := splitKV(line) - if !ok { - continue - } - if os.Getenv(k) == "" { - _ = os.Setenv(k, v) - } - } -} - -func splitLines(s string) []string { - var lines []string - start := 0 - for i := 0; i < len(s); i++ { - if s[i] == '\n' { - lines = append(lines, s[start:i]) - start = i + 1 - } - } - if start < len(s) { - lines = append(lines, s[start:]) - } - return lines -} - -func trimComment(s string) string { - if i := indexByte(s, '#'); i >= 0 { - s = s[:i] - } - for len(s) > 0 && (s[len(s)-1] == ' ' || s[len(s)-1] == '\r') { - s = s[:len(s)-1] - } - for len(s) > 0 && s[0] == ' ' { - s = s[1:] - } - return s -} - -func splitKV(s string) (string, string, bool) { - for i := 0; i < len(s); i++ { - if s[i] == '=' { - return s[:i], s[i+1:], true - } - } - return "", "", false -} - -func indexByte(s string, c byte) int { - for i := 0; i < len(s); i++ { - if s[i] == c { - return i - } - } - return -1 -} diff --git a/cmd/install/main.go b/cmd/install/main.go deleted file mode 100644 index 2716522..0000000 --- a/cmd/install/main.go +++ /dev/null @@ -1,27 +0,0 @@ -package main - -import ( - "fmt" - "os" - "path/filepath" - - "shop/internal/setup" -) - -func main() { - root, err := os.Getwd() - if err != nil { - fmt.Fprintf(os.Stderr, "ошибка: %v\n", err) - os.Exit(1) - } - - if _, err := os.Stat(filepath.Join(root, "docker-compose.yml")); os.IsNotExist(err) { - fmt.Fprintln(os.Stderr, "запустите установщик из корня проекта (где docker-compose.yml)") - os.Exit(1) - } - - if _, err := setup.RunInteractive(root); err != nil { - fmt.Fprintf(os.Stderr, "установка не завершена: %v\n", err) - os.Exit(1) - } -} diff --git a/cmd/server/main.go b/cmd/server/main.go deleted file mode 100644 index 06c00f6..0000000 --- a/cmd/server/main.go +++ /dev/null @@ -1,163 +0,0 @@ -package main - -import ( - "context" - "html/template" - "io/fs" - "log" - "net/http" - "os" - "os/signal" - "syscall" - "time" - - "shop/internal/auth" - "shop/internal/check" - "shop/internal/config" - "shop/internal/database" - "shop/internal/handlers" - "shop/internal/repository" - "shop/internal/version" - "shop/internal/web" -) - -func main() { - cfg, err := config.Load() - if err != nil { - log.Fatalf("config: %v", err) - } - - ctx := context.Background() - pool, err := database.Connect(ctx, cfg.DatabaseURL) - if err != nil { - log.Fatalf("database: %v", err) - } - defer pool.Close() - - startupReport, err := check.WithDatabase(ctx, pool) - if err != nil { - log.Fatalf("version check: %v", err) - } - for _, it := range startupReport.Items { - if it.Name == "postgresql" && it.Status == check.StatusWarn { - log.Printf("warning: %s — %s", it.Name, it.Detail) - } - } - log.Printf("ShopNova %s | Go %s | PostgreSQL check OK", version.AppVersion, version.GoRuntime()) - - tmpl, err := loadTemplates() - if err != nil { - 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, 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 { - log.Fatalf("static fs: %v", err) - } - - mux := http.NewServeMux() - mux.Handle("GET /static/", http.StripPrefix("/static/", http.FileServer(http.FS(staticSub)))) - 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, - Handler: mux, - ReadTimeout: cfg.ReadTimeout, - WriteTimeout: cfg.WriteTimeout, - } - - go func() { - log.Printf("server listening on %s", cfg.HTTPAddr) - if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed { - log.Fatalf("listen: %v", err) - } - }() - - quit := make(chan os.Signal, 1) - signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM) - <-quit - - shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second) - defer cancel() - if err := srv.Shutdown(shutdownCtx); err != nil { - log.Printf("shutdown: %v", err) - } -} - -func loadTemplates() (*template.Template, error) { - funcMap := template.FuncMap{ - "formatPrice": func(v float64) string { - return formatRub(v) - }, - } - return template.New("").Funcs(funcMap).ParseFS(web.TemplatesFS, "templates/*.html") -} - -func formatRub(v float64) string { - intPart := int64(v) - frac := int64((v - float64(intPart)) * 100) - if frac < 0 { - frac = -frac - } - return formatThousands(intPart) + "," + pad2(frac) + " ₽" -} - -func formatThousands(n int64) string { - if n < 0 { - n = -n - } - s := "" - for n >= 1000 { - s = "," + pad3(n%1000) + s - n /= 1000 - } - return itoa(n) + s -} - -func pad3(n int64) string { - if n < 10 { - return "00" + itoa(n) - } - if n < 100 { - return "0" + itoa(n) - } - return itoa(n) -} - -func pad2(n int64) string { - if n < 10 { - return "0" + itoa(n) - } - return itoa(n) -} - -func itoa(n int64) string { - if n == 0 { - return "0" - } - var b [20]byte - i := len(b) - for n > 0 { - i-- - b[i] = byte('0' + n%10) - n /= 10 - } - return string(b[i:]) -} diff --git a/docker-compose.yml b/docker-compose.yml deleted file mode 100644 index 8161360..0000000 --- a/docker-compose.yml +++ /dev/null @@ -1,94 +0,0 @@ -services: - ssl-init: - image: alpine:3.20 - container_name: shop-ssl-init - volumes: - - postgres_ssl:/certs - - ./postgres/ssl/generate-certs.sh:/generate-certs.sh:ro - entrypoint: ["/bin/sh", "-c", "apk add --no-cache openssl > /dev/null && sh /generate-certs.sh /certs"] - restart: "no" - - postgres: - image: postgres:17-alpine - container_name: shop-postgres - depends_on: - ssl-init: - condition: service_completed_successfully - environment: - POSTGRES_USER: ${POSTGRES_USER:-shop} - POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-shop_secret} - POSTGRES_DB: ${POSTGRES_DB:-shopdb} - volumes: - - postgres_data:/var/lib/postgresql/data - - postgres_ssl:/var/lib/postgresql/ssl:ro - - ./postgres/init:/docker-entrypoint-initdb.d:ro - command: - - postgres - - -c - - ssl=on - - -c - - ssl_cert_file=/var/lib/postgresql/ssl/server.crt - - -c - - ssl_key_file=/var/lib/postgresql/ssl/server.key - - -c - - ssl_min_protocol_version=TLSv1.2 - healthcheck: - test: ["CMD-SHELL", "pg_isready -U $$POSTGRES_USER -d $$POSTGRES_DB"] - interval: 5s - timeout: 5s - retries: 10 - start_period: 15s - networks: - - backend - restart: unless-stopped - - app: - build: - context: . - dockerfile: Dockerfile - container_name: shop-app - depends_on: - postgres: - condition: service_healthy - environment: - APP_PORT: "8080" - DATABASE_URL: postgres://${POSTGRES_USER:-shop}:${POSTGRES_PASSWORD:-shop_secret}@postgres:5432/${POSTGRES_DB:-shopdb}?sslmode=require - COOKIE_SECURE: ${COOKIE_SECURE:-false} - networks: - - backend - - frontend - restart: unless-stopped - - traefik: - image: traefik:v3.2 - container_name: shop-traefik - depends_on: - - app - command: - - --log.level=INFO - - --accesslog=true - - --providers.file.directory=/etc/traefik/dynamic - - --providers.file.watch=true - - --entrypoints.web.address=:80 - - --entrypoints.websecure.address=:443 - - --certificatesresolvers.letsencrypt.acme.email=${ACME_EMAIL:-admin@localhost} - - --certificatesresolvers.letsencrypt.acme.storage=/letsencrypt/acme.json - - --certificatesresolvers.letsencrypt.acme.httpchallenge.entrypoint=web - ports: - - "${HTTP_PORT:-80}:80" - - "${HTTPS_PORT:-443}:443" - volumes: - - ./traefik/dynamic:/etc/traefik/dynamic:ro - - traefik_letsencrypt:/letsencrypt - networks: - - frontend - restart: unless-stopped - -volumes: - postgres_data: - postgres_ssl: - traefik_letsencrypt: - -networks: - backend: - frontend: diff --git a/go.mod b/go.mod deleted file mode 100644 index a613f61..0000000 --- a/go.mod +++ /dev/null @@ -1,16 +0,0 @@ -module shop - -go 1.22 - -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/sync v0.10.0 // indirect - golang.org/x/text v0.21.0 // indirect -) diff --git a/go.sum b/go.sum deleted file mode 100644 index 731b5df..0000000 --- a/go.sum +++ /dev/null @@ -1,28 +0,0 @@ -github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= -github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= -github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= -github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= -github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= -github.com/jackc/pgx/v5 v5.7.2 h1:mLoDLV6sonKlvjIEsV56SkWNCnuNv531l94GaIzO+XI= -github.com/jackc/pgx/v5 v5.7.2/go.mod h1:ncY89UGWxg82EykZUwSpUKEfccBGGYq1xjrOpsbsfGQ= -github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= -github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= -github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= -github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= -github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= -github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= -github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk= -github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= -golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U= -golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk= -golang.org/x/sync v0.10.0 h1:3NQrjDixjgGwUOCaF8w2+VYHv0Ve/vGYSbdkTa98gmQ= -golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= -golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo= -golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= -gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= -gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= -gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/install.ps1 b/install.ps1 deleted file mode 100644 index 58e7764..0000000 --- a/install.ps1 +++ /dev/null @@ -1,4 +0,0 @@ -# Интерактивная установка: домен, база данных, .env, Traefik -$ErrorActionPreference = "Stop" -Set-Location $PSScriptRoot -go run ./cmd/install diff --git a/install.sh b/install.sh deleted file mode 100755 index 84e1f1d..0000000 --- a/install.sh +++ /dev/null @@ -1,9 +0,0 @@ -#!/bin/sh -set -e -cd "$(dirname "$0")" -if command -v go >/dev/null 2>&1; then - go run ./cmd/install -else - echo "Go не найден — запуск через Docker..." - docker run --rm -it -v "$(pwd):/app" -w /app golang:1.22-alpine go run ./cmd/install -fi diff --git a/internal/auth/cookies.go b/internal/auth/cookies.go deleted file mode 100644 index f51cdf2..0000000 --- a/internal/auth/cookies.go +++ /dev/null @@ -1,3 +0,0 @@ -package auth - -const SessionCookieName = "shop_session" diff --git a/internal/auth/password.go b/internal/auth/password.go deleted file mode 100644 index 680639b..0000000 --- a/internal/auth/password.go +++ /dev/null @@ -1,27 +0,0 @@ -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 deleted file mode 100644 index ef4adbd..0000000 --- a/internal/auth/service.go +++ /dev/null @@ -1,137 +0,0 @@ -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/check/check.go b/internal/check/check.go deleted file mode 100644 index f09e833..0000000 --- a/internal/check/check.go +++ /dev/null @@ -1,176 +0,0 @@ -package check - -import ( - "context" - "fmt" - "os/exec" - "regexp" - "strconv" - "strings" - "time" - - "github.com/jackc/pgx/v5/pgxpool" - - "shop/internal/version" -) - -type Status string - -const ( - StatusOK Status = "ok" - StatusWarn Status = "warn" - StatusError Status = "error" -) - -type Item struct { - Name string `json:"name"` - Status Status `json:"status"` - Detail string `json:"detail"` - Expected string `json:"expected,omitempty"` -} - -type Report struct { - AppVersion string `json:"app_version"` - GoVersion string `json:"go_version"` - Items []Item `json:"checks"` -} - -func (r Report) Healthy() bool { - for _, it := range r.Items { - if it.Status == StatusError { - return false - } - } - return true -} - -func AppInfo() Report { - return Report{ - AppVersion: version.AppVersion, - GoVersion: version.GoRuntime(), - Items: []Item{ - { - Name: "go_runtime", - Status: goRuntimeStatus(), - Detail: version.GoRuntime(), - Expected: ">=" + version.MinGoVersion, - }, - }, - } -} - -func goRuntimeStatus() Status { - v := strings.TrimPrefix(version.GoRuntime(), "go") - major, minor, ok := parseGoVersion(v) - if !ok { - return StatusWarn - } - expMajor, expMinor, _ := parseGoVersion(version.MinGoVersion) - if major > expMajor || (major == expMajor && minor >= expMinor) { - return StatusOK - } - return StatusWarn -} - -func parseGoVersion(v string) (major, minor int, ok bool) { - parts := strings.Split(v, ".") - if len(parts) < 2 { - return 0, 0, false - } - major, err1 := strconv.Atoi(parts[0]) - minor, err2 := strconv.Atoi(parts[1]) - return major, minor, err1 == nil && err2 == nil -} - -func WithDatabase(ctx context.Context, pool *pgxpool.Pool) (Report, error) { - r := AppInfo() - dbItems, err := Database(ctx, pool) - if err != nil { - return r, err - } - r.Items = append(r.Items, dbItems...) - return r, nil -} - -func Database(ctx context.Context, pool *pgxpool.Pool) ([]Item, error) { - ctx, cancel := context.WithTimeout(ctx, 5*time.Second) - defer cancel() - - var pgVersion string - if err := pool.QueryRow(ctx, `SHOW server_version`).Scan(&pgVersion); err != nil { - return []Item{{ - Name: "database", - Status: StatusError, - Detail: err.Error(), - }}, nil - } - - major, ok := postgresMajor(pgVersion) - pgStatus := StatusOK - detail := pgVersion - expected := fmt.Sprintf("%d.x", version.ExpectedPostgresMajor) - - if !ok { - pgStatus = StatusWarn - detail = pgVersion + " (не удалось определить major)" - } else if major != version.ExpectedPostgresMajor { - pgStatus = StatusWarn - detail = fmt.Sprintf("%s (ожидается PostgreSQL %d)", pgVersion, version.ExpectedPostgresMajor) - } - - return []Item{ - {Name: "database", Status: StatusOK, Detail: "подключено"}, - {Name: "postgresql", Status: pgStatus, Detail: detail, Expected: expected}, - }, nil -} - -var pgMajorRe = regexp.MustCompile(`^(\d+)`) - -func postgresMajor(v string) (int, bool) { - m := pgMajorRe.FindStringSubmatch(strings.TrimSpace(v)) - if len(m) < 2 { - return 0, false - } - n, err := strconv.Atoi(m[1]) - return n, err == nil -} - -func ToolVersions(ctx context.Context) []Item { - var items []Item - if out, err := run(ctx, "docker", "version", "--format", "{{.Server.Version}}"); err == nil { - items = append(items, Item{Name: "docker", Status: StatusOK, Detail: strings.TrimSpace(out)}) - } else { - items = append(items, Item{Name: "docker", Status: StatusWarn, Detail: "не найден"}) - } - if out, err := run(ctx, "docker", "compose", "version", "--short"); err == nil { - items = append(items, Item{Name: "docker_compose", Status: StatusOK, Detail: strings.TrimSpace(out)}) - } else { - items = append(items, Item{Name: "docker_compose", Status: StatusWarn, Detail: "не найден"}) - } - return items -} - -func run(ctx context.Context, name string, args ...string) (string, error) { - ctx, cancel := context.WithTimeout(ctx, 8*time.Second) - defer cancel() - cmd := exec.CommandContext(ctx, name, args...) - out, err := cmd.Output() - return string(out), err -} - -func Merge(reports ...Report) Report { - if len(reports) == 0 { - return AppInfo() - } - out := reports[0] - for _, r := range reports[1:] { - if out.AppVersion == "" { - out.AppVersion = r.AppVersion - } - if out.GoVersion == "" { - out.GoVersion = r.GoVersion - } - out.Items = append(out.Items, r.Items...) - } - return out -} diff --git a/internal/config/config.go b/internal/config/config.go deleted file mode 100644 index 517b7f6..0000000 --- a/internal/config/config.go +++ /dev/null @@ -1,61 +0,0 @@ -package config - -import ( - "fmt" - "os" - "strconv" - "time" -) - -type Config struct { - HTTPAddr string - DatabaseURL string - ReadTimeout time.Duration - WriteTimeout time.Duration - SessionTTL time.Duration - CookieSecure bool -} - -func Load() (Config, error) { - port := env("APP_PORT", "8080") - cfg := Config{ - HTTPAddr: ":" + port, - 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") - } - return cfg, nil -} - -func env(key, fallback string) string { - if v := os.Getenv(key); v != "" { - return v - } - 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 == "" { - return fallback - } - sec, err := strconv.Atoi(v) - if err != nil || sec <= 0 { - return fallback - } - return time.Duration(sec) * time.Second -} diff --git a/internal/database/database.go b/internal/database/database.go deleted file mode 100644 index c8460f4..0000000 --- a/internal/database/database.go +++ /dev/null @@ -1,36 +0,0 @@ -package database - -import ( - "context" - "fmt" - "time" - - "github.com/jackc/pgx/v5/pgxpool" -) - -func Connect(ctx context.Context, databaseURL string) (*pgxpool.Pool, error) { - cfg, err := pgxpool.ParseConfig(databaseURL) - if err != nil { - return nil, fmt.Errorf("parse database url: %w", err) - } - - cfg.MaxConns = 10 - cfg.MinConns = 2 - cfg.MaxConnLifetime = time.Hour - cfg.HealthCheckPeriod = 30 * time.Second - - pool, err := pgxpool.NewWithConfig(ctx, cfg) - if err != nil { - return nil, fmt.Errorf("create pool: %w", err) - } - - ctx, cancel := context.WithTimeout(ctx, 10*time.Second) - defer cancel() - - if err := pool.Ping(ctx); err != nil { - pool.Close() - return nil, fmt.Errorf("ping database: %w", err) - } - - return pool, nil -} diff --git a/internal/handlers/account.go b/internal/handlers/account.go deleted file mode 100644 index e65d272..0000000 --- a/internal/handlers/account.go +++ /dev/null @@ -1,66 +0,0 @@ -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 deleted file mode 100644 index 3857057..0000000 --- a/internal/handlers/auth.go +++ /dev/null @@ -1,131 +0,0 @@ -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/health.go b/internal/handlers/health.go deleted file mode 100644 index d0e88c3..0000000 --- a/internal/handlers/health.go +++ /dev/null @@ -1,57 +0,0 @@ -package handlers - -import ( - "encoding/json" - "net/http" - - "github.com/jackc/pgx/v5/pgxpool" - - "shop/internal/check" -) - -type HealthHandler struct { - pool *pgxpool.Pool -} - -func NewHealthHandler(pool *pgxpool.Pool) *HealthHandler { - return &HealthHandler{pool: pool} -} - -func (h *HealthHandler) Health(w http.ResponseWriter, r *http.Request) { - ctx := r.Context() - report, err := check.WithDatabase(ctx, h.pool) - if err != nil { - writeJSON(w, http.StatusServiceUnavailable, map[string]any{ - "status": "error", - "error": err.Error(), - "version": report.AppVersion, - }) - return - } - - status := "ok" - code := http.StatusOK - if !report.Healthy() { - status = "degraded" - code = http.StatusServiceUnavailable - } - - writeJSON(w, code, map[string]any{ - "status": status, - "app_version": report.AppVersion, - "go_version": report.GoVersion, - "checks": report.Items, - }) -} - -func (h *HealthHandler) Version(w http.ResponseWriter, r *http.Request) { - ctx := r.Context() - report, _ := check.WithDatabase(ctx, h.pool) - writeJSON(w, http.StatusOK, report) -} - -func writeJSON(w http.ResponseWriter, code int, v any) { - w.Header().Set("Content-Type", "application/json; charset=utf-8") - w.WriteHeader(code) - _ = json.NewEncoder(w).Encode(v) -} diff --git a/internal/handlers/home.go b/internal/handlers/home.go deleted file mode 100644 index 73b0228..0000000 --- a/internal/handlers/home.go +++ /dev/null @@ -1,62 +0,0 @@ -package handlers - -import ( - "log" - "net/http" - - "shop/internal/models" - "shop/internal/repository" -) - -type HomeHandler struct { - products *repository.ProductRepository - pages *Pages -} - -func NewHomeHandler(products *repository.ProductRepository, pages *Pages) *HomeHandler { - return &HomeHandler{products: products, pages: pages} -} - -type homePageData struct { - Layout - Products []models.Product - Categories []string - TotalItems int -} - -func (h *HomeHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { - if r.URL.Path != "/" { - http.NotFound(w, r) - return - } - - ctx := r.Context() - featured, err := h.products.Featured(ctx, 8) - if err != nil { - log.Printf("featured products: %v", err) - http.Error(w, "Ошибка загрузки каталога", http.StatusInternalServerError) - return - } - - categories, err := h.products.Categories(ctx) - if err != nil { - log.Printf("categories: %v", err) - categories = nil - } - - total, err := h.products.Count(ctx) - if err != nil { - log.Printf("count: %v", err) - total = 0 - } - - data := homePageData{ - Layout: h.pages.layout(r, "Главная", "home"), - Products: featured, - Categories: categories, - TotalItems: total, - } - data.Success = flashMsg(r, "ok") - - h.pages.render(w, "home.html", data) -} diff --git a/internal/handlers/page.go b/internal/handlers/page.go deleted file mode 100644 index 4a641f7..0000000 --- a/internal/handlers/page.go +++ /dev/null @@ -1,53 +0,0 @@ -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/product.go b/internal/models/product.go deleted file mode 100644 index 15a4e89..0000000 --- a/internal/models/product.go +++ /dev/null @@ -1,11 +0,0 @@ -package models - -type Product struct { - ID int - Name string - Description string - Price float64 - ImageURL string - Category string - Featured bool -} diff --git a/internal/models/user.go b/internal/models/user.go deleted file mode 100644 index 73902e7..0000000 --- a/internal/models/user.go +++ /dev/null @@ -1,10 +0,0 @@ -package models - -import "time" - -type User struct { - ID int - Email string - Name string - CreatedAt time.Time -} diff --git a/internal/repository/products.go b/internal/repository/products.go deleted file mode 100644 index 0356fd9..0000000 --- a/internal/repository/products.go +++ /dev/null @@ -1,65 +0,0 @@ -package repository - -import ( - "context" - - "github.com/jackc/pgx/v5/pgxpool" - - "shop/internal/models" -) - -type ProductRepository struct { - pool *pgxpool.Pool -} - -func NewProductRepository(pool *pgxpool.Pool) *ProductRepository { - return &ProductRepository{pool: pool} -} - -func (r *ProductRepository) Featured(ctx context.Context, limit int) ([]models.Product, error) { - rows, err := r.pool.Query(ctx, ` - SELECT id, name, description, price, image_url, category, featured - FROM products - WHERE featured = true - ORDER BY id - LIMIT $1`, limit) - if err != nil { - return nil, err - } - defer rows.Close() - - var items []models.Product - for rows.Next() { - var p models.Product - if err := rows.Scan(&p.ID, &p.Name, &p.Description, &p.Price, &p.ImageURL, &p.Category, &p.Featured); err != nil { - return nil, err - } - items = append(items, p) - } - return items, rows.Err() -} - -func (r *ProductRepository) Categories(ctx context.Context) ([]string, error) { - rows, err := r.pool.Query(ctx, ` - SELECT DISTINCT category FROM products ORDER BY category`) - if err != nil { - return nil, err - } - defer rows.Close() - - var cats []string - for rows.Next() { - var c string - if err := rows.Scan(&c); err != nil { - return nil, err - } - cats = append(cats, c) - } - return cats, rows.Err() -} - -func (r *ProductRepository) Count(ctx context.Context) (int, error) { - var n int - err := r.pool.QueryRow(ctx, `SELECT COUNT(*) FROM products`).Scan(&n) - return n, err -} diff --git a/internal/repository/sessions.go b/internal/repository/sessions.go deleted file mode 100644 index 389e47d..0000000 --- a/internal/repository/sessions.go +++ /dev/null @@ -1,44 +0,0 @@ -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 deleted file mode 100644 index b004a65..0000000 --- a/internal/repository/users.go +++ /dev/null @@ -1,80 +0,0 @@ -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/setup/setup.go b/internal/setup/setup.go deleted file mode 100644 index 0ba425d..0000000 --- a/internal/setup/setup.go +++ /dev/null @@ -1,223 +0,0 @@ -package setup - -import ( - "bufio" - "fmt" - "net/url" - "os" - "path/filepath" - "strings" -) - -type Config struct { - SiteDomain string - AcmeEmail string - HTTPPort string - HTTPSPort string - UseDockerDB bool - DBHost string - DBPort string - DBUser string - DBPassword string - DBName string - DBSSLMode string -} - -func RunInteractive(root string) (Config, error) { - in := bufio.NewReader(os.Stdin) - fmt.Println("=== Установщик ShopNova ===") - fmt.Println() - - cfg := Config{} - - cfg.SiteDomain = ask(in, "Домен сайта (например shop.example.com, Enter = localhost)", "localhost") - cfg.AcmeEmail = ask(in, "Email для Let's Encrypt (Traefik)", "admin@localhost") - cfg.HTTPPort = ask(in, "HTTP порт", "80") - cfg.HTTPSPort = ask(in, "HTTPS порт", "443") - - useDocker := askYesNo(in, "Использовать PostgreSQL из Docker Compose?", true) - cfg.UseDockerDB = useDocker - - if useDocker { - cfg.DBHost = "postgres" - cfg.DBPort = "5432" - cfg.DBSSLMode = "require" - fmt.Println("\n--- База данных (контейнер postgres) ---") - } else { - fmt.Println("\n--- База данных (внешний сервер) ---") - cfg.DBHost = ask(in, "Хост БД", "localhost") - cfg.DBPort = ask(in, "Порт БД", "5432") - cfg.DBSSLMode = ask(in, "SSL mode (disable|require|verify-full)", "require") - } - - cfg.DBUser = ask(in, "Пользователь БД", "shop") - cfg.DBPassword = askPassword(in, "Пароль БД") - cfg.DBName = ask(in, "Имя базы данных", "shopdb") - - if err := WriteFiles(root, cfg); err != nil { - return cfg, err - } - - fmt.Println("\n✓ Созданы файлы: .env, traefik/dynamic/shop.yml") - fmt.Println("\nДальше (на сервере):") - fmt.Println(" docker compose up --build -d") - fmt.Println(" ./check.sh --after-start") - if !useLocalDomain(cfg.SiteDomain) { - fmt.Printf(" Сайт: https://%s\n", cfg.SiteDomain) - } else { - fmt.Printf(" Сайт: http://localhost:%s\n", cfg.HTTPPort) - } - return cfg, nil -} - -func WriteFiles(root string, cfg Config) error { - envPath := filepath.Join(root, ".env") - traefikPath := filepath.Join(root, "traefik", "dynamic", "shop.yml") - - if err := os.WriteFile(envPath, []byte(buildEnv(cfg)), 0o600); err != nil { - return fmt.Errorf("write .env: %w", err) - } - if err := os.MkdirAll(filepath.Dir(traefikPath), 0o755); err != nil { - return err - } - if err := os.WriteFile(traefikPath, []byte(buildTraefikDynamic(cfg)), 0o644); err != nil { - return fmt.Errorf("write traefik config: %w", err) - } - return nil -} - -func buildEnv(cfg Config) string { - dbURL := DatabaseURL(cfg) - cookieSecure := "false" - if !useLocalDomain(cfg.SiteDomain) { - cookieSecure = "true" - } - lines := []string{ - "# Сгенерировано установщиком ShopNova", - fmt.Sprintf("SITE_DOMAIN=%s", cfg.SiteDomain), - fmt.Sprintf("ACME_EMAIL=%s", cfg.AcmeEmail), - fmt.Sprintf("HTTP_PORT=%s", cfg.HTTPPort), - fmt.Sprintf("HTTPS_PORT=%s", cfg.HTTPSPort), - "", - fmt.Sprintf("POSTGRES_USER=%s", cfg.DBUser), - fmt.Sprintf("POSTGRES_PASSWORD=%s", cfg.DBPassword), - fmt.Sprintf("POSTGRES_DB=%s", cfg.DBName), - "", - fmt.Sprintf("DATABASE_URL=%s", dbURL), - "APP_PORT=8080", - fmt.Sprintf("COOKIE_SECURE=%s", cookieSecure), - "SESSION_TTL_HOURS=168", - "", - fmt.Sprintf("DB_HOST=%s", cfg.DBHost), - fmt.Sprintf("DB_PORT=%s", cfg.DBPort), - fmt.Sprintf("DB_SSLMODE=%s", cfg.DBSSLMode), - } - return strings.Join(lines, "\n") + "\n" -} - -func DatabaseURL(cfg Config) string { - u := &url.URL{ - Scheme: "postgres", - User: url.UserPassword(cfg.DBUser, cfg.DBPassword), - Host: fmt.Sprintf("%s:%s", cfg.DBHost, cfg.DBPort), - Path: cfg.DBName, - } - q := u.Query() - q.Set("sslmode", cfg.DBSSLMode) - u.RawQuery = q.Encode() - return u.String() -} - -func buildTraefikDynamic(cfg Config) string { - domain := strings.TrimSpace(cfg.SiteDomain) - if useLocalDomain(domain) { - return fmt.Sprintf(`# Сгенерировано установщиком ShopNova (localhost) -http: - routers: - shop: - rule: "Host(`+"`%s`"+`)" - entryPoints: [web] - middlewares: [gzip] - service: shop - middlewares: - gzip: - compress: {} - services: - shop: - loadBalancer: - servers: - - url: "http://app:8080" -`, domain) - } - - return fmt.Sprintf(`# Сгенерировано установщиком ShopNova -http: - routers: - shop-http: - rule: "Host(`+"`%s`"+`)" - entryPoints: [web] - middlewares: [redirect-https] - service: shop - shop: - rule: "Host(`+"`%s`"+`)" - entryPoints: [websecure] - middlewares: [gzip] - service: shop - tls: - certResolver: letsencrypt - middlewares: - redirect-https: - redirectScheme: - scheme: https - permanent: true - gzip: - compress: {} - services: - shop: - loadBalancer: - servers: - - url: "http://app:8080" -`, domain, domain) -} - -func useLocalDomain(d string) bool { - d = strings.ToLower(strings.TrimSpace(d)) - return d == "" || d == "localhost" || d == "127.0.0.1" || d == "local" -} - -func ask(in *bufio.Reader, prompt, def string) string { - if def != "" { - fmt.Printf("%s [%s]: ", prompt, def) - } else { - fmt.Printf("%s: ", prompt) - } - line, _ := in.ReadString('\n') - line = strings.TrimSpace(line) - if line == "" { - return def - } - return line -} - -func askPassword(in *bufio.Reader, prompt string) string { - fmt.Printf("%s: ", prompt) - line, _ := in.ReadString('\n') - return strings.TrimSpace(line) -} - -func askYesNo(in *bufio.Reader, prompt string, def bool) bool { - defStr := "y" - if !def { - defStr = "n" - } - for { - ans := strings.ToLower(ask(in, prompt+" (y/n)", defStr)) - switch ans { - case "y", "yes", "д", "да": - return true - case "n", "no", "н", "нет": - return false - } - fmt.Println("Введите y или n") - } -} diff --git a/internal/version/version.go b/internal/version/version.go deleted file mode 100644 index bac2489..0000000 --- a/internal/version/version.go +++ /dev/null @@ -1,13 +0,0 @@ -package version - -import "runtime" - -const ( - AppVersion = "0.20" - ExpectedPostgresMajor = 17 - MinGoVersion = "1.22" -) - -func GoRuntime() string { - return runtime.Version() -} diff --git a/internal/web/embed.go b/internal/web/embed.go deleted file mode 100644 index d66769d..0000000 --- a/internal/web/embed.go +++ /dev/null @@ -1,9 +0,0 @@ -package web - -import "embed" - -//go:embed templates/* -var TemplatesFS embed.FS - -//go:embed static/* -var StaticFS embed.FS diff --git a/internal/web/static/css/style.css b/internal/web/static/css/style.css deleted file mode 100644 index 5c9ef0e..0000000 --- a/internal/web/static/css/style.css +++ /dev/null @@ -1,615 +0,0 @@ -:root { - --bg: #0f0f12; - --bg-elevated: #1a1a21; - --surface: #23232d; - --text: #f4f4f6; - --text-muted: #9b9bab; - --accent: #e8c547; - --accent-hover: #f5d76a; - --border: rgba(255, 255, 255, 0.08); - --radius: 14px; - --shadow: 0 24px 48px rgba(0, 0, 0, 0.45); - --font: "DM Sans", system-ui, sans-serif; - --font-display: "Instrument Serif", Georgia, serif; -} - -*, -*::before, -*::after { - box-sizing: border-box; -} - -html { - scroll-behavior: smooth; -} - -body { - margin: 0; - font-family: var(--font); - background: var(--bg); - color: var(--text); - line-height: 1.6; - min-height: 100vh; -} - -.container { - width: min(1120px, 92vw); - margin-inline: auto; -} - -a { - color: inherit; - text-decoration: none; -} - -/* Header */ -.site-header { - position: sticky; - top: 0; - z-index: 100; - backdrop-filter: blur(12px); - background: rgba(15, 15, 18, 0.85); - border-bottom: 1px solid var(--border); -} - -.header-inner { - display: flex; - align-items: center; - justify-content: space-between; - gap: 1.5rem; - padding: 1rem 0; -} - -.logo { - font-family: var(--font-display); - font-size: 1.5rem; - font-weight: 400; - letter-spacing: -0.02em; -} - -.logo span { - color: var(--accent); -} - -.nav { - display: flex; - gap: 1.75rem; -} - -.nav-link { - font-size: 0.95rem; - color: var(--text-muted); - transition: color 0.2s; -} - -.nav-link:hover, -.nav-link.active { - color: var(--text); -} - -.header-actions { - display: flex; - gap: 0.75rem; -} - -/* Buttons */ -.btn { - display: inline-flex; - align-items: center; - justify-content: center; - padding: 0.55rem 1.1rem; - font-family: inherit; - font-size: 0.9rem; - font-weight: 500; - border-radius: 999px; - border: none; - cursor: pointer; - transition: background 0.2s, transform 0.15s; -} - -.btn:active { - transform: scale(0.98); -} - -.btn-primary { - background: var(--accent); - color: #1a1508; -} - -.btn-primary:hover { - background: var(--accent-hover); -} - -.btn-ghost { - background: transparent; - color: var(--text); - border: 1px solid var(--border); -} - -.btn-ghost:hover { - background: var(--surface); -} - -.btn-lg { - padding: 0.85rem 1.5rem; - font-size: 1rem; -} - -.btn-sm { - padding: 0.4rem 0.85rem; - font-size: 0.82rem; -} - -/* Hero */ -.hero { - padding: 4rem 0 5rem; - background: - radial-gradient(ellipse 80% 60% at 70% 20%, rgba(232, 197, 71, 0.12), transparent), - var(--bg); -} - -.hero-grid { - display: grid; - grid-template-columns: 1fr 1fr; - gap: 3rem; - align-items: center; -} - -.eyebrow { - text-transform: uppercase; - letter-spacing: 0.14em; - font-size: 0.75rem; - color: var(--accent); - margin: 0 0 1rem; -} - -.hero-title { - font-family: var(--font-display); - font-size: clamp(2.5rem, 5vw, 3.75rem); - font-weight: 400; - line-height: 1.1; - margin: 0 0 1.25rem; -} - -.hero-title em { - font-style: italic; - color: var(--accent); -} - -.hero-lead { - color: var(--text-muted); - font-size: 1.1rem; - max-width: 28ch; - margin: 0 0 2rem; -} - -.hero-cta { - display: flex; - flex-wrap: wrap; - gap: 1rem; -} - -.hero-visual { - position: relative; - min-height: 320px; -} - -.hero-card { - position: absolute; - border-radius: var(--radius); - box-shadow: var(--shadow); -} - -.hero-card-a { - inset: 10% 20% 25% 0; - background: linear-gradient(145deg, #3d3d4a, #23232d); - border: 1px solid var(--border); -} - -.hero-card-b { - inset: 35% 0 0 25%; - background: linear-gradient(145deg, rgba(232, 197, 71, 0.35), #2a2820); - border: 1px solid rgba(232, 197, 71, 0.25); -} - -/* Sections */ -.section { - padding: 4rem 0; -} - -.section-title { - font-family: var(--font-display); - font-size: 2rem; - font-weight: 400; - margin: 0 0 0.5rem; -} - -.section-sub { - color: var(--text-muted); - margin: 0 0 2rem; -} - -.section-head { - margin-bottom: 2rem; -} - -/* Categories */ -.category-list { - display: flex; - flex-wrap: wrap; - gap: 0.75rem; - list-style: none; - padding: 0; - margin: 0; -} - -.category-chip { - display: inline-block; - padding: 0.5rem 1rem; - background: var(--surface); - border: 1px solid var(--border); - border-radius: 999px; - font-size: 0.9rem; - transition: border-color 0.2s, background 0.2s; -} - -.category-chip:hover { - border-color: var(--accent); - background: var(--bg-elevated); -} - -/* Products */ -.product-grid { - display: grid; - grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); - gap: 1.5rem; -} - -.product-card { - background: var(--bg-elevated); - border: 1px solid var(--border); - border-radius: var(--radius); - overflow: hidden; - transition: transform 0.25s, box-shadow 0.25s; -} - -.product-card:hover { - transform: translateY(-4px); - box-shadow: var(--shadow); -} - -.product-image { - aspect-ratio: 4 / 3; - background-size: cover; - background-position: center; - background-color: var(--surface); -} - -.product-body { - padding: 1.25rem; -} - -.product-category { - font-size: 0.75rem; - text-transform: uppercase; - letter-spacing: 0.08em; - color: var(--accent); -} - -.product-name { - font-size: 1.1rem; - margin: 0.35rem 0 0.5rem; -} - -.product-desc { - font-size: 0.88rem; - color: var(--text-muted); - margin: 0 0 1rem; - display: -webkit-box; - -webkit-line-clamp: 2; - -webkit-box-orient: vertical; - overflow: hidden; -} - -.product-footer { - display: flex; - align-items: center; - justify-content: space-between; - gap: 0.75rem; -} - -.product-price { - font-weight: 600; - font-size: 1.05rem; -} - -.empty-state { - text-align: center; - color: var(--text-muted); - padding: 3rem; - background: var(--bg-elevated); - border-radius: var(--radius); - border: 1px dashed var(--border); -} - -/* Features */ -.features { - background: var(--bg-elevated); - border-block: 1px solid var(--border); -} - -.features-grid { - display: grid; - grid-template-columns: repeat(3, 1fr); - gap: 2rem; -} - -.feature h3 { - margin: 0 0 0.5rem; - font-size: 1.1rem; -} - -.feature p { - margin: 0; - color: var(--text-muted); - font-size: 0.95rem; -} - -/* Footer */ -.site-footer { - padding: 2.5rem 0; - border-top: 1px solid var(--border); -} - -.footer-inner { - display: flex; - flex-wrap: wrap; - justify-content: space-between; - gap: 1rem; -} - -.footer-brand { - font-family: var(--font-display); - font-size: 1.25rem; - margin: 0; -} - -.footer-copy { - margin: 0; - color: var(--text-muted); - font-size: 0.9rem; -} - -@media (max-width: 768px) { - .nav { - display: none; - } - - .hero-grid { - grid-template-columns: 1fr; - } - - .hero-visual { - min-height: 200px; - } - - .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 deleted file mode 100644 index 735e60d..0000000 --- a/internal/web/templates/account.html +++ /dev/null @@ -1,46 +0,0 @@ -{{define "account.html"}} -{{template "layout" .}} -{{end}} - -{{define "content"}} - -{{end}} diff --git a/internal/web/templates/home.html b/internal/web/templates/home.html deleted file mode 100644 index 1ab30dc..0000000 --- a/internal/web/templates/home.html +++ /dev/null @@ -1,82 +0,0 @@ -{{define "home.html"}} -{{template "layout" .}} -{{end}} - -{{define "content"}} -
-
-
-

Новая коллекция 2026

-

Стиль, который
остаётся с вами

-

Качественные товары с быстрой доставкой. В каталоге уже {{.TotalItems}} позиций.

- -
- -
-
- -{{if .Categories}} -
-
-

Категории

-
    - {{range .Categories}} -
  • {{.}}
  • - {{end}} -
-
-
-{{end}} - -
-
-
-

Популярные товары

-

Избранное из нашего ассортимента

-
- {{if .Products}} -
- {{range .Products}} -
-
-
- {{.Category}} -

{{.Name}}

-

{{.Description}}

- -
-
- {{end}} -
- {{else}} -

Товары скоро появятся. Проверьте подключение к базе данных.

- {{end}} -
-
- -
-
-
-

Быстрая доставка

-

Отправка в день заказа по всей России.

-
-
-

Гарантия качества

-

14 дней на возврат без лишних вопросов.

-
-
-

Безопасная оплата

-

Шифрование данных и защищённые платежи.

-
-
-
-{{end}} diff --git a/internal/web/templates/layout.html b/internal/web/templates/layout.html deleted file mode 100644 index abe8f57..0000000 --- a/internal/web/templates/layout.html +++ /dev/null @@ -1,50 +0,0 @@ -{{define "layout"}} - - - - - - {{.Title}} — ShopNova - - - - - - - - {{if .Success}}

{{.Success}}

{{end}} - {{if .Error}}

{{.Error}}

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

Вход

-

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

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

Регистрация

-

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

-
- - - - - -
- -
-
-
-{{end}} diff --git a/package.json b/package.json new file mode 100644 index 0000000..71fdaff --- /dev/null +++ b/package.json @@ -0,0 +1,22 @@ +{ + "name": "shop", + "version": "1.0.0", + "description": "Интернет-магазин на Node.js с локальной SQLite", + "main": "src/server.js", + "scripts": { + "start": "node src/server.js", + "dev": "node --watch src/server.js", + "seed": "node src/seed.js" + }, + "engines": { + "node": ">=18" + }, + "dependencies": { + "bcryptjs": "^2.4.3", + "better-sqlite3": "^11.7.0", + "connect-sqlite3": "^0.9.15", + "ejs": "^3.1.10", + "express": "^4.21.2", + "express-session": "^1.18.1" + } +} diff --git a/postgres/init/01_schema.sql b/postgres/init/01_schema.sql deleted file mode 100644 index f9ab2b3..0000000 --- a/postgres/init/01_schema.sql +++ /dev/null @@ -1,23 +0,0 @@ -CREATE TABLE IF NOT EXISTS products ( - id SERIAL PRIMARY KEY, - name VARCHAR(200) NOT NULL, - description TEXT NOT NULL DEFAULT '', - price NUMERIC(10, 2) NOT NULL CHECK (price >= 0), - image_url TEXT NOT NULL DEFAULT '', - category VARCHAR(100) NOT NULL DEFAULT 'Разное', - featured BOOLEAN NOT NULL DEFAULT false, - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() -); - -CREATE INDEX IF NOT EXISTS idx_products_featured ON products (featured) WHERE featured = true; -CREATE INDEX IF NOT EXISTS idx_products_category ON products (category); - -INSERT INTO products (name, description, price, image_url, category, featured) VALUES -('Куртка Urban Shell', 'Лёгкая ветровка с водоотталкивающим покрытием.', 8990.00, 'https://images.unsplash.com/photo-1544022613-e87ca75a784a?w=600&q=80', 'Одежда', true), -('Кроссовки Nova Run', 'Амортизация для бега и повседневной носки.', 7490.00, 'https://images.unsplash.com/photo-1542291026-7eec264c27ff?w=600&q=80', 'Обувь', true), -('Рюкзак City Pack', 'Отделение для ноутбука 15" и USB-порт.', 4590.00, 'https://images.unsplash.com/photo-1553062407-98eeb64c6a62?w=600&q=80', 'Аксессуары', true), -('Часы Minimal Steel', 'Корпус из нержавеющей стали, сапфировое стекло.', 12990.00, 'https://images.unsplash.com/photo-1523275335684-37898b6baf30?w=600&q=80', 'Аксессуары', true), -('Худи Soft Loop', 'Хлопок премиум, уютная посадка.', 3990.00, 'https://images.unsplash.com/photo-1556821840-3a63f95609a7?w=600&q=80', 'Одежда', true), -('Наушники Air Tone', 'Активное шумоподавление, 30 ч автономности.', 9990.00, 'https://images.unsplash.com/photo-1505740420928-5e560c06d30e?w=600&q=80', 'Электроника', true), -('Футболка Essential', 'Базовая модель из органического хлопка.', 1990.00, 'https://images.unsplash.com/photo-1521572163474-6864f9cf17ab?w=600&q=80', 'Одежда', false), -('Кеды Classic Low', 'Кожаный верх, резиновая подошва.', 5490.00, 'https://images.unsplash.com/photo-1460353581641-37baddab0fa2?w=600&q=80', 'Обувь', true); diff --git a/postgres/init/02_users.sql b/postgres/init/02_users.sql deleted file mode 100644 index 56c1ab0..0000000 --- a/postgres/init/02_users.sql +++ /dev/null @@ -1,17 +0,0 @@ -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); diff --git a/postgres/ssl/generate-certs.sh b/postgres/ssl/generate-certs.sh deleted file mode 100755 index 1850c7f..0000000 --- a/postgres/ssl/generate-certs.sh +++ /dev/null @@ -1,26 +0,0 @@ -#!/bin/sh -set -e - -DIR="${1:-/certs}" -CRT="$DIR/server.crt" -KEY="$DIR/server.key" - -if [ -f "$CRT" ] && [ -f "$KEY" ]; then - echo "SSL certificates already exist in $DIR" - exit 0 -fi - -mkdir -p "$DIR" - -openssl req -new -x509 -days 825 -nodes -text \ - -out "$CRT" \ - -keyout "$KEY" \ - -subj "/CN=postgres.shop.local" - -chmod 600 "$KEY" -chmod 644 "$CRT" - -# postgres image runs as uid 999 -chown 999:999 "$KEY" "$CRT" 2>/dev/null || true - -echo "Generated PostgreSQL SSL certificates in $DIR" diff --git a/push-to-gitea.ps1 b/push-to-gitea.ps1 deleted file mode 100644 index fc764c3..0000000 --- a/push-to-gitea.ps1 +++ /dev/null @@ -1,36 +0,0 @@ -# Push в https://git.evilfox.cc/test/shop3.git -# При запросе введите логин и пароль (или токен Gitea) от git.evilfox.cc - -$ErrorActionPreference = "Stop" -Set-Location $PSScriptRoot - -$git = Get-Command git -ErrorAction SilentlyContinue -if (-not $git) { - $git = "C:\Program Files\Git\bin\git.exe" - if (-not (Test-Path $git)) { - Write-Error "Git не найден. Установите: winget install Git.Git" - } -} else { - $git = $git.Source -} - -$remote = "https://git.evilfox.cc/test/shop3.git" - -if (-not (Test-Path .git)) { - & $git init -b main -} - -& $git add -A -$status = & $git status --porcelain -if ($status) { - & $git commit -m "Интернет-магазин: Go, PostgreSQL 17 SSL, Caddy, Docker Compose" -} - -$remotes = & $git remote 2>$null -if ($remotes -contains "origin") { - & $git remote set-url origin $remote -} else { - & $git remote add origin $remote -} - -& $git push -u origin main diff --git a/src/cart.js b/src/cart.js new file mode 100644 index 0000000..da87290 --- /dev/null +++ b/src/cart.js @@ -0,0 +1,34 @@ +function getCart(req) { + if (!req.session.cart) { + req.session.cart = {}; + } + return req.session.cart; +} + +function cartCount(cart) { + return Object.values(cart).reduce((sum, qty) => sum + qty, 0); +} + +function cartItems(db, cart) { + const ids = Object.keys(cart).map(Number).filter(Boolean); + if (ids.length === 0) return []; + + const placeholders = ids.map(() => '?').join(','); + const products = db + .prepare(`SELECT * FROM products WHERE id IN (${placeholders})`) + .all(...ids); + + return products + .map((p) => ({ + ...p, + quantity: cart[p.id] || 0, + line_total: (cart[p.id] || 0) * p.price_cents, + })) + .filter((p) => p.quantity > 0); +} + +function cartTotal(items) { + return items.reduce((sum, i) => sum + i.line_total, 0); +} + +module.exports = { getCart, cartCount, cartItems, cartTotal }; diff --git a/src/db.js b/src/db.js new file mode 100644 index 0000000..36da261 --- /dev/null +++ b/src/db.js @@ -0,0 +1,76 @@ +const fs = require('fs'); +const path = require('path'); +const Database = require('better-sqlite3'); + +const dataDir = path.join(__dirname, '..', 'data'); +if (!fs.existsSync(dataDir)) { + fs.mkdirSync(dataDir, { recursive: true }); +} + +const dbPath = path.join(dataDir, 'shop.db'); +const db = new Database(dbPath); + +db.pragma('journal_mode = WAL'); +db.pragma('foreign_keys = ON'); + +db.exec(` + CREATE TABLE IF NOT EXISTS users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + email TEXT NOT NULL UNIQUE COLLATE NOCASE, + password_hash TEXT NOT NULL, + name TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT (datetime('now')) + ); + + CREATE TABLE IF NOT EXISTS categories ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + slug TEXT NOT NULL UNIQUE, + name TEXT NOT NULL + ); + + CREATE TABLE IF NOT EXISTS products ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + category_id INTEGER REFERENCES categories(id) ON DELETE SET NULL, + slug TEXT NOT NULL UNIQUE, + name TEXT NOT NULL, + description TEXT NOT NULL DEFAULT '', + price_cents INTEGER NOT NULL CHECK (price_cents >= 0), + stock INTEGER NOT NULL DEFAULT 0 CHECK (stock >= 0), + image_url TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')) + ); + + CREATE TABLE IF NOT EXISTS orders ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL REFERENCES users(id), + status TEXT NOT NULL DEFAULT 'pending' + CHECK (status IN ('pending', 'paid', 'shipped', 'cancelled')), + total_cents INTEGER NOT NULL CHECK (total_cents >= 0), + customer_name TEXT NOT NULL, + customer_email TEXT NOT NULL, + customer_phone TEXT NOT NULL DEFAULT '', + address TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT (datetime('now')) + ); + + CREATE TABLE IF NOT EXISTS order_items ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + order_id INTEGER NOT NULL REFERENCES orders(id) ON DELETE CASCADE, + product_id INTEGER NOT NULL REFERENCES products(id), + quantity INTEGER NOT NULL CHECK (quantity > 0), + price_cents INTEGER NOT NULL CHECK (price_cents >= 0) + ); + + CREATE INDEX IF NOT EXISTS idx_products_category ON products(category_id); + CREATE INDEX IF NOT EXISTS idx_orders_user ON orders(user_id); +`); + +function formatPrice(cents) { + return (cents / 100).toLocaleString('ru-RU', { + style: 'currency', + currency: 'RUB', + minimumFractionDigits: 0, + }); +} + +module.exports = { db, formatPrice, dbPath }; diff --git a/src/middleware/auth.js b/src/middleware/auth.js new file mode 100644 index 0000000..7a8e8bc --- /dev/null +++ b/src/middleware/auth.js @@ -0,0 +1,22 @@ +function requireAuth(req, res, next) { + if (!req.session.userId) { + const nextUrl = encodeURIComponent(req.originalUrl); + return res.redirect(`/login?next=${nextUrl}`); + } + next(); +} + +function loadUser(req, res, next) { + if (req.session.userId) { + const { db } = require('../db'); + const user = db + .prepare('SELECT id, email, name FROM users WHERE id = ?') + .get(req.session.userId); + res.locals.user = user || null; + } else { + res.locals.user = null; + } + next(); +} + +module.exports = { requireAuth, loadUser }; diff --git a/src/public/css/style.css b/src/public/css/style.css new file mode 100644 index 0000000..21835a1 --- /dev/null +++ b/src/public/css/style.css @@ -0,0 +1,581 @@ +:root { + --bg: #0f0f12; + --surface: #1a1a21; + --surface-2: #24242e; + --border: #2e2e3a; + --text: #f4f4f6; + --muted: #9b9bab; + --accent: #6c5ce7; + --accent-hover: #7f70f0; + --success: #00b894; + --warn: #fdcb6e; + --error: #ff7675; + --radius: 12px; + --shadow: 0 8px 32px rgba(0, 0, 0, 0.35); + font-family: 'DM Sans', system-ui, sans-serif; +} + +*, +*::before, +*::after { + box-sizing: border-box; +} + +body { + margin: 0; + min-height: 100vh; + background: var(--bg); + color: var(--text); + line-height: 1.5; + display: flex; + flex-direction: column; +} + +a { + color: var(--accent-hover); + text-decoration: none; +} + +a:hover { + text-decoration: underline; +} + +.container { + width: min(1200px, 100% - 2rem); + margin-inline: auto; +} + +.header { + position: sticky; + top: 0; + z-index: 100; + background: rgba(15, 15, 18, 0.9); + backdrop-filter: blur(12px); + border-bottom: 1px solid var(--border); +} + +.header__inner { + display: flex; + align-items: center; + gap: 1rem; + padding: 0.85rem 0; + flex-wrap: wrap; +} + +.logo { + font-size: 1.35rem; + font-weight: 700; + color: var(--text); + text-decoration: none; + letter-spacing: -0.02em; +} + +.logo:hover { + color: var(--accent-hover); + text-decoration: none; +} + +.search { + flex: 1; + display: flex; + gap: 0.5rem; + min-width: 200px; + max-width: 420px; +} + +.search input { + flex: 1; +} + +.nav { + display: flex; + align-items: center; + gap: 0.75rem; + margin-left: auto; +} + +.nav__link { + color: var(--muted); + font-weight: 500; +} + +.nav__link:hover { + color: var(--text); +} + +.nav__cart { + position: relative; +} + +.badge { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 1.25rem; + height: 1.25rem; + padding: 0 0.35rem; + margin-left: 0.25rem; + font-size: 0.7rem; + font-weight: 700; + background: var(--accent); + color: #fff; + border-radius: 999px; +} + +.main { + flex: 1; + padding: 2rem 0 3rem; +} + +.footer { + border-top: 1px solid var(--border); + padding: 1.5rem 0; + color: var(--muted); + font-size: 0.9rem; +} + +.hero { + margin-bottom: 2rem; +} + +.hero h1 { + margin: 0 0 0.35rem; + font-size: clamp(1.75rem, 4vw, 2.25rem); + letter-spacing: -0.03em; +} + +.hero p { + margin: 0; + color: var(--muted); +} + +.categories { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + margin-bottom: 1.75rem; +} + +.chip { + padding: 0.4rem 0.9rem; + border-radius: 999px; + background: var(--surface); + border: 1px solid var(--border); + color: var(--muted); + font-size: 0.9rem; + text-decoration: none; +} + +.chip:hover, +.chip--active { + background: var(--accent); + border-color: var(--accent); + color: #fff; + text-decoration: none; +} + +.grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); + gap: 1.25rem; +} + +.card { + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius); + overflow: hidden; + transition: transform 0.2s, box-shadow 0.2s; +} + +.card:hover { + transform: translateY(-2px); + box-shadow: var(--shadow); +} + +.card__image-wrap { + display: block; + aspect-ratio: 1; + overflow: hidden; + background: var(--surface-2); +} + +.card__image { + width: 100%; + height: 100%; + object-fit: cover; +} + +.card__placeholder { + display: flex; + align-items: center; + justify-content: center; + height: 100%; + color: var(--muted); +} + +.card__body { + padding: 1rem; +} + +.card__category { + font-size: 0.75rem; + text-transform: uppercase; + letter-spacing: 0.06em; + color: var(--muted); +} + +.card__title { + margin: 0.35rem 0; + font-size: 1rem; + font-weight: 600; +} + +.card__title a { + color: var(--text); + text-decoration: none; +} + +.card__title a:hover { + color: var(--accent-hover); +} + +.card__price { + margin: 0 0 0.75rem; + font-size: 1.1rem; + font-weight: 700; +} + +.card__form { + margin: 0; +} + +.product-detail { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 2.5rem; + align-items: start; +} + +@media (max-width: 768px) { + .product-detail { + grid-template-columns: 1fr; + } +} + +.product-detail__image { + width: 100%; + border-radius: var(--radius); + aspect-ratio: 1; + object-fit: cover; + background: var(--surface-2); +} + +.product-detail__price { + font-size: 1.75rem; + font-weight: 700; + margin: 0.5rem 0; +} + +.product-detail__desc { + color: var(--muted); +} + +.product-detail__form { + display: flex; + flex-wrap: wrap; + align-items: flex-end; + gap: 1rem; + margin: 1.5rem 0; +} + +.link-back { + display: inline-block; + margin-top: 1rem; + color: var(--muted); +} + +.btn { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 0.55rem 1.1rem; + border: none; + border-radius: 8px; + font: inherit; + font-weight: 600; + cursor: pointer; + text-decoration: none; + transition: background 0.15s, color 0.15s; +} + +.btn--primary { + background: var(--accent); + color: #fff; +} + +.btn--primary:hover { + background: var(--accent-hover); + color: #fff; + text-decoration: none; +} + +.btn--ghost { + background: transparent; + color: var(--muted); + border: 1px solid var(--border); +} + +.btn--ghost:hover { + color: var(--text); + border-color: var(--muted); +} + +.btn--block { + width: 100%; +} + +.btn--sm { + padding: 0.35rem 0.75rem; + font-size: 0.85rem; +} + +.btn--lg { + padding: 0.75rem 1.5rem; + font-size: 1rem; +} + +.input { + width: 100%; + padding: 0.6rem 0.85rem; + border: 1px solid var(--border); + border-radius: 8px; + background: var(--bg); + color: var(--text); + font: inherit; +} + +.input:focus { + outline: 2px solid var(--accent); + outline-offset: 1px; +} + +.input--qty { + width: 4.5rem; +} + +.label { + display: flex; + flex-direction: column; + gap: 0.35rem; + font-size: 0.9rem; + font-weight: 500; +} + +.form { + display: flex; + flex-direction: column; + gap: 1rem; + max-width: 420px; +} + +.form h1, +.form h2 { + margin: 0 0 0.5rem; +} + +.form-footer { + text-align: center; + color: var(--muted); + font-size: 0.9rem; +} + +.auth { + display: flex; + justify-content: center; + padding: 2rem 0; +} + +.auth .form { + width: 100%; + max-width: 400px; + padding: 1.75rem; +} + +.alert { + padding: 0.75rem 1rem; + border-radius: 8px; + margin: 0 0 1rem; +} + +.alert--error { + background: rgba(255, 118, 117, 0.15); + color: var(--error); +} + +.alert--success { + background: rgba(0, 184, 148, 0.15); + color: var(--success); +} + +.alert--warn { + background: rgba(253, 203, 110, 0.15); + color: var(--warn); +} + +.empty { + color: var(--muted); + padding: 2rem 0; +} + +.cart-table-wrap { + margin-top: 1rem; +} + +.cart-table { + width: 100%; + border-collapse: collapse; + background: var(--surface); + border-radius: var(--radius); + overflow: hidden; +} + +.cart-table th, +.cart-table td { + padding: 0.85rem 1rem; + text-align: left; + border-bottom: 1px solid var(--border); +} + +.cart-table th { + background: var(--surface-2); + font-size: 0.85rem; + color: var(--muted); +} + +.cart-table__product { + display: flex; + align-items: center; + gap: 0.75rem; +} + +.cart-table__thumb { + width: 48px; + height: 48px; + object-fit: cover; + border-radius: 6px; +} + +.cart-actions { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 1rem; + margin-top: 1.5rem; +} + +.cart-total { + margin-left: auto; + font-size: 1.25rem; +} + +.checkout-layout { + display: grid; + grid-template-columns: 1fr 320px; + gap: 2rem; + align-items: start; + margin-top: 1rem; +} + +@media (max-width: 768px) { + .checkout-layout { + grid-template-columns: 1fr; + } +} + +.checkout-summary { + padding: 1.25rem; +} + +.checkout-list { + list-style: none; + margin: 0; + padding: 0; +} + +.checkout-list li { + display: flex; + justify-content: space-between; + gap: 1rem; + padding: 0.5rem 0; + border-bottom: 1px solid var(--border); + font-size: 0.95rem; +} + +.checkout-total { + margin-top: 1rem; + font-size: 1.15rem; +} + +.account-card { + padding: 1.5rem; + max-width: 480px; +} + +.account-actions { + margin-top: 1.25rem; +} + +.muted { + color: var(--muted); +} + +.hint { + color: var(--muted); + font-size: 0.9rem; +} + +.status { + display: inline-block; + padding: 0.2rem 0.6rem; + border-radius: 6px; + font-size: 0.85rem; + font-weight: 600; +} + +.status--pending { + background: rgba(253, 203, 110, 0.2); + color: var(--warn); +} + +.status--paid, +.status--shipped { + background: rgba(0, 184, 148, 0.2); + color: var(--success); +} + +.status--cancelled { + background: rgba(255, 118, 117, 0.2); + color: var(--error); +} + +.order-card { + padding: 1.5rem; + max-width: 640px; +} + +.error-page { + text-align: center; + padding: 4rem 0; +} + +.error-page h1 { + font-size: 4rem; + margin: 0; + color: var(--muted); +} + +.inline-form { + display: inline; + margin: 0; +} diff --git a/src/routes/auth.js b/src/routes/auth.js new file mode 100644 index 0000000..a9f1d1c --- /dev/null +++ b/src/routes/auth.js @@ -0,0 +1,116 @@ +const express = require('express'); +const bcrypt = require('bcryptjs'); +const { db } = require('../db'); +const { getCart, cartCount } = require('../cart'); +const { requireAuth } = require('../middleware/auth'); + +const router = express.Router(); + +router.use((req, res, next) => { + const cart = getCart(req); + res.locals.cartCount = cartCount(cart); + res.locals.formatPrice = require('../db').formatPrice; + next(); +}); + +router.get('/register', (req, res) => { + if (req.session.userId) return res.redirect('/account'); + res.render('register', { title: 'Регистрация', error: null, values: {} }); +}); + +router.post('/register', (req, res) => { + const { name, email, password, password2 } = req.body; + const values = { name, email }; + + if (!name?.trim() || !email?.trim() || !password) { + return res.status(400).render('register', { + title: 'Регистрация', + error: 'Заполните все поля', + values, + }); + } + if (password.length < 6) { + return res.status(400).render('register', { + title: 'Регистрация', + error: 'Пароль не менее 6 символов', + values, + }); + } + if (password !== password2) { + return res.status(400).render('register', { + title: 'Регистрация', + error: 'Пароли не совпадают', + values, + }); + } + + const hash = bcrypt.hashSync(password, 10); + try { + const r = db + .prepare('INSERT INTO users (email, password_hash, name) VALUES (?, ?, ?)') + .run(email.trim().toLowerCase(), hash, name.trim()); + req.session.userId = r.lastInsertRowid; + res.redirect('/'); + } catch (err) { + if (err.code === 'SQLITE_CONSTRAINT_UNIQUE') { + return res.status(400).render('register', { + title: 'Регистрация', + error: 'Этот email уже зарегистрирован', + values, + }); + } + throw err; + } +}); + +router.get('/login', (req, res) => { + if (req.session.userId) return res.redirect('/account'); + res.render('login', { + title: 'Вход', + error: null, + next: req.query.next || '/', + values: {}, + }); +}); + +router.post('/login', (req, res) => { + const { email, password } = req.body; + const next = req.body.next || '/'; + const values = { email }; + + const user = db + .prepare('SELECT * FROM users WHERE email = ?') + .get((email || '').trim().toLowerCase()); + + if (!user || !bcrypt.compareSync(password || '', user.password_hash)) { + return res.status(401).render('login', { + title: 'Вход', + error: 'Неверный email или пароль', + next, + values, + }); + } + + req.session.userId = user.id; + res.redirect(next.startsWith('/') ? next : '/'); +}); + +router.post('/logout', (req, res) => { + req.session.destroy(() => { + res.redirect('/'); + }); +}); + +router.get('/account', requireAuth, (req, res) => { + const user = db + .prepare('SELECT id, email, name, created_at FROM users WHERE id = ?') + .get(req.session.userId); + + const orderCount = db + .prepare('SELECT COUNT(*) AS n FROM orders WHERE user_id = ?') + .get(user.id).n; + + res.render('account', { title: 'Личный кабинет', user, orderCount }); +}); + +module.exports = router; diff --git a/src/routes/shop.js b/src/routes/shop.js new file mode 100644 index 0000000..498073f --- /dev/null +++ b/src/routes/shop.js @@ -0,0 +1,267 @@ +const express = require('express'); +const { db, formatPrice } = require('../db'); +const { getCart, cartCount, cartItems, cartTotal } = require('../cart'); +const { requireAuth } = require('../middleware/auth'); + +const router = express.Router(); + +function enrichLocals(req, res) { + const cart = getCart(req); + res.locals.cartCount = cartCount(cart); + res.locals.formatPrice = formatPrice; +} + +router.use((req, res, next) => { + enrichLocals(req, res); + next(); +}); + +router.get('/', (req, res) => { + const category = req.query.category || ''; + const q = (req.query.q || '').trim(); + + let sql = ` + SELECT p.*, c.name AS category_name, c.slug AS category_slug + FROM products p + LEFT JOIN categories c ON c.id = p.category_id + WHERE p.stock > 0 + `; + const params = []; + + if (category) { + sql += ' AND c.slug = ?'; + params.push(category); + } + if (q) { + sql += ' AND (p.name LIKE ? OR p.description LIKE ?)'; + const like = `%${q}%`; + params.push(like, like); + } + sql += ' ORDER BY p.name'; + + const products = db.prepare(sql).all(...params); + const categories = db.prepare('SELECT * FROM categories ORDER BY name').all(); + + res.render('home', { + title: 'Каталог', + products, + categories, + activeCategory: category, + searchQuery: q, + }); +}); + +router.get('/product/:slug', (req, res) => { + const product = db + .prepare( + `SELECT p.*, c.name AS category_name, c.slug AS category_slug + FROM products p + LEFT JOIN categories c ON c.id = p.category_id + WHERE p.slug = ?` + ) + .get(req.params.slug); + + if (!product) { + return res.status(404).render('error', { + title: 'Не найдено', + message: 'Товар не найден', + code: 404, + }); + } + + res.render('product', { title: product.name, product }); +}); + +router.get('/cart', (req, res) => { + const cart = getCart(req); + const items = cartItems(db, cart); + const total = cartTotal(items); + + const errorMsg = req.query.error ? decodeURIComponent(String(req.query.error)) : null; + res.render('cart', { + title: 'Корзина', + items, + total, + error: errorMsg, + }); +}); + +router.post('/cart/add', (req, res) => { + const productId = parseInt(req.body.product_id, 10); + const quantity = Math.max(1, parseInt(req.body.quantity, 10) || 1); + + const product = db + .prepare('SELECT id, stock FROM products WHERE id = ?') + .get(productId); + if (!product) { + return res.redirect('/'); + } + + const cart = getCart(req); + const current = cart[productId] || 0; + const nextQty = Math.min(product.stock, current + quantity); + cart[productId] = nextQty; + + const redirect = req.body.redirect || '/cart'; + res.redirect(redirect); +}); + +router.post('/cart/update', (req, res) => { + const cart = getCart(req); + const updates = req.body.items || {}; + + for (const [id, qty] of Object.entries(updates)) { + const productId = parseInt(id, 10); + const quantity = parseInt(qty, 10); + if (!productId) continue; + + if (!quantity || quantity <= 0) { + delete cart[productId]; + continue; + } + + const product = db + .prepare('SELECT stock FROM products WHERE id = ?') + .get(productId); + if (product) { + cart[productId] = Math.min(product.stock, quantity); + } + } + + res.redirect('/cart'); +}); + +router.post('/cart/remove/:id', (req, res) => { + const cart = getCart(req); + delete cart[parseInt(req.params.id, 10)]; + res.redirect('/cart'); +}); + +router.get('/checkout', requireAuth, (req, res) => { + const cart = getCart(req); + const items = cartItems(db, cart); + if (items.length === 0) { + return res.redirect('/cart'); + } + + res.render('checkout', { + title: 'Оформление заказа', + items, + total: cartTotal(items), + error: null, + }); +}); + +router.post('/checkout', requireAuth, (req, res) => { + const cart = getCart(req); + const items = cartItems(db, cart); + if (items.length === 0) { + return res.redirect('/cart'); + } + + const { name, email, phone, address } = req.body; + if (!name?.trim() || !email?.trim() || !address?.trim()) { + return res.status(400).render('checkout', { + title: 'Оформление заказа', + items, + total: cartTotal(items), + error: 'Заполните имя, email и адрес доставки', + }); + } + + const total = cartTotal(items); + + const placeOrder = db.transaction(() => { + for (const item of items) { + const row = db + .prepare('SELECT stock FROM products WHERE id = ?') + .get(item.id); + if (!row || row.stock < item.quantity) { + throw new Error(`Недостаточно «${item.name}» на складе`); + } + } + + const order = db + .prepare( + `INSERT INTO orders (user_id, status, total_cents, customer_name, customer_email, customer_phone, address) + VALUES (?, 'pending', ?, ?, ?, ?, ?)` + ) + .run( + req.session.userId, + total, + name.trim(), + email.trim(), + (phone || '').trim(), + address.trim() + ); + + const insertItem = db.prepare( + `INSERT INTO order_items (order_id, product_id, quantity, price_cents) + VALUES (?, ?, ?, ?)` + ); + const updateStock = db.prepare( + 'UPDATE products SET stock = stock - ? WHERE id = ?' + ); + + for (const item of items) { + insertItem.run(order.lastInsertRowid, item.id, item.quantity, item.price_cents); + updateStock.run(item.quantity, item.id); + } + + return order.lastInsertRowid; + }); + + try { + const orderId = placeOrder(); + req.session.cart = {}; + res.redirect(`/orders/${orderId}?success=1`); + } catch (err) { + res.redirect(`/cart?error=${encodeURIComponent(err.message)}`); + } +}); + +router.get('/orders', requireAuth, (req, res) => { + const orders = db + .prepare( + `SELECT id, status, total_cents, created_at + FROM orders WHERE user_id = ? + ORDER BY created_at DESC` + ) + .all(req.session.userId); + + res.render('orders', { title: 'Мои заказы', orders }); +}); + +router.get('/orders/:id', requireAuth, (req, res) => { + const order = db + .prepare( + `SELECT * FROM orders WHERE id = ? AND user_id = ?` + ) + .get(req.params.id, req.session.userId); + + if (!order) { + return res.status(404).render('error', { + title: 'Не найдено', + message: 'Заказ не найден', + code: 404, + }); + } + + const items = db + .prepare( + `SELECT oi.*, p.name, p.slug, p.image_url + FROM order_items oi + JOIN products p ON p.id = oi.product_id + WHERE oi.order_id = ?` + ) + .all(order.id); + + res.render('order', { + title: `Заказ #${order.id}`, + order, + items, + success: req.query.success === '1', + }); +}); + +module.exports = router; diff --git a/src/seed.js b/src/seed.js new file mode 100644 index 0000000..e92afc4 --- /dev/null +++ b/src/seed.js @@ -0,0 +1,119 @@ +const { db } = require('./db'); + +const count = db.prepare('SELECT COUNT(*) AS n FROM products').get().n; +if (count > 0) { + console.log('База уже содержит товары, пропуск seed.'); + process.exit(0); +} + +const insertCategory = db.prepare( + 'INSERT INTO categories (slug, name) VALUES (?, ?)' +); +const insertProduct = db.prepare(` + INSERT INTO products (category_id, slug, name, description, price_cents, stock, image_url) + VALUES (?, ?, ?, ?, ?, ?, ?) +`); + +const categories = [ + { slug: 'electronics', name: 'Электроника' }, + { slug: 'clothing', name: 'Одежда' }, + { slug: 'home', name: 'Дом и быт' }, +]; + +const categoryIds = {}; +const seed = db.transaction(() => { + for (const c of categories) { + const r = insertCategory.run(c.slug, c.name); + categoryIds[c.slug] = r.lastInsertRowid; + } + + const products = [ + { + cat: 'electronics', + slug: 'wireless-headphones', + name: 'Беспроводные наушники', + description: 'Шумоподавление, 30 ч автономности, Bluetooth 5.3.', + price: 499000, + stock: 24, + image: 'https://images.unsplash.com/photo-1505740420928-5e560c06d30e?w=400&h=400&fit=crop', + }, + { + cat: 'electronics', + slug: 'smart-watch', + name: 'Умные часы', + description: 'Пульс, GPS, водозащита IP68.', + price: 1299000, + stock: 15, + image: 'https://images.unsplash.com/photo-1523275335684-37898b6baf30?w=400&h=400&fit=crop', + }, + { + cat: 'electronics', + slug: 'mechanical-keyboard', + name: 'Механическая клавиатура', + description: 'Hot-swap, RGB подсветка, переключатели Brown.', + price: 749000, + stock: 18, + image: 'https://images.unsplash.com/photo-1587829741301-dc798b83add3?w=400&h=400&fit=crop', + }, + { + cat: 'clothing', + slug: 'cotton-tshirt', + name: 'Хлопковая футболка', + description: '100% хлопок, унисекс, размеры S–XL.', + price: 199000, + stock: 50, + image: 'https://images.unsplash.com/photo-1521572163474-6864f9cf17ab?w=400&h=400&fit=crop', + }, + { + cat: 'clothing', + slug: 'denim-jacket', + name: 'Джинсовая куртка', + description: 'Классический крой, прочный деним.', + price: 459000, + stock: 12, + image: 'https://images.unsplash.com/photo-1551028711-00167b16eac5?w=400&h=400&fit=crop', + }, + { + cat: 'home', + slug: 'ceramic-mug', + name: 'Керамическая кружка', + description: 'Объём 350 мл, подходит для посудомойки.', + price: 89000, + stock: 40, + image: 'https://images.unsplash.com/photo-1514228742587-6b1558fcca13?w=400&h=400&fit=crop', + }, + { + cat: 'home', + slug: 'desk-lamp', + name: 'Настольная лампа', + description: 'LED, регулировка яркости и цветовой температуры.', + price: 329000, + stock: 20, + image: 'https://images.unsplash.com/photo-1507473885765-e6ed057f782c?w=400&h=400&fit=crop', + }, + { + cat: 'home', + slug: 'throw-blanket', + name: 'Плед', + description: 'Мягкий флис, 150×200 см.', + price: 249000, + stock: 30, + image: 'https://images.unsplash.com/photo-1555041469-a586c12e1940?w=400&h=400&fit=crop', + }, + ]; + + for (const p of products) { + insertProduct.run( + categoryIds[p.cat], + p.slug, + p.name, + p.description, + p.price, + p.stock, + p.image + ); + } +}); + +seed(); +console.log('Добавлено категорий:', categories.length, ', товаров: 8'); diff --git a/src/server.js b/src/server.js new file mode 100644 index 0000000..6847358 --- /dev/null +++ b/src/server.js @@ -0,0 +1,60 @@ +const path = require('path'); +const express = require('express'); +const session = require('express-session'); +const SQLiteStore = require('connect-sqlite3')(session); + +require('./db'); +require('./seed'); + +const { loadUser } = require('./middleware/auth'); +const shopRoutes = require('./routes/shop'); +const authRoutes = require('./routes/auth'); + +const app = express(); +const PORT = process.env.PORT || 3000; + +app.set('view engine', 'ejs'); +app.set('views', path.join(__dirname, 'views')); + +app.use(express.static(path.join(__dirname, 'public'))); +app.use(express.urlencoded({ extended: true })); + +app.use( + session({ + store: new SQLiteStore({ db: 'sessions.db', dir: path.join(__dirname, '..', 'data') }), + secret: process.env.SESSION_SECRET || 'dev-secret-change-in-production', + resave: false, + saveUninitialized: false, + cookie: { + maxAge: 7 * 24 * 60 * 60 * 1000, + httpOnly: true, + sameSite: 'lax', + }, + }) +); + +app.use(loadUser); + +app.use('/', shopRoutes); +app.use('/', authRoutes); + +app.use((req, res) => { + res.status(404).render('error', { + title: 'Не найдено', + message: 'Страница не найдена', + code: 404, + }); +}); + +app.use((err, req, res, _next) => { + console.error(err); + res.status(500).render('error', { + title: 'Ошибка', + message: 'Внутренняя ошибка сервера', + code: 500, + }); +}); + +app.listen(PORT, () => { + console.log(`Магазин: http://localhost:${PORT}`); +}); diff --git a/src/views/account.ejs b/src/views/account.ejs new file mode 100644 index 0000000..a0de022 --- /dev/null +++ b/src/views/account.ejs @@ -0,0 +1,15 @@ +<%- include('partials/layout-start') %> + + + +<%- include('partials/layout-end') %> diff --git a/src/views/cart.ejs b/src/views/cart.ejs new file mode 100644 index 0000000..dfd4ad8 --- /dev/null +++ b/src/views/cart.ejs @@ -0,0 +1,54 @@ +<%- include('partials/layout-start') %> + +

Корзина

+ +<% if (error) { %>

<%= error %>

<% } %> + +<% if (!items.length) { %> +

Корзина пуста. Перейти в каталог

+<% } else { %> +
+ + + + + + + + + + + + <% items.forEach(item => { %> + + + + + + + + <% }) %> + +
ТоварЦенаКол-воСумма
+ <% if (item.image_url) { %> + + <% } %> + <%= item.name %> + <%= formatPrice(item.price_cents) %> + + <%= formatPrice(item.line_total) %> + +
+
+ +

Итого: <%= formatPrice(total) %>

+ <% if (user) { %> + Оформить заказ + <% } else { %> +

Войдите, чтобы оформить заказ.

+ <% } %> +
+
+<% } %> + +<%- include('partials/layout-end') %> diff --git a/src/views/checkout.ejs b/src/views/checkout.ejs new file mode 100644 index 0000000..d85b92f --- /dev/null +++ b/src/views/checkout.ejs @@ -0,0 +1,43 @@ +<%- include('partials/layout-start') %> + +

Оформление заказа

+ +<% if (error) { %>

<%= error %>

<% } %> + +
+
+

Данные доставки

+ + + + + +
+ + +
+ +<%- include('partials/layout-end') %> diff --git a/src/views/error.ejs b/src/views/error.ejs new file mode 100644 index 0000000..ae84e90 --- /dev/null +++ b/src/views/error.ejs @@ -0,0 +1,9 @@ +<%- include('partials/layout-start') %> + +
+

<%= code %>

+

<%= message %>

+ На главную +
+ +<%- include('partials/layout-end') %> diff --git a/src/views/home.ejs b/src/views/home.ejs new file mode 100644 index 0000000..7234e8a --- /dev/null +++ b/src/views/home.ejs @@ -0,0 +1,47 @@ +<%- include('partials/layout-start') %> + +
+

Каталог товаров

+

Доставка по России. Оплата при получении.

+
+ +<% if (categories.length) { %> + +<% } %> + +<% if (!products.length) { %> +

Товары не найдены. Попробуйте другой запрос.

+<% } else { %> +
+ <% products.forEach(p => { %> + + <% }) %> +
+<% } %> + +<%- include('partials/layout-end') %> diff --git a/src/views/login.ejs b/src/views/login.ejs new file mode 100644 index 0000000..8e8de8b --- /dev/null +++ b/src/views/login.ejs @@ -0,0 +1,21 @@ +<%- include('partials/layout-start') %> + +
+
+

Вход

+ <% if (error) { %>

<%= error %>

<% } %> + + + + + +
+
+ +<%- include('partials/layout-end') %> diff --git a/src/views/order.ejs b/src/views/order.ejs new file mode 100644 index 0000000..b8e98ca --- /dev/null +++ b/src/views/order.ejs @@ -0,0 +1,30 @@ +<%- include('partials/layout-start') %> + +

Заказ #<%= order.id %>

+ +<% if (success) { %> +

Заказ успешно оформлен! Мы свяжемся с вами по email.

+<% } %> + +
+ <% const statusLabels = { pending: 'Ожидает обработки', paid: 'Оплачен', shipped: 'Отправлен', cancelled: 'Отменён' }; %> +

Статус: <%= statusLabels[order.status] || order.status %>

+

Дата: <%= new Date(order.created_at).toLocaleString('ru-RU') %>

+

Доставка: <%= order.address %>

+

Контакт: <%= order.customer_name %>, <%= order.customer_email %><% if (order.customer_phone) { %>, <%= order.customer_phone %><% } %>

+ +

Состав заказа

+
    + <% items.forEach(item => { %> +
  • + <%= item.name %> × <%= item.quantity %> + <%= formatPrice(item.price_cents * item.quantity) %> +
  • + <% }) %> +
+

Итого: <%= formatPrice(order.total_cents) %>

+
+ +

← Все заказы

+ +<%- include('partials/layout-end') %> diff --git a/src/views/orders.ejs b/src/views/orders.ejs new file mode 100644 index 0000000..2fa98c3 --- /dev/null +++ b/src/views/orders.ejs @@ -0,0 +1,33 @@ +<%- include('partials/layout-start') %> + +

Мои заказы

+ +<% if (!orders.length) { %> +

Заказов пока нет. Перейти в каталог

+<% } else { %> + + + + + + + + + + + + <% const statusLabels = { pending: 'Ожидает', paid: 'Оплачен', shipped: 'Отправлен', cancelled: 'Отменён' }; %> + <% orders.forEach(o => { %> + + + + + + + + <% }) %> + +
ДатаСтатусСумма
#<%= o.id %><%= new Date(o.created_at).toLocaleString('ru-RU') %><%= statusLabels[o.status] || o.status %><%= formatPrice(o.total_cents) %>Подробнее
+<% } %> + +<%- include('partials/layout-end') %> diff --git a/src/views/partials/layout-end.ejs b/src/views/partials/layout-end.ejs new file mode 100644 index 0000000..b0e1e18 --- /dev/null +++ b/src/views/partials/layout-end.ejs @@ -0,0 +1,8 @@ + +
+
+

© <%= new Date().getFullYear() %> Shop — локальный интернет-магазин на Node.js + SQLite

+
+
+ + diff --git a/src/views/partials/layout-start.ejs b/src/views/partials/layout-start.ejs new file mode 100644 index 0000000..0679ea3 --- /dev/null +++ b/src/views/partials/layout-start.ejs @@ -0,0 +1,37 @@ + + + + + + <%= title %> — Shop + + + + + + +
+
+ + + +
+
+
diff --git a/src/views/product.ejs b/src/views/product.ejs new file mode 100644 index 0000000..1258804 --- /dev/null +++ b/src/views/product.ejs @@ -0,0 +1,37 @@ +<%- include('partials/layout-start') %> + +
+
+ <% if (product.image_url) { %> + <%= product.name %> + <% } else { %> +
Нет фото
+ <% } %> +
+
+ <% if (product.category_name) { %> + <%= product.category_name %> + <% } %> +

<%= product.name %>

+

<%= formatPrice(product.price_cents) %>

+

<%= product.description %>

+

В наличии: <%= product.stock %> шт.

+ + <% if (product.stock > 0) { %> +
+ + + + +
+ <% } else { %> +

Нет в наличии

+ <% } %> + ← Назад в каталог +
+
+ +<%- include('partials/layout-end') %> diff --git a/src/views/register.ejs b/src/views/register.ejs new file mode 100644 index 0000000..b599e96 --- /dev/null +++ b/src/views/register.ejs @@ -0,0 +1,28 @@ +<%- include('partials/layout-start') %> + +
+
+

Регистрация

+ <% if (error) { %>

<%= error %>

<% } %> + + + + + + +
+
+ +<%- include('partials/layout-end') %> diff --git a/traefik/dynamic/shop.yml b/traefik/dynamic/shop.yml deleted file mode 100644 index 92eaca8..0000000 --- a/traefik/dynamic/shop.yml +++ /dev/null @@ -1,16 +0,0 @@ -# Локальная разработка (перезаписывается установщиком ./install.sh) -http: - routers: - shop: - rule: "Host(`localhost`)" - entryPoints: [web] - middlewares: [gzip] - service: shop - middlewares: - gzip: - compress: {} - services: - shop: - loadBalancer: - servers: - - url: "http://app:8080" diff --git a/traefik/dynamic/shop.yml.example b/traefik/dynamic/shop.yml.example deleted file mode 100644 index 145949a..0000000 --- a/traefik/dynamic/shop.yml.example +++ /dev/null @@ -1,16 +0,0 @@ -# Пример — установщик создаёт shop.yml автоматически -http: - routers: - shop: - rule: "Host(`localhost`)" - entryPoints: [web] - middlewares: [gzip] - service: shop - middlewares: - gzip: - compress: {} - services: - shop: - loadBalancer: - servers: - - url: "http://app:8080"