+2
-19
@@ -1,19 +1,2 @@
|
|||||||
# Скопируйте в .env или запустите: ./install.sh
|
PORT=3000
|
||||||
|
SESSION_SECRET=change-me-to-a-long-random-string
|
||||||
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
|
|
||||||
|
|||||||
+5
-5
@@ -1,6 +1,6 @@
|
|||||||
|
node_modules/
|
||||||
|
data/
|
||||||
.env
|
.env
|
||||||
*.exe
|
*.db
|
||||||
bin/
|
*.db-journal
|
||||||
vendor/
|
sessions.db
|
||||||
.idea/
|
|
||||||
.vscode/
|
|
||||||
|
|||||||
@@ -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
|
|
||||||
-27
@@ -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"]
|
|
||||||
@@ -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
|
```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
|
```bash
|
||||||
# 1. Клонировать
|
git remote add origin https://git.evilfox.cc/admin/shop.git
|
||||||
git clone https://git.evilfox.cc/test/shop3.git
|
git push -u origin main
|
||||||
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
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Одной цепочкой (после клона введите ответы установщика):
|
|
||||||
|
|
||||||
```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` или установщик).
|
|
||||||
|
|||||||
@@ -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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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:])
|
|
||||||
}
|
|
||||||
@@ -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:
|
|
||||||
@@ -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
|
|
||||||
)
|
|
||||||
@@ -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=
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
# Интерактивная установка: домен, база данных, .env, Traefik
|
|
||||||
$ErrorActionPreference = "Stop"
|
|
||||||
Set-Location $PSScriptRoot
|
|
||||||
go run ./cmd/install
|
|
||||||
@@ -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
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
package auth
|
|
||||||
|
|
||||||
const SessionCookieName = "shop_session"
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -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)
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -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)
|
|
||||||
}
|
|
||||||
@@ -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)
|
|
||||||
}
|
|
||||||
@@ -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 ""
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
package models
|
|
||||||
|
|
||||||
type Product struct {
|
|
||||||
ID int
|
|
||||||
Name string
|
|
||||||
Description string
|
|
||||||
Price float64
|
|
||||||
ImageURL string
|
|
||||||
Category string
|
|
||||||
Featured bool
|
|
||||||
}
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
package models
|
|
||||||
|
|
||||||
import "time"
|
|
||||||
|
|
||||||
type User struct {
|
|
||||||
ID int
|
|
||||||
Email string
|
|
||||||
Name string
|
|
||||||
CreatedAt time.Time
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -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"
|
|
||||||
}
|
|
||||||
@@ -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")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
package version
|
|
||||||
|
|
||||||
import "runtime"
|
|
||||||
|
|
||||||
const (
|
|
||||||
AppVersion = "0.20"
|
|
||||||
ExpectedPostgresMajor = 17
|
|
||||||
MinGoVersion = "1.22"
|
|
||||||
)
|
|
||||||
|
|
||||||
func GoRuntime() string {
|
|
||||||
return runtime.Version()
|
|
||||||
}
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
package web
|
|
||||||
|
|
||||||
import "embed"
|
|
||||||
|
|
||||||
//go:embed templates/*
|
|
||||||
var TemplatesFS embed.FS
|
|
||||||
|
|
||||||
//go:embed static/*
|
|
||||||
var StaticFS embed.FS
|
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
@@ -1,46 +0,0 @@
|
|||||||
{{define "account.html"}}
|
|
||||||
{{template "layout" .}}
|
|
||||||
{{end}}
|
|
||||||
|
|
||||||
{{define "content"}}
|
|
||||||
<section class="section account-section">
|
|
||||||
<div class="container">
|
|
||||||
<h1 class="section-title">Личный кабинет</h1>
|
|
||||||
<div class="account-grid">
|
|
||||||
<aside class="account-sidebar">
|
|
||||||
<div class="account-user-card">
|
|
||||||
<p class="account-label">Аккаунт</p>
|
|
||||||
<p class="account-name">{{.User.Name}}</p>
|
|
||||||
<p class="account-email">{{.User.Email}}</p>
|
|
||||||
<p class="account-meta">С нами с {{.User.CreatedAt.Format "02.01.2006"}}</p>
|
|
||||||
</div>
|
|
||||||
<nav class="account-nav">
|
|
||||||
<span class="account-nav-item active">Профиль</span>
|
|
||||||
<a href="/#catalog" class="account-nav-item">Каталог</a>
|
|
||||||
</nav>
|
|
||||||
</aside>
|
|
||||||
<div class="account-main">
|
|
||||||
<div class="auth-card account-card">
|
|
||||||
<h2>Настройки профиля</h2>
|
|
||||||
<form method="POST" action="/account" class="auth-form">
|
|
||||||
<label class="form-field">
|
|
||||||
<span>Имя</span>
|
|
||||||
<input type="text" name="name" value="{{.Name}}" required minlength="2">
|
|
||||||
</label>
|
|
||||||
<label class="form-field">
|
|
||||||
<span>Email</span>
|
|
||||||
<input type="email" value="{{.User.Email}}" disabled>
|
|
||||||
</label>
|
|
||||||
<button type="submit" class="btn btn-primary">Сохранить</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
<div class="account-hint auth-card">
|
|
||||||
<h3>Заказы</h3>
|
|
||||||
<p class="text-muted">История заказов появится в следующих версиях. Пока вы можете просматривать каталог и добавлять товары в корзину.</p>
|
|
||||||
<a href="/#catalog" class="btn btn-ghost">Перейти в каталог</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
{{end}}
|
|
||||||
@@ -1,82 +0,0 @@
|
|||||||
{{define "home.html"}}
|
|
||||||
{{template "layout" .}}
|
|
||||||
{{end}}
|
|
||||||
|
|
||||||
{{define "content"}}
|
|
||||||
<section class="hero">
|
|
||||||
<div class="container hero-grid">
|
|
||||||
<div class="hero-copy">
|
|
||||||
<p class="eyebrow">Новая коллекция 2026</p>
|
|
||||||
<h1 class="hero-title">Стиль, который<br><em>остаётся с вами</em></h1>
|
|
||||||
<p class="hero-lead">Качественные товары с быстрой доставкой. В каталоге уже {{.TotalItems}} позиций.</p>
|
|
||||||
<div class="hero-cta">
|
|
||||||
<a href="#catalog" class="btn btn-primary btn-lg">Смотреть каталог</a>
|
|
||||||
<a href="#categories" class="btn btn-ghost btn-lg">Категории</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="hero-visual" aria-hidden="true">
|
|
||||||
<div class="hero-card hero-card-a"></div>
|
|
||||||
<div class="hero-card hero-card-b"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{{if .Categories}}
|
|
||||||
<section id="categories" class="section categories">
|
|
||||||
<div class="container">
|
|
||||||
<h2 class="section-title">Категории</h2>
|
|
||||||
<ul class="category-list">
|
|
||||||
{{range .Categories}}
|
|
||||||
<li><a href="#catalog" class="category-chip">{{.}}</a></li>
|
|
||||||
{{end}}
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
{{end}}
|
|
||||||
|
|
||||||
<section id="catalog" class="section catalog">
|
|
||||||
<div class="container">
|
|
||||||
<div class="section-head">
|
|
||||||
<h2 class="section-title">Популярные товары</h2>
|
|
||||||
<p class="section-sub">Избранное из нашего ассортимента</p>
|
|
||||||
</div>
|
|
||||||
{{if .Products}}
|
|
||||||
<div class="product-grid">
|
|
||||||
{{range .Products}}
|
|
||||||
<article class="product-card">
|
|
||||||
<div class="product-image" style="background-image: url('{{.ImageURL}}')"></div>
|
|
||||||
<div class="product-body">
|
|
||||||
<span class="product-category">{{.Category}}</span>
|
|
||||||
<h3 class="product-name">{{.Name}}</h3>
|
|
||||||
<p class="product-desc">{{.Description}}</p>
|
|
||||||
<div class="product-footer">
|
|
||||||
<span class="product-price">{{formatPrice .Price}}</span>
|
|
||||||
<button type="button" class="btn btn-primary btn-sm">В корзину</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</article>
|
|
||||||
{{end}}
|
|
||||||
</div>
|
|
||||||
{{else}}
|
|
||||||
<p class="empty-state">Товары скоро появятся. Проверьте подключение к базе данных.</p>
|
|
||||||
{{end}}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section class="section features">
|
|
||||||
<div class="container features-grid">
|
|
||||||
<div class="feature">
|
|
||||||
<h3>Быстрая доставка</h3>
|
|
||||||
<p>Отправка в день заказа по всей России.</p>
|
|
||||||
</div>
|
|
||||||
<div class="feature">
|
|
||||||
<h3>Гарантия качества</h3>
|
|
||||||
<p>14 дней на возврат без лишних вопросов.</p>
|
|
||||||
</div>
|
|
||||||
<div class="feature">
|
|
||||||
<h3>Безопасная оплата</h3>
|
|
||||||
<p>Шифрование данных и защищённые платежи.</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
{{end}}
|
|
||||||
@@ -1,50 +0,0 @@
|
|||||||
{{define "layout"}}
|
|
||||||
<!DOCTYPE html>
|
|
||||||
<html lang="ru">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
||||||
<title>{{.Title}} — ShopNova</title>
|
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
|
||||||
<link href="https://fonts.googleapis.com/css2?family=DM+Sans:ital,opsz,wght@0,9..40,400;0,9..40,500;0,9..40,600;0,9..40,700;1,9..40,400&family=Instrument+Serif:ital@0;1&display=swap" rel="stylesheet">
|
|
||||||
<link rel="stylesheet" href="/static/css/style.css">
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<header class="site-header">
|
|
||||||
<div class="container header-inner">
|
|
||||||
<a href="/" class="logo">Shop<span>Nova</span></a>
|
|
||||||
<nav class="nav">
|
|
||||||
<a href="/" class="nav-link{{if eq .Nav "home"}} active{{end}}">Главная</a>
|
|
||||||
<a href="/#catalog" class="nav-link">Каталог</a>
|
|
||||||
{{if .User}}
|
|
||||||
<a href="/account" class="nav-link{{if eq .Nav "account"}} active{{end}}">Личный кабинет</a>
|
|
||||||
{{end}}
|
|
||||||
</nav>
|
|
||||||
<div class="header-actions">
|
|
||||||
{{if .User}}
|
|
||||||
<span class="user-greeting">{{.User.Name}}</span>
|
|
||||||
<form method="POST" action="/logout" class="inline-form">
|
|
||||||
<button type="submit" class="btn btn-ghost">Выйти</button>
|
|
||||||
</form>
|
|
||||||
{{else}}
|
|
||||||
<a href="/login" class="btn btn-ghost">Вход</a>
|
|
||||||
<a href="/register" class="btn btn-primary">Регистрация</a>
|
|
||||||
{{end}}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
{{if .Success}}<div class="container"><p class="alert alert-success">{{.Success}}</p></div>{{end}}
|
|
||||||
{{if .Error}}<div class="container"><p class="alert alert-error">{{.Error}}</p></div>{{end}}
|
|
||||||
<main>
|
|
||||||
{{template "content" .}}
|
|
||||||
</main>
|
|
||||||
<footer class="site-footer">
|
|
||||||
<div class="container footer-inner">
|
|
||||||
<p class="footer-brand">ShopNova</p>
|
|
||||||
<p class="footer-copy">© 2026 Интернет-магазин. Go + PostgreSQL + Traefik.</p>
|
|
||||||
</div>
|
|
||||||
</footer>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
{{end}}
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
{{define "login.html"}}
|
|
||||||
{{template "layout" .}}
|
|
||||||
{{end}}
|
|
||||||
|
|
||||||
{{define "content"}}
|
|
||||||
<section class="section auth-section">
|
|
||||||
<div class="container auth-container">
|
|
||||||
<div class="auth-card">
|
|
||||||
<h1 class="auth-title">Вход</h1>
|
|
||||||
<p class="auth-sub">Войдите в личный кабинет</p>
|
|
||||||
<form method="POST" action="/login" class="auth-form">
|
|
||||||
<input type="hidden" name="next" value="{{.Next}}">
|
|
||||||
<label class="form-field">
|
|
||||||
<span>Email</span>
|
|
||||||
<input type="email" name="email" value="{{.Email}}" required autocomplete="email">
|
|
||||||
</label>
|
|
||||||
<label class="form-field">
|
|
||||||
<span>Пароль</span>
|
|
||||||
<input type="password" name="password" required autocomplete="current-password">
|
|
||||||
</label>
|
|
||||||
<button type="submit" class="btn btn-primary btn-lg btn-block">Войти</button>
|
|
||||||
</form>
|
|
||||||
<p class="auth-footer">Нет аккаунта? <a href="/register">Регистрация</a></p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
{{end}}
|
|
||||||
@@ -1,34 +0,0 @@
|
|||||||
{{define "register.html"}}
|
|
||||||
{{template "layout" .}}
|
|
||||||
{{end}}
|
|
||||||
|
|
||||||
{{define "content"}}
|
|
||||||
<section class="section auth-section">
|
|
||||||
<div class="container auth-container">
|
|
||||||
<div class="auth-card">
|
|
||||||
<h1 class="auth-title">Регистрация</h1>
|
|
||||||
<p class="auth-sub">Создайте аккаунт для заказов и личного кабинета</p>
|
|
||||||
<form method="POST" action="/register" class="auth-form">
|
|
||||||
<label class="form-field">
|
|
||||||
<span>Имя</span>
|
|
||||||
<input type="text" name="name" value="{{.Name}}" required minlength="2" autocomplete="name">
|
|
||||||
</label>
|
|
||||||
<label class="form-field">
|
|
||||||
<span>Email</span>
|
|
||||||
<input type="email" name="email" value="{{.Email}}" required autocomplete="email">
|
|
||||||
</label>
|
|
||||||
<label class="form-field">
|
|
||||||
<span>Пароль</span>
|
|
||||||
<input type="password" name="password" required minlength="8" autocomplete="new-password">
|
|
||||||
</label>
|
|
||||||
<label class="form-field">
|
|
||||||
<span>Повторите пароль</span>
|
|
||||||
<input type="password" name="password_confirm" required minlength="8" autocomplete="new-password">
|
|
||||||
</label>
|
|
||||||
<button type="submit" class="btn btn-primary btn-lg btn-block">Зарегистрироваться</button>
|
|
||||||
</form>
|
|
||||||
<p class="auth-footer">Уже есть аккаунт? <a href="/login">Войти</a></p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
{{end}}
|
|
||||||
@@ -0,0 +1,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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
|
||||||
@@ -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);
|
|
||||||
@@ -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"
|
|
||||||
@@ -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
|
|
||||||
+34
@@ -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 };
|
||||||
@@ -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 };
|
||||||
@@ -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 };
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
+119
@@ -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');
|
||||||
@@ -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}`);
|
||||||
|
});
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
<%- include('partials/layout-start') %>
|
||||||
|
|
||||||
|
<div class="account">
|
||||||
|
<h1>Личный кабинет</h1>
|
||||||
|
<div class="card account-card">
|
||||||
|
<p><strong><%= user.name %></strong></p>
|
||||||
|
<p class="muted"><%= user.email %></p>
|
||||||
|
<p class="muted">С нами с <%= new Date(user.created_at).toLocaleDateString('ru-RU') %></p>
|
||||||
|
<div class="account-actions">
|
||||||
|
<a href="/orders" class="btn btn--primary">Мои заказы (<%= orderCount %>)</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<%- include('partials/layout-end') %>
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
<%- include('partials/layout-start') %>
|
||||||
|
|
||||||
|
<h1>Корзина</h1>
|
||||||
|
|
||||||
|
<% if (error) { %><p class="alert alert--error"><%= error %></p><% } %>
|
||||||
|
|
||||||
|
<% if (!items.length) { %>
|
||||||
|
<p class="empty">Корзина пуста. <a href="/">Перейти в каталог</a></p>
|
||||||
|
<% } else { %>
|
||||||
|
<form action="/cart/update" method="post" class="cart-table-wrap">
|
||||||
|
<table class="cart-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Товар</th>
|
||||||
|
<th>Цена</th>
|
||||||
|
<th>Кол-во</th>
|
||||||
|
<th>Сумма</th>
|
||||||
|
<th></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<% items.forEach(item => { %>
|
||||||
|
<tr>
|
||||||
|
<td class="cart-table__product">
|
||||||
|
<% if (item.image_url) { %>
|
||||||
|
<img src="<%= item.image_url %>" alt="" class="cart-table__thumb">
|
||||||
|
<% } %>
|
||||||
|
<a href="/product/<%= item.slug %>"><%= item.name %></a>
|
||||||
|
</td>
|
||||||
|
<td><%= formatPrice(item.price_cents) %></td>
|
||||||
|
<td>
|
||||||
|
<input type="number" name="items[<%= item.id %>]" value="<%= item.quantity %>" min="0" max="<%= item.stock %>" class="input input--qty">
|
||||||
|
</td>
|
||||||
|
<td><%= formatPrice(item.line_total) %></td>
|
||||||
|
<td>
|
||||||
|
<button type="submit" formaction="/cart/remove/<%= item.id %>" formmethod="post" class="btn btn--ghost btn--sm" title="Удалить">×</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<% }) %>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<div class="cart-actions">
|
||||||
|
<button type="submit" class="btn btn--ghost">Обновить</button>
|
||||||
|
<p class="cart-total">Итого: <strong><%= formatPrice(total) %></strong></p>
|
||||||
|
<% if (user) { %>
|
||||||
|
<a href="/checkout" class="btn btn--primary btn--lg">Оформить заказ</a>
|
||||||
|
<% } else { %>
|
||||||
|
<p class="hint"><a href="/login?next=/checkout">Войдите</a>, чтобы оформить заказ.</p>
|
||||||
|
<% } %>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
<% } %>
|
||||||
|
|
||||||
|
<%- include('partials/layout-end') %>
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
<%- include('partials/layout-start') %>
|
||||||
|
|
||||||
|
<h1>Оформление заказа</h1>
|
||||||
|
|
||||||
|
<% if (error) { %><p class="alert alert--error"><%= error %></p><% } %>
|
||||||
|
|
||||||
|
<div class="checkout-layout">
|
||||||
|
<form action="/checkout" method="post" class="form card">
|
||||||
|
<h2>Данные доставки</h2>
|
||||||
|
<label class="label">
|
||||||
|
Имя
|
||||||
|
<input type="text" name="name" class="input" required value="<%= user ? user.name : '' %>">
|
||||||
|
</label>
|
||||||
|
<label class="label">
|
||||||
|
Email
|
||||||
|
<input type="email" name="email" class="input" required value="<%= user ? user.email : '' %>">
|
||||||
|
</label>
|
||||||
|
<label class="label">
|
||||||
|
Телефон
|
||||||
|
<input type="tel" name="phone" class="input" placeholder="+7 …">
|
||||||
|
</label>
|
||||||
|
<label class="label">
|
||||||
|
Адрес доставки
|
||||||
|
<textarea name="address" class="input" rows="3" required placeholder="Город, улица, дом, квартира"></textarea>
|
||||||
|
</label>
|
||||||
|
<button type="submit" class="btn btn--primary btn--lg btn--block">Подтвердить заказ</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<aside class="checkout-summary card">
|
||||||
|
<h2>Ваш заказ</h2>
|
||||||
|
<ul class="checkout-list">
|
||||||
|
<% items.forEach(item => { %>
|
||||||
|
<li>
|
||||||
|
<span><%= item.name %> × <%= item.quantity %></span>
|
||||||
|
<span><%= formatPrice(item.line_total) %></span>
|
||||||
|
</li>
|
||||||
|
<% }) %>
|
||||||
|
</ul>
|
||||||
|
<p class="checkout-total">Итого: <strong><%= formatPrice(total) %></strong></p>
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<%- include('partials/layout-end') %>
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
<%- include('partials/layout-start') %>
|
||||||
|
|
||||||
|
<div class="error-page">
|
||||||
|
<h1><%= code %></h1>
|
||||||
|
<p><%= message %></p>
|
||||||
|
<a href="/" class="btn btn--primary">На главную</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<%- include('partials/layout-end') %>
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
<%- include('partials/layout-start') %>
|
||||||
|
|
||||||
|
<section class="hero">
|
||||||
|
<h1>Каталог товаров</h1>
|
||||||
|
<p>Доставка по России. Оплата при получении.</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<% if (categories.length) { %>
|
||||||
|
<nav class="categories" aria-label="Категории">
|
||||||
|
<a href="/" class="chip <%= !activeCategory ? 'chip--active' : '' %>">Все</a>
|
||||||
|
<% categories.forEach(c => { %>
|
||||||
|
<a href="/?category=<%= c.slug %>" class="chip <%= activeCategory === c.slug ? 'chip--active' : '' %>"><%= c.name %></a>
|
||||||
|
<% }) %>
|
||||||
|
</nav>
|
||||||
|
<% } %>
|
||||||
|
|
||||||
|
<% if (!products.length) { %>
|
||||||
|
<p class="empty">Товары не найдены. Попробуйте другой запрос.</p>
|
||||||
|
<% } else { %>
|
||||||
|
<div class="grid">
|
||||||
|
<% products.forEach(p => { %>
|
||||||
|
<article class="card">
|
||||||
|
<a href="/product/<%= p.slug %>" class="card__image-wrap">
|
||||||
|
<% if (p.image_url) { %>
|
||||||
|
<img src="<%= p.image_url %>" alt="<%= p.name %>" class="card__image" loading="lazy">
|
||||||
|
<% } else { %>
|
||||||
|
<div class="card__placeholder">Нет фото</div>
|
||||||
|
<% } %>
|
||||||
|
</a>
|
||||||
|
<div class="card__body">
|
||||||
|
<% if (p.category_name) { %>
|
||||||
|
<span class="card__category"><%= p.category_name %></span>
|
||||||
|
<% } %>
|
||||||
|
<h2 class="card__title"><a href="/product/<%= p.slug %>"><%= p.name %></a></h2>
|
||||||
|
<p class="card__price"><%= formatPrice(p.price_cents) %></p>
|
||||||
|
<form action="/cart/add" method="post" class="card__form">
|
||||||
|
<input type="hidden" name="product_id" value="<%= p.id %>">
|
||||||
|
<input type="hidden" name="redirect" value="/cart">
|
||||||
|
<button type="submit" class="btn btn--primary btn--block">В корзину</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
<% }) %>
|
||||||
|
</div>
|
||||||
|
<% } %>
|
||||||
|
|
||||||
|
<%- include('partials/layout-end') %>
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
<%- include('partials/layout-start') %>
|
||||||
|
|
||||||
|
<div class="auth">
|
||||||
|
<form action="/login" method="post" class="form card">
|
||||||
|
<h1>Вход</h1>
|
||||||
|
<% if (error) { %><p class="alert alert--error"><%= error %></p><% } %>
|
||||||
|
<input type="hidden" name="next" value="<%= next %>">
|
||||||
|
<label class="label">
|
||||||
|
Email
|
||||||
|
<input type="email" name="email" class="input" required value="<%= values.email || '' %>">
|
||||||
|
</label>
|
||||||
|
<label class="label">
|
||||||
|
Пароль
|
||||||
|
<input type="password" name="password" class="input" required>
|
||||||
|
</label>
|
||||||
|
<button type="submit" class="btn btn--primary btn--block">Войти</button>
|
||||||
|
<p class="form-footer">Нет аккаунта? <a href="/register">Регистрация</a></p>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<%- include('partials/layout-end') %>
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
<%- include('partials/layout-start') %>
|
||||||
|
|
||||||
|
<h1>Заказ #<%= order.id %></h1>
|
||||||
|
|
||||||
|
<% if (success) { %>
|
||||||
|
<p class="alert alert--success">Заказ успешно оформлен! Мы свяжемся с вами по email.</p>
|
||||||
|
<% } %>
|
||||||
|
|
||||||
|
<div class="card order-card">
|
||||||
|
<% const statusLabels = { pending: 'Ожидает обработки', paid: 'Оплачен', shipped: 'Отправлен', cancelled: 'Отменён' }; %>
|
||||||
|
<p><strong>Статус:</strong> <span class="status status--<%= order.status %>"><%= statusLabels[order.status] || order.status %></span></p>
|
||||||
|
<p><strong>Дата:</strong> <%= new Date(order.created_at).toLocaleString('ru-RU') %></p>
|
||||||
|
<p><strong>Доставка:</strong> <%= order.address %></p>
|
||||||
|
<p><strong>Контакт:</strong> <%= order.customer_name %>, <%= order.customer_email %><% if (order.customer_phone) { %>, <%= order.customer_phone %><% } %></p>
|
||||||
|
|
||||||
|
<h2>Состав заказа</h2>
|
||||||
|
<ul class="checkout-list">
|
||||||
|
<% items.forEach(item => { %>
|
||||||
|
<li>
|
||||||
|
<span><a href="/product/<%= item.slug %>"><%= item.name %></a> × <%= item.quantity %></span>
|
||||||
|
<span><%= formatPrice(item.price_cents * item.quantity) %></span>
|
||||||
|
</li>
|
||||||
|
<% }) %>
|
||||||
|
</ul>
|
||||||
|
<p class="checkout-total">Итого: <strong><%= formatPrice(order.total_cents) %></strong></p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p><a href="/orders" class="link-back">← Все заказы</a></p>
|
||||||
|
|
||||||
|
<%- include('partials/layout-end') %>
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
<%- include('partials/layout-start') %>
|
||||||
|
|
||||||
|
<h1>Мои заказы</h1>
|
||||||
|
|
||||||
|
<% if (!orders.length) { %>
|
||||||
|
<p class="empty">Заказов пока нет. <a href="/">Перейти в каталог</a></p>
|
||||||
|
<% } else { %>
|
||||||
|
<table class="cart-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>№</th>
|
||||||
|
<th>Дата</th>
|
||||||
|
<th>Статус</th>
|
||||||
|
<th>Сумма</th>
|
||||||
|
<th></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<% const statusLabels = { pending: 'Ожидает', paid: 'Оплачен', shipped: 'Отправлен', cancelled: 'Отменён' }; %>
|
||||||
|
<% orders.forEach(o => { %>
|
||||||
|
<tr>
|
||||||
|
<td>#<%= o.id %></td>
|
||||||
|
<td><%= new Date(o.created_at).toLocaleString('ru-RU') %></td>
|
||||||
|
<td><span class="status status--<%= o.status %>"><%= statusLabels[o.status] || o.status %></span></td>
|
||||||
|
<td><%= formatPrice(o.total_cents) %></td>
|
||||||
|
<td><a href="/orders/<%= o.id %>">Подробнее</a></td>
|
||||||
|
</tr>
|
||||||
|
<% }) %>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<% } %>
|
||||||
|
|
||||||
|
<%- include('partials/layout-end') %>
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
</main>
|
||||||
|
<footer class="footer">
|
||||||
|
<div class="container">
|
||||||
|
<p>© <%= new Date().getFullYear() %> Shop — локальный интернет-магазин на Node.js + SQLite</p>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="ru">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title><%= title %> — Shop</title>
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=DM+Sans:ital,opsz,wght@0,9..40,400;0,9..40,500;0,9..40,600;0,9..40,700;1,9..40,400&display=swap" rel="stylesheet">
|
||||||
|
<link rel="stylesheet" href="/css/style.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<header class="header">
|
||||||
|
<div class="container header__inner">
|
||||||
|
<a href="/" class="logo">Shop</a>
|
||||||
|
<form class="search" action="/" method="get">
|
||||||
|
<input type="search" name="q" placeholder="Поиск товаров…" value="<%= typeof searchQuery !== 'undefined' ? searchQuery : '' %>" aria-label="Поиск">
|
||||||
|
<button type="submit" class="btn btn--ghost">Найти</button>
|
||||||
|
</form>
|
||||||
|
<nav class="nav">
|
||||||
|
<a href="/cart" class="nav__link nav__cart">
|
||||||
|
Корзина
|
||||||
|
<% if (cartCount > 0) { %><span class="badge"><%= cartCount %></span><% } %>
|
||||||
|
</a>
|
||||||
|
<% if (user) { %>
|
||||||
|
<a href="/account" class="nav__link"><%= user.name %></a>
|
||||||
|
<form action="/logout" method="post" class="inline-form">
|
||||||
|
<button type="submit" class="btn btn--ghost btn--sm">Выйти</button>
|
||||||
|
</form>
|
||||||
|
<% } else { %>
|
||||||
|
<a href="/login" class="nav__link">Вход</a>
|
||||||
|
<a href="/register" class="btn btn--primary btn--sm">Регистрация</a>
|
||||||
|
<% } %>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
<main class="main container">
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
<%- include('partials/layout-start') %>
|
||||||
|
|
||||||
|
<article class="product-detail">
|
||||||
|
<div class="product-detail__media">
|
||||||
|
<% if (product.image_url) { %>
|
||||||
|
<img src="<%= product.image_url %>" alt="<%= product.name %>" class="product-detail__image">
|
||||||
|
<% } else { %>
|
||||||
|
<div class="card__placeholder product-detail__image">Нет фото</div>
|
||||||
|
<% } %>
|
||||||
|
</div>
|
||||||
|
<div class="product-detail__info">
|
||||||
|
<% if (product.category_name) { %>
|
||||||
|
<a href="/?category=<%= product.category_slug %>" class="card__category"><%= product.category_name %></a>
|
||||||
|
<% } %>
|
||||||
|
<h1><%= product.name %></h1>
|
||||||
|
<p class="product-detail__price"><%= formatPrice(product.price_cents) %></p>
|
||||||
|
<p class="product-detail__desc"><%= product.description %></p>
|
||||||
|
<p class="product-detail__stock">В наличии: <strong><%= product.stock %></strong> шт.</p>
|
||||||
|
|
||||||
|
<% if (product.stock > 0) { %>
|
||||||
|
<form action="/cart/add" method="post" class="product-detail__form">
|
||||||
|
<input type="hidden" name="product_id" value="<%= product.id %>">
|
||||||
|
<label class="label">
|
||||||
|
Количество
|
||||||
|
<input type="number" name="quantity" value="1" min="1" max="<%= product.stock %>" class="input input--qty">
|
||||||
|
</label>
|
||||||
|
<input type="hidden" name="redirect" value="/cart">
|
||||||
|
<button type="submit" class="btn btn--primary btn--lg">Добавить в корзину</button>
|
||||||
|
</form>
|
||||||
|
<% } else { %>
|
||||||
|
<p class="alert alert--warn">Нет в наличии</p>
|
||||||
|
<% } %>
|
||||||
|
<a href="/" class="link-back">← Назад в каталог</a>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<%- include('partials/layout-end') %>
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
<%- include('partials/layout-start') %>
|
||||||
|
|
||||||
|
<div class="auth">
|
||||||
|
<form action="/register" method="post" class="form card">
|
||||||
|
<h1>Регистрация</h1>
|
||||||
|
<% if (error) { %><p class="alert alert--error"><%= error %></p><% } %>
|
||||||
|
<label class="label">
|
||||||
|
Имя
|
||||||
|
<input type="text" name="name" class="input" required value="<%= values.name || '' %>">
|
||||||
|
</label>
|
||||||
|
<label class="label">
|
||||||
|
Email
|
||||||
|
<input type="email" name="email" class="input" required value="<%= values.email || '' %>">
|
||||||
|
</label>
|
||||||
|
<label class="label">
|
||||||
|
Пароль
|
||||||
|
<input type="password" name="password" class="input" required minlength="6">
|
||||||
|
</label>
|
||||||
|
<label class="label">
|
||||||
|
Повторите пароль
|
||||||
|
<input type="password" name="password2" class="input" required>
|
||||||
|
</label>
|
||||||
|
<button type="submit" class="btn btn--primary btn--block">Создать аккаунт</button>
|
||||||
|
<p class="form-footer">Уже есть аккаунт? <a href="/login">Войти</a></p>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<%- include('partials/layout-end') %>
|
||||||
@@ -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"
|
|
||||||
@@ -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"
|
|
||||||
Reference in New Issue
Block a user