Compare commits

...

14 Commits

Author SHA1 Message Date
Vakanaut
793d51fdce feat: complete account and dashboard sprint
Some checks failed
CI / build-and-test (pull_request) Has been cancelled
2026-07-12 15:56:26 +03:00
a6108ea927 Merge pull request 'feat: complete p0 hardening sprint' (#39) from feature/p0-hardening-auth-results into main
Some checks failed
CI / build-and-test (push) Has been cancelled
Reviewed-on: #39
2026-07-12 12:13:45 +00:00
Vakanaut
a317c9b760 feat: complete p0 hardening sprint
Some checks failed
CI / build-and-test (pull_request) Has been cancelled
2026-07-12 15:08:00 +03:00
b0ebdeca72 Merge pull request 'docs: add versioned backlog and changelog' (#38) from feature/versioned-backlog into main
Some checks failed
CI / build-and-test (push) Has been cancelled
Reviewed-on: #38
2026-07-12 06:28:25 +00:00
Vaka.pro
c30d6b6041 docs: add versioned backlog and changelog
Some checks failed
CI / build-and-test (pull_request) Has been cancelled
2026-07-12 09:26:20 +03:00
5122ac9d1b Merge pull request 'feature/registration-auth' (#37) from feature/registration-auth into main
Some checks failed
CI / build-and-test (push) Has been cancelled
Reviewed-on: #37
2026-05-24 11:36:51 +00:00
Vaka.pro
83fa04ecf2 Merge origin/main into feature/registration-auth
Some checks failed
CI / build-and-test (pull_request) Has been cancelled
2026-05-24 14:34:45 +03:00
Vaka.pro
fb246e2e55 fix: harden authentication security
Some checks failed
CI / build-and-test (pull_request) Has been cancelled
2026-05-24 14:27:22 +03:00
a22bb7e471 Merge pull request 'feat: add mobile race list tabs' (#36) from feature/mobile-race-list-tabs into main
Some checks failed
CI / build-and-test (push) Has been cancelled
Reviewed-on: #36
2026-05-24 11:13:12 +00:00
Vaka.pro
85aff823fc feat: add mobile race list tabs
Some checks failed
CI / build-and-test (pull_request) Has been cancelled
2026-05-24 14:10:09 +03:00
Vaka.pro
35c3554742 feat: add registration and authentication 2026-05-21 00:01:35 +03:00
13dd8fa426 Merge pull request 'fix: remove fallback image from dashboard race hero' (#35) from fix/dashboard-hero-background-layering into main
Some checks failed
CI / build-and-test (push) Has been cancelled
Reviewed-on: #35
2026-04-27 21:40:55 +00:00
Vaka.pro
f62be600cd fix: remove fallback image from dashboard race hero
Some checks failed
CI / build-and-test (pull_request) Has been cancelled
2026-04-28 00:40:01 +03:00
0f5249726b Merge pull request 'fix: use next race image as dashboard hero background' (#34) from fix/dashboard-hero-race-background into main
Some checks failed
CI / build-and-test (push) Has been cancelled
Reviewed-on: #34
fix: use next race image as dashboard hero background
Set the dashboard hero background from the nearest upcoming race visual, using the existing race visual fallback chain. Add a BEM modifier for the image-backed hero state and bump the frontend patch version.
2026-04-27 20:31:25 +00:00
64 changed files with 4059 additions and 1361 deletions

View File

@@ -20,9 +20,6 @@ DB_PASSWORD=replace_with_strong_secret
# PORT=3000
API_PORT=3001
# ─── Режим без БД (только тесты / smoke API, не для migrate/seed) ─
# CALENDAR_RUN_MOCK_DB=1
# ─── CORS ────────────────────────────────────────────────────
# Должен совпадать с origin в браузере (схема + хост + порт, без пути), иначе API «молчит».
# Локальный Vite: http://localhost:5173
@@ -30,6 +27,34 @@ API_PORT=3001
# Прод: https://ваш-домен — несколько origin через запятую: https://a.ru,https://www.a.ru
CORS_ORIGIN=http://localhost:5173
# ─── Auth / sessions ─────────────────────────────────────────
# APP_BASE_URL is the only source for verify/reset email links.
APP_BASE_URL=http://localhost:5173
SESSION_SECRET=replace_with_32plus_char_random_secret
# Production defaults to __Host-sid + Secure cookies. Local dev can stay insecure over http.
# SESSION_COOKIE_NAME=__Host-sid
# SESSION_COOKIE_SECURE=true
# SESSION_TTL_DAYS=30
# AUTH_CLEANUP_INTERVAL_HOURS=24
# ─── Cloudflare Turnstile ────────────────────────────────────
TURNSTILE_SECRET_KEY=replace_with_turnstile_secret
# Local tests/dev only, rejected in production:
# TURNSTILE_BYPASS_TOKEN=mock-turnstile-token
# ─── SMTP email ──────────────────────────────────────────────
SMTP_HOST=smtp.example.com
SMTP_PORT=587
SMTP_SECURE=false
SMTP_USER=replace_with_smtp_user
SMTP_PASSWORD=replace_with_smtp_password
SMTP_FROM=Calendar Run <no-reply@example.com>
# ─── Seed after auth ─────────────────────────────────────────
# Required once users exist, so seed never creates ownerless races or overwrites user edits.
# SEED_OWNER_USER_ID=
# SEED_OWNER_EMAIL=
# ─── Версия API (опционально) ─────────────────────────────────
# Если в образе не удаётся прочитать package.json, подставьте вручную (видно в GET /health).
# APP_VERSION=1.0.0

View File

@@ -9,6 +9,20 @@ on:
jobs:
build-and-test:
runs-on: ubuntu-latest
services:
postgres:
image: postgres:16-alpine
env:
POSTGRES_DB: calendar_run_test
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
ports:
- 5432:5432
options: >-
--health-cmd "pg_isready -U postgres -d calendar_run_test"
--health-interval 10s
--health-timeout 5s
--health-retries 5
steps:
- uses: actions/checkout@v4
@@ -23,10 +37,18 @@ jobs:
- name: Backend — install, build, test
working-directory: backend
env:
CALENDAR_RUN_MOCK_DB: "1"
NODE_ENV: test
DB_HOST: 127.0.0.1
DB_PORT: "5432"
DB_NAME: calendar_run_test
DB_USER: postgres
DB_PASSWORD: postgres
SESSION_SECRET: test-session-secret
TURNSTILE_BYPASS_TOKEN: mock-turnstile-token
run: |
npm ci
npm run build
npm run db:migrate
npm test
- name: Frontend — install, build

5
.gitignore vendored
View File

@@ -2,4 +2,7 @@ node_modules/
dist/
.env
*.log
.DS_Store
*.tsbuildinfo
vite.config.js
vite.config.d.ts
.DS_Store

188
CHANGELOG.md Normal file
View File

@@ -0,0 +1,188 @@
# Changelog
Все заметки о выпусках Calendar Run. Backend и frontend имеют независимые версии.
Дата означает дату попадания изменения в `main`; она не заменяет дату production-деплоя.
Планируемые версии не вносятся сюда до выпуска — они находятся в [docs/product-backlog.md](docs/product-backlog.md).
## В работе
### Backend 1.5.0
#### Added
- Управление паролем и сессиями: проверка текущего пароля, список активных сессий, отзыв выбранной или всех остальных сессий.
### Frontend 0.8.0
#### Added
- Экран «Аккаунт»: подтверждённый email, смена пароля и управление другими активными сессиями.
- На дашборде появились фильтруемые «Последние старты» с пагинацией и переходом в старт, ссылки из карточек рекордов и интерактивный график прогресса.
## Backend
### [1.4.2] — 2026-07-12
#### Changed
- CI и тесты используют настоящий PostgreSQL с миграциями; самодельный SQL mock удалён.
- Контракт API стартов актуализирован: UUID `id`, пользовательский `slug`, авторизация, CSRF и строгая валидация запросов.
- Получение обложки по сайту организатора ограничено безопасными публичными HTTP(S)-адресами, редиректами, размером и временем ответа.
### [1.4.1] — 2026-05-24
#### Changed
- Усилена безопасность аутентификации, сессий и production security profile.
- Добавлены проверки, предотвращающие использование dev-bypass Turnstile в production.
### [1.4.0] — 2026-05-24
#### Added
- Регистрация, вход/выход, подтверждение email, повторная отправка письма и сброс пароля.
- Cookie-сессии, CSRF-защита, rate limit и Turnstile.
- Владение стартами: пользователь видит и изменяет только свои записи.
- SMTP-уведомления и очистка истёкших auth-сущностей.
### [1.3.0] — 2026-04-27
#### Added
- Необязательное автоматическое извлечение обложки старта по сайту организатора.
- Поле `coverImageUrl` в модели, миграции и API.
### [1.2.2] — 2026-04-12
#### Changed
- Patch-релиз: удалено временное диагностическое логирование запросов.
### [1.2.1] — 2026-04-10
#### Added
- Временная диагностика `Host`/`Origin` и статуса ответа для расследования проблем подключения.
### [1.2.0] — 2026-04-08
#### Changed
- Унифицирован контракт API на префиксе `/api` для Express, Vite и nginx.
### [1.1.0] — 2026-04-08
#### Added
- `GET /api/meta` для отображения серверной версии в интерфейсе.
### [1.0.1] — 2026-04-08
#### Fixed
- Совместимость маршрутов API с прокси и клиентским префиксом `/api`.
### [1.0.0] — 2026-04-01
#### Added
- Базовый Express API для стартов, PostgreSQL-миграции, seed из CSV и health/readiness endpoints.
## Frontend
### [0.7.1] — 2026-07-12
#### Changed
- Формы используют нативные поля даты и времени старта; результаты вводятся отдельными полями времени и места.
- Подтверждение email завершается явным экраном успеха, auth-формы показывают состояние отправки и ошибки.
- Карточка личного рекорда отображается только при реальном улучшении результата.
- Удалены сгенерированные Vite/TypeScript-файлы, неиспользуемый API-клиент и статический каталог обложек.
### [0.7.0] — 2026-05-24
#### Added
- Экраны регистрации, входа, подтверждения email и сброса пароля.
- Защищённые маршруты, состояние сессии и выход из аккаунта.
### [0.6.2] — 2026-05-24
#### Added
- Табы «Будущие» и «Прошедшие» для компактного мобильного списка стартов.
### [0.6.1] — 2026-04-27
#### Changed
- Обложка ближайшего старта используется фоном hero-блока дашборда.
### [0.6.0] — 2026-04-27
#### Added
- Поддержка пользовательской и автоматически полученной обложки старта в списках и карточке.
### [0.5.2] — 2026-04-27
#### Added
- Favicon сервиса.
### [0.5.1] — 2026-04-27
#### Fixed
- Корректное определение версии frontend из `package.json`.
### [0.5.0] — 2026-04-22
#### Changed
- Редизайн дашборда и визуального представления стартов.
### [0.4.0] — 2026-04-13
#### Added
- Форма старта с селектами времени, просмотр календаря годом и месяцем, страница конкретного дня.
- Предзаполнение даты при добавлении из календаря.
### [0.3.1] — 2026-04-12
#### Fixed
- Исправления UX списка и календаря, автоматическая установка статуса «Пробежал» при валидном времени финиша.
### [0.3.0] — 2026-04-08
#### Changed
- Единый клиентский путь `/api` и устойчивый разбор ответов сервера.
### [0.2.0] — 2026-04-08
#### Added
- Русский UI и футер с версиями frontend и backend.
### [0.1.1] — 2026-04-08
#### Fixed
- Получение версии приложения из `package.json` вместо Vite define.
### [0.1.0] — 2026-04-06
#### Added
- React/Vite приложение, типизированный API-клиент, список и карточка стартов, формы CRUD, дашборд и базовая аналитика результатов.
## Как добавлять запись при выпуске
1. Изменить версию соответствующего пакета по SemVer и обновить lockfile.
2. Добавить только фактически выпущенные изменения в раздел `Backend` или `Frontend`.
3. Если изменился API — обновить [docs/backend-api-for-frontend.md](docs/backend-api-for-frontend.md) в том же PR.
4. Перед выпуском проверить, что футер показывает новую версию frontend и фактическую версию backend.

View File

@@ -6,7 +6,7 @@
Один шаблон для локальной разработки и для Docker-стека: `**[.env.example](.env.example)`** → скопируйте в `**.env**` в корне репозитория.
Там же перечислены `**DB_HOST**`, `**DB_PORT**`, `**DB_NAME**`, `**DB_USER**`, `**DB_PASSWORD**` (подключение бэкенда к БД), `**PORT**` / `**API_PORT**`, опционально `**CALENDAR_RUN_MOCK_DB**` и `**CORS_ORIGIN**`.
Там же перечислены `**DB_HOST**`, `**DB_PORT**`, `**DB_NAME**`, `**DB_USER**`, `**DB_PASSWORD**` (подключение бэкенда к БД), `**PORT**` / `**API_PORT**` и `**CORS_ORIGIN**`.
## Backend — локально
@@ -17,8 +17,6 @@
5. Dev-режим: `npm run dev`
6. Или production: `npm run build && npm start`
Без PostgreSQL (только smoke API): в `.env` задайте `CALENDAR_RUN_MOCK_DB=1`; `**db:migrate` и `seed` с mock не использовать**.
## Frontend — локально
```bash
@@ -46,4 +44,3 @@ docker compose -f docker-compose.stack.yml exec runners-calendar-backend node di
- [Шпаргалка API для фронта](docs/backend-api-for-frontend.md)
- [Эксплуатация backend](docs/backend.md)

View File

@@ -0,0 +1,114 @@
CREATE EXTENSION IF NOT EXISTS pgcrypto;
CREATE TABLE IF NOT EXISTS users (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
email TEXT NOT NULL,
password_hash TEXT NOT NULL,
email_verified_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ
);
CREATE UNIQUE INDEX IF NOT EXISTS users_email_normalized_key
ON users (LOWER(BTRIM(email)));
CREATE TABLE IF NOT EXISTS sessions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
token_hash TEXT NOT NULL UNIQUE,
csrf_token_hash TEXT NOT NULL,
expires_at TIMESTAMPTZ NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
last_seen_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
revoked_at TIMESTAMPTZ
);
CREATE INDEX IF NOT EXISTS sessions_user_id_idx ON sessions(user_id);
CREATE INDEX IF NOT EXISTS sessions_expires_at_idx ON sessions(expires_at);
CREATE TABLE IF NOT EXISTS email_verification_tokens (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
token_hash TEXT NOT NULL UNIQUE,
expires_at TIMESTAMPTZ NOT NULL,
used_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS email_verification_tokens_user_id_idx
ON email_verification_tokens(user_id);
CREATE TABLE IF NOT EXISTS password_reset_tokens (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
token_hash TEXT NOT NULL UNIQUE,
expires_at TIMESTAMPTZ NOT NULL,
used_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS password_reset_tokens_user_id_idx
ON password_reset_tokens(user_id);
CREATE TABLE IF NOT EXISTS app_settings (
key TEXT PRIMARY KEY,
value TEXT NOT NULL,
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
DO $$
DECLARE
id_type TEXT;
pk_name TEXT;
BEGIN
SELECT data_type INTO id_type
FROM information_schema.columns
WHERE table_name = 'races' AND column_name = 'id';
IF id_type IS NOT NULL AND id_type <> 'uuid' THEN
SELECT conname INTO pk_name
FROM pg_constraint
WHERE conrelid = 'races'::regclass AND contype = 'p'
LIMIT 1;
IF pk_name IS NOT NULL THEN
EXECUTE format('ALTER TABLE races DROP CONSTRAINT %I', pk_name);
END IF;
IF NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name = 'races' AND column_name = 'slug'
) THEN
ALTER TABLE races RENAME COLUMN id TO slug;
ELSE
ALTER TABLE races DROP COLUMN id;
END IF;
END IF;
END $$;
ALTER TABLE races ADD COLUMN IF NOT EXISTS id UUID DEFAULT gen_random_uuid();
UPDATE races SET id = gen_random_uuid() WHERE id IS NULL;
ALTER TABLE races ALTER COLUMN id SET NOT NULL;
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint
WHERE conrelid = 'races'::regclass AND contype = 'p'
) THEN
ALTER TABLE races ADD CONSTRAINT races_pkey PRIMARY KEY (id);
END IF;
END $$;
ALTER TABLE races ADD COLUMN IF NOT EXISTS slug TEXT;
UPDATE races SET slug = id::text WHERE slug IS NULL OR BTRIM(slug) = '';
ALTER TABLE races ALTER COLUMN slug SET NOT NULL;
ALTER TABLE races ADD COLUMN IF NOT EXISTS owner_user_id UUID REFERENCES users(id) ON DELETE CASCADE;
ALTER TABLE races ADD COLUMN IF NOT EXISTS source TEXT NOT NULL DEFAULT 'user';
CREATE UNIQUE INDEX IF NOT EXISTS races_owner_slug_key
ON races(owner_user_id, slug)
WHERE owner_user_id IS NOT NULL;
CREATE INDEX IF NOT EXISTS races_owner_user_id_idx ON races(owner_user_id);

View File

@@ -1,26 +1,33 @@
{
"name": "calendar-run-backend",
"version": "1.3.0",
"version": "1.5.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "calendar-run-backend",
"version": "1.3.0",
"version": "1.5.0",
"dependencies": {
"argon2": "^0.44.0",
"cookie-parser": "^1.4.7",
"cors": "^2.8.5",
"csv-parse": "^5.6.0",
"dotenv": "^16.4.7",
"express": "^4.21.2",
"pg": "^8.13.1"
"express-rate-limit": "^8.4.1",
"helmet": "^8.1.0",
"nodemailer": "^8.0.7",
"pg": "^8.13.1",
"zod": "^4.3.6"
},
"devDependencies": {
"@types/cookie-parser": "^1.4.10",
"@types/cors": "^2.8.17",
"@types/express": "^5.0.0",
"@types/node": "^22.12.0",
"@types/nodemailer": "^8.0.0",
"@types/pg": "^8.11.10",
"@types/supertest": "^6.0.2",
"cross-env": "^10.1.0",
"supertest": "^7.0.0",
"ts-node": "^10.9.2",
"tsx": "^4.19.2",
@@ -44,7 +51,6 @@
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@epic-web/invariant/-/invariant-1.0.0.tgz",
"integrity": "sha512-lrTPqgvfFQtR/eY/qkIzp98OGdNJu0m5ji3q/nJI8v3SXkRKEnWiOxMmbvcSoAIzv/cGiuvRy57k4suKQSAdwA==",
"dev": true,
"license": "MIT"
},
"node_modules/@esbuild/aix-ppc64": {
@@ -540,6 +546,15 @@
"@noble/hashes": "^1.1.5"
}
},
"node_modules/@phc/format": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@phc/format/-/format-1.0.0.tgz",
"integrity": "sha512-m7X9U6BG2+J+R1lSOdCiITLLrxm+cWlNI3HUFA92oLO77ObGNzaKdh8pMLqdZcshtkKuV84olNNXDfMc4FezBQ==",
"license": "MIT",
"engines": {
"node": ">=10"
}
},
"node_modules/@tsconfig/node10": {
"version": "1.0.12",
"resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.12.tgz",
@@ -589,6 +604,16 @@
"@types/node": "*"
}
},
"node_modules/@types/cookie-parser": {
"version": "1.4.10",
"resolved": "https://registry.npmjs.org/@types/cookie-parser/-/cookie-parser-1.4.10.tgz",
"integrity": "sha512-B4xqkqfZ8Wek+rCOeRxsjMS9OgvzebEzzLYw7NHYuvzb7IdxOkI0ZHGgeEBX4PUM7QGVvNSK60T3OvWj3YfBRg==",
"dev": true,
"license": "MIT",
"peerDependencies": {
"@types/express": "*"
}
},
"node_modules/@types/cookiejar": {
"version": "2.1.5",
"resolved": "https://registry.npmjs.org/@types/cookiejar/-/cookiejar-2.1.5.tgz",
@@ -651,11 +676,20 @@
"integrity": "sha512-F0R/h2+dsy5wJAUe3tAU6oqa2qbWY5TpNfL/RGmo1y38hiyO1w3x2jPtt76wmuaJI4DQnOBu21cNXQ2STIUUWg==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"undici-types": "~6.21.0"
}
},
"node_modules/@types/nodemailer": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/@types/nodemailer/-/nodemailer-8.0.0.tgz",
"integrity": "sha512-fyf8jWULsCo0d0BuoQ75i6IeoHs47qcqxWc7yUdUcV0pOZGjUTTOvwdG1PRXUDqN/8A64yQdQdnA2pZgcdi+cA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/pg": {
"version": "8.20.0",
"resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.20.0.tgz",
@@ -773,6 +807,22 @@
"dev": true,
"license": "MIT"
},
"node_modules/argon2": {
"version": "0.44.0",
"resolved": "https://registry.npmjs.org/argon2/-/argon2-0.44.0.tgz",
"integrity": "sha512-zHPGN3S55sihSQo0dBbK0A5qpi2R31z7HZDZnry3ifOyj8bZZnpZND2gpmhnRGO1V/d555RwBqIK5W4Mrmv3ig==",
"hasInstallScript": true,
"license": "MIT",
"dependencies": {
"@phc/format": "^1.0.0",
"cross-env": "^10.0.0",
"node-addon-api": "^8.5.0",
"node-gyp-build": "^4.8.4"
},
"engines": {
"node": ">=16.17.0"
}
},
"node_modules/array-flatten": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz",
@@ -908,6 +958,25 @@
"node": ">= 0.6"
}
},
"node_modules/cookie-parser": {
"version": "1.4.7",
"resolved": "https://registry.npmjs.org/cookie-parser/-/cookie-parser-1.4.7.tgz",
"integrity": "sha512-nGUvgXnotP3BsjiLX2ypbQnWoGUPIIfHQNZkkC668ntrzGWEZVW70HDEB1qnNGMicPje6EttlIgzo51YSwNQGw==",
"license": "MIT",
"dependencies": {
"cookie": "0.7.2",
"cookie-signature": "1.0.6"
},
"engines": {
"node": ">= 0.8.0"
}
},
"node_modules/cookie-parser/node_modules/cookie-signature": {
"version": "1.0.6",
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz",
"integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==",
"license": "MIT"
},
"node_modules/cookie-signature": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz",
@@ -949,7 +1018,6 @@
"version": "10.1.0",
"resolved": "https://registry.npmjs.org/cross-env/-/cross-env-10.1.0.tgz",
"integrity": "sha512-GsYosgnACZTADcmEyJctkJIoqAhHjttw7RsFrVoJNXbsWWqaq6Ym+7kZjq6mS45O0jij6vtiReppKQEtqWy6Dw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@epic-web/invariant": "^1.0.0",
@@ -967,7 +1035,6 @@
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
"integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==",
"dev": true,
"license": "MIT",
"dependencies": {
"path-key": "^3.1.0",
@@ -1233,6 +1300,24 @@
"url": "https://opencollective.com/express"
}
},
"node_modules/express-rate-limit": {
"version": "8.4.1",
"resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.4.1.tgz",
"integrity": "sha512-NGVYwQSAyEQgzxX1iCM978PP9AdO/hW93gMcF6ZwQCm+rFvLsBH6w4xcXWTcliS8La5EPRN3p9wzItqBwJrfNw==",
"license": "MIT",
"dependencies": {
"ip-address": "10.1.0"
},
"engines": {
"node": ">= 16"
},
"funding": {
"url": "https://github.com/sponsors/express-rate-limit"
},
"peerDependencies": {
"express": ">= 4.11"
}
},
"node_modules/fast-safe-stringify": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/fast-safe-stringify/-/fast-safe-stringify-2.1.1.tgz",
@@ -1437,6 +1522,15 @@
"node": ">= 0.4"
}
},
"node_modules/helmet": {
"version": "8.1.0",
"resolved": "https://registry.npmjs.org/helmet/-/helmet-8.1.0.tgz",
"integrity": "sha512-jOiHyAZsmnr8LqoPGmCjYAaiuWwjAPLgY8ZX2XrmHawt99/u1y6RgrZMTeoPfpUbV96HOalYgz1qzkRbw54Pmg==",
"license": "MIT",
"engines": {
"node": ">=18.0.0"
}
},
"node_modules/http-errors": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
@@ -1475,6 +1569,15 @@
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
"license": "ISC"
},
"node_modules/ip-address": {
"version": "10.1.0",
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz",
"integrity": "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==",
"license": "MIT",
"engines": {
"node": ">= 12"
}
},
"node_modules/ipaddr.js": {
"version": "1.9.1",
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
@@ -1488,7 +1591,6 @@
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
"integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
"dev": true,
"license": "ISC"
},
"node_modules/make-error": {
@@ -1582,6 +1684,35 @@
"node": ">= 0.6"
}
},
"node_modules/node-addon-api": {
"version": "8.7.0",
"resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-8.7.0.tgz",
"integrity": "sha512-9MdFxmkKaOYVTV+XVRG8ArDwwQ77XIgIPyKASB1k3JPq3M8fGQQQE3YpMOrKm6g//Ktx8ivZr8xo1Qmtqub+GA==",
"license": "MIT",
"engines": {
"node": "^18 || ^20 || >= 21"
}
},
"node_modules/node-gyp-build": {
"version": "4.8.4",
"resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.8.4.tgz",
"integrity": "sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==",
"license": "MIT",
"bin": {
"node-gyp-build": "bin.js",
"node-gyp-build-optional": "optional.js",
"node-gyp-build-test": "build-test.js"
}
},
"node_modules/nodemailer": {
"version": "8.0.7",
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-8.0.7.tgz",
"integrity": "sha512-pkjE4mkBzQjdJT4/UmlKl3pX0rC9fZmjh7c6C9o7lv66Ac6w9WCnzPzhbPNxwZAzlF4mdq4CSWB5+FbK6FWCow==",
"license": "MIT-0",
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/object-assign": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
@@ -1638,7 +1769,6 @@
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
"integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
@@ -1655,7 +1785,6 @@
"resolved": "https://registry.npmjs.org/pg/-/pg-8.20.0.tgz",
"integrity": "sha512-ldhMxz2r8fl/6QkXnBD3CR9/xg694oT6DZQ2s6c/RI28OjtSOpxnPrUCGOBJ46RCUxcWdx3p6kw/xnDHjKvaRA==",
"license": "MIT",
"peer": true,
"dependencies": {
"pg-connection-string": "^2.12.0",
"pg-pool": "^3.13.0",
@@ -1922,7 +2051,6 @@
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
"integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==",
"dev": true,
"license": "MIT",
"dependencies": {
"shebang-regex": "^3.0.0"
@@ -1935,7 +2063,6 @@
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz",
"integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
@@ -2207,7 +2334,6 @@
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true,
"license": "Apache-2.0",
"peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@@ -2261,7 +2387,6 @@
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
"integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==",
"dev": true,
"license": "ISC",
"dependencies": {
"isexe": "^2.0.0"
@@ -2298,6 +2423,15 @@
"engines": {
"node": ">=6"
}
},
"node_modules/zod": {
"version": "4.3.6",
"resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz",
"integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}
}
}
}

View File

@@ -1,6 +1,6 @@
{
"name": "calendar-run-backend",
"version": "1.3.0",
"version": "1.5.0",
"private": true,
"scripts": {
"build": "tsc",
@@ -8,22 +8,29 @@
"start": "node dist/index.js",
"db:migrate": "ts-node src/migrate.ts",
"seed": "ts-node src/seed.ts",
"test": "cross-env CALENDAR_RUN_MOCK_DB=1 tsx --test test/app.test.ts"
"test": "tsx --import ./test/setup.ts --test test/app.test.ts"
},
"dependencies": {
"argon2": "^0.44.0",
"cookie-parser": "^1.4.7",
"cors": "^2.8.5",
"csv-parse": "^5.6.0",
"dotenv": "^16.4.7",
"express": "^4.21.2",
"pg": "^8.13.1"
"express-rate-limit": "^8.4.1",
"helmet": "^8.1.0",
"nodemailer": "^8.0.7",
"pg": "^8.13.1",
"zod": "^4.3.6"
},
"devDependencies": {
"@types/cookie-parser": "^1.4.10",
"@types/cors": "^2.8.17",
"@types/express": "^5.0.0",
"@types/node": "^22.12.0",
"@types/nodemailer": "^8.0.0",
"@types/pg": "^8.11.10",
"@types/supertest": "^6.0.2",
"cross-env": "^10.1.0",
"supertest": "^7.0.0",
"ts-node": "^10.9.2",
"tsx": "^4.19.2",

View File

@@ -1,18 +1,59 @@
import express, { Request, Response, NextFunction } from "express";
import cors from "cors";
import cookieParser from "cookie-parser";
import helmet from "helmet";
import { config } from "./config";
import { loadAuth, requireCsrf } from "./authMiddleware";
import authRouter from "./routes/auth";
import healthRouter from "./routes/health";
import racesRouter from "./routes/races";
const TURNSTILE_ORIGIN = "https://challenges.cloudflare.com";
export function buildHelmetOptions(securityProfile: string) {
return {
contentSecurityPolicy:
securityProfile === "production"
? {
directives: {
defaultSrc: ["'self'"],
scriptSrc: ["'self'", TURNSTILE_ORIGIN],
styleSrc: ["'self'"],
imgSrc: ["'self'", "data:", "https:"],
connectSrc: ["'self'", TURNSTILE_ORIGIN],
frameSrc: [TURNSTILE_ORIGIN],
objectSrc: ["'none'"],
frameAncestors: ["'none'"],
},
}
: false,
hsts:
securityProfile === "production"
? { maxAge: 31_536_000, includeSubDomains: true }
: false,
referrerPolicy: { policy: "strict-origin-when-cross-origin" as const },
};
}
export function createApp(): express.Express {
const app = express();
app.use(helmet(buildHelmetOptions(config.securityProfile)));
app.use(
cors({ origin: config.corsOrigin, methods: ["GET", "POST", "PATCH", "DELETE", "OPTIONS"] }),
cors({
origin: config.corsOrigin,
credentials: true,
methods: ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"],
allowedHeaders: ["Content-Type", "X-CSRF-Token"],
}),
);
app.use(express.json());
app.use(cookieParser(config.session.secret));
app.use(loadAuth);
app.use(requireCsrf);
app.use("/api", healthRouter);
app.use("/api", authRouter);
app.use("/api", racesRouter);
app.use((err: unknown, _req: Request, res: Response, _next: NextFunction) => {

View File

@@ -0,0 +1,83 @@
import { NextFunction, Request, Response } from "express";
import { config } from "./config";
import { csrfMatches, getSession } from "./authService";
declare global {
namespace Express {
interface Request {
auth?: {
user: {
id: string;
email: string;
emailVerifiedAt: string | null;
};
csrfTokenHash: string;
sessionToken: string;
};
}
}
}
const MUTATING_METHODS = new Set(["POST", "PUT", "PATCH", "DELETE"]);
export function setSessionCookie(res: Response, sessionToken: string): void {
res.cookie(config.session.cookieName, sessionToken, {
httpOnly: true,
secure: config.session.secure,
sameSite: "lax",
path: "/",
maxAge: config.session.ttlDays * 24 * 60 * 60 * 1000,
});
}
export function clearSessionCookie(res: Response): void {
res.clearCookie(config.session.cookieName, {
httpOnly: true,
secure: config.session.secure,
sameSite: "lax",
path: "/",
});
}
export async function loadAuth(req: Request, _res: Response, next: NextFunction): Promise<void> {
const token = req.cookies?.[config.session.cookieName];
if (typeof token !== "string" || token.trim() === "") {
next();
return;
}
try {
const session = await getSession(token);
if (session) {
req.auth = { ...session, sessionToken: token };
}
} catch (error) {
next(error);
return;
}
next();
}
export function requireAuth(req: Request, res: Response, next: NextFunction): void {
if (!req.auth) {
res.status(401).json({ error: "unauthorized", details: ["Authentication required"] });
return;
}
if (!req.auth.user.emailVerifiedAt) {
res.status(403).json({ error: "email_not_verified", details: ["Email verification required"] });
return;
}
next();
}
export function requireCsrf(req: Request, res: Response, next: NextFunction): void {
if (!MUTATING_METHODS.has(req.method) || !req.auth) {
next();
return;
}
const token = req.header("X-CSRF-Token");
if (!token || !csrfMatches(req.auth.csrfTokenHash, token)) {
res.status(403).json({ error: "csrf_error", details: ["Invalid CSRF token"] });
return;
}
next();
}

490
backend/src/authService.ts Normal file
View File

@@ -0,0 +1,490 @@
import { PoolClient } from "pg";
import { config } from "./config";
import { pool } from "./db";
import { sendMail } from "./mailer";
import {
addDays,
addHours,
hashPassword,
normalizeEmail,
randomToken,
sha256Hex,
timingSafeEqualHex,
verifyPassword,
} from "./security";
import { anonymizeEmail, securityLog } from "./securityLog";
export interface AuthUser {
id: string;
email: string;
emailVerifiedAt: string | null;
}
interface UserRow {
id: string;
email: string;
password_hash: string;
email_verified_at: Date | string | null;
}
interface SessionRow {
id: string;
user_id: string;
token_hash: string;
csrf_token_hash: string;
expires_at: Date | string;
email: string;
email_verified_at: Date | string | null;
}
interface SessionListRow {
id: string;
created_at: Date | string;
last_seen_at: Date | string;
expires_at: Date | string;
current: boolean;
}
export interface ActiveSession {
id: string;
createdAt: string;
lastSeenAt: string;
expiresAt: string;
current: boolean;
}
function toIso(value: Date | string | null): string | null {
if (!value) {
return null;
}
return value instanceof Date ? value.toISOString() : String(value);
}
function userFromRow(row: UserRow): AuthUser {
return {
id: row.id,
email: row.email,
emailVerifiedAt: toIso(row.email_verified_at),
};
}
function appUrl(path: string, token: string): string {
const url = new URL(path, config.appBaseUrl);
url.searchParams.set("token", token);
return url.toString();
}
function isUniqueViolation(error: unknown): boolean {
return typeof error === "object" && error !== null && (error as { code?: unknown }).code === "23505";
}
export async function findUserByEmail(email: string): Promise<UserRow | null> {
const normalized = normalizeEmail(email);
const { rows } = await pool.query<UserRow>(
"SELECT id, email, password_hash, email_verified_at FROM users WHERE LOWER(BTRIM(email)) = $1",
[normalized],
);
return rows[0] ?? null;
}
export async function createVerificationToken(client: PoolClient, userId: string): Promise<string> {
await client.query(
"UPDATE email_verification_tokens SET used_at = NOW() WHERE user_id = $1 AND used_at IS NULL",
[userId],
);
const token = randomToken(32);
await client.query(
`INSERT INTO email_verification_tokens (user_id, token_hash, expires_at)
VALUES ($1, $2, $3)`,
[userId, sha256Hex(token), addHours(new Date(), 24)],
);
return token;
}
export async function createResetToken(client: PoolClient, userId: string): Promise<string> {
await client.query(
"UPDATE password_reset_tokens SET used_at = NOW() WHERE user_id = $1 AND used_at IS NULL",
[userId],
);
const token = randomToken(32);
await client.query(
`INSERT INTO password_reset_tokens (user_id, token_hash, expires_at)
VALUES ($1, $2, $3)`,
[userId, sha256Hex(token), addHours(new Date(), 1)],
);
return token;
}
export async function sendVerificationEmail(email: string, token: string): Promise<void> {
await sendMail(
email,
"Подтвердите email в Calendar Run",
`Откройте ссылку для подтверждения email: ${appUrl("/verify-email", token)}`,
);
}
export async function sendResetEmail(email: string, token: string): Promise<void> {
await sendMail(
email,
"Сброс пароля Calendar Run",
`Откройте ссылку для сброса пароля: ${appUrl("/reset-password", token)}`,
);
}
export async function registerUser(email: string, password: string): Promise<void> {
const normalized = normalizeEmail(email);
const passwordHash = await hashPassword(password);
const client = await pool.connect();
try {
await client.query("BEGIN");
let rows: UserRow[];
try {
({ rows } = await client.query<UserRow>(
`INSERT INTO users (email, password_hash)
VALUES ($1, $2)
RETURNING id, email, password_hash, email_verified_at`,
[normalized, passwordHash],
));
} catch (error) {
if (isUniqueViolation(error)) {
await client.query("ROLLBACK");
securityLog("register.existing", { emailHash: anonymizeEmail(normalized) });
return;
}
throw error;
}
const user = rows[0];
if (user) {
const token = await createVerificationToken(client, user.id);
await client.query("COMMIT");
await sendVerificationEmail(user.email, token);
securityLog("register.created", { emailHash: anonymizeEmail(normalized) });
return;
}
await client.query("COMMIT");
securityLog("register.existing", { emailHash: anonymizeEmail(normalized) });
} catch (error) {
await client.query("ROLLBACK");
throw error;
} finally {
client.release();
}
}
export async function createSession(userId: string): Promise<{ sessionToken: string; csrfToken: string; user: AuthUser }> {
const sessionToken = randomToken(32);
const csrfToken = randomToken(32);
const expiresAt = addDays(new Date(), config.session.ttlDays);
const { rows } = await pool.query<SessionRow>(
`INSERT INTO sessions (user_id, token_hash, csrf_token_hash, expires_at)
VALUES ($1, $2, $3, $4)
RETURNING id, user_id, token_hash, csrf_token_hash, expires_at,
(SELECT email FROM users WHERE id = $1) AS email,
(SELECT email_verified_at FROM users WHERE id = $1) AS email_verified_at`,
[userId, sha256Hex(sessionToken), sha256Hex(csrfToken), expiresAt],
);
const row = rows[0];
securityLog("session.issued", { userId });
return {
sessionToken,
csrfToken,
user: {
id: row.user_id,
email: row.email,
emailVerifiedAt: toIso(row.email_verified_at),
},
};
}
export async function loginUser(email: string, password: string): Promise<{ ok: false } | { ok: true; sessionToken: string; csrfToken: string; user: AuthUser }> {
const normalized = normalizeEmail(email);
const user = await findUserByEmail(normalized);
const passwordOk = await verifyPassword(user?.password_hash ?? null, password);
if (!user || !passwordOk || !user.email_verified_at) {
securityLog("login.failed", { emailHash: anonymizeEmail(normalized) });
return { ok: false };
}
const session = await createSession(user.id);
securityLog("login.succeeded", { userId: user.id });
return { ok: true, ...session };
}
export async function revokeSession(sessionToken: string): Promise<void> {
await pool.query("UPDATE sessions SET revoked_at = NOW() WHERE token_hash = $1", [sha256Hex(sessionToken)]);
securityLog("session.revoked");
}
export async function listActiveSessions(userId: string, sessionToken: string): Promise<ActiveSession[]> {
const { rows } = await pool.query<SessionListRow>(
`SELECT id, created_at, last_seen_at, expires_at, token_hash = $2 AS current
FROM sessions
WHERE user_id = $1 AND revoked_at IS NULL AND expires_at > NOW()
ORDER BY created_at DESC`,
[userId, sha256Hex(sessionToken)],
);
return rows.map((row) => ({
id: row.id,
createdAt: toIso(row.created_at)!,
lastSeenAt: toIso(row.last_seen_at)!,
expiresAt: toIso(row.expires_at)!,
current: row.current,
}));
}
export async function revokeSessionById(userId: string, sessionId: string, sessionToken: string): Promise<{ revoked: boolean; current: boolean }> {
const { rows } = await pool.query<{ current: boolean }>(
`UPDATE sessions
SET revoked_at = NOW()
WHERE id = $1 AND user_id = $2 AND revoked_at IS NULL AND expires_at > NOW()
RETURNING token_hash = $3 AS current`,
[sessionId, userId, sha256Hex(sessionToken)],
);
const row = rows[0];
if (!row) {
return { revoked: false, current: false };
}
securityLog("session.revoked", { userId });
return { revoked: true, current: row.current };
}
export async function revokeOtherSessions(userId: string, sessionToken: string): Promise<void> {
await pool.query(
`UPDATE sessions
SET revoked_at = NOW()
WHERE user_id = $1 AND token_hash <> $2 AND revoked_at IS NULL AND expires_at > NOW()`,
[userId, sha256Hex(sessionToken)],
);
securityLog("session.others_revoked", { userId });
}
export async function changePassword(
userId: string,
sessionToken: string,
currentPassword: string,
newPassword: string,
): Promise<boolean> {
const newPasswordHash = await hashPassword(newPassword);
const client = await pool.connect();
try {
await client.query("BEGIN");
const { rows } = await client.query<{ password_hash: string }>(
"SELECT password_hash FROM users WHERE id = $1 FOR UPDATE",
[userId],
);
const user = rows[0];
if (!user || !(await verifyPassword(user.password_hash, currentPassword))) {
await client.query("ROLLBACK");
return false;
}
await client.query("UPDATE users SET password_hash = $2, updated_at = NOW() WHERE id = $1", [userId, newPasswordHash]);
await client.query(
"UPDATE sessions SET revoked_at = NOW() WHERE user_id = $1 AND token_hash <> $2 AND revoked_at IS NULL",
[userId, sha256Hex(sessionToken)],
);
await client.query("COMMIT");
securityLog("password.changed", { userId });
return true;
} catch (error) {
await client.query("ROLLBACK");
throw error;
} finally {
client.release();
}
}
export async function rotateCsrf(sessionToken: string): Promise<string | null> {
const csrfToken = randomToken(32);
const { rowCount } = await pool.query(
`UPDATE sessions
SET csrf_token_hash = $2, last_seen_at = NOW()
WHERE token_hash = $1 AND revoked_at IS NULL AND expires_at > NOW()`,
[sha256Hex(sessionToken), sha256Hex(csrfToken)],
);
return rowCount && rowCount > 0 ? csrfToken : null;
}
export async function getSession(sessionToken: string): Promise<{ user: AuthUser; csrfTokenHash: string } | null> {
const tokenHash = sha256Hex(sessionToken);
const { rows } = await pool.query<SessionRow>(
`SELECT s.id, s.user_id, s.token_hash, s.csrf_token_hash, s.expires_at, u.email, u.email_verified_at
FROM sessions s
JOIN users u ON u.id = s.user_id
WHERE s.token_hash = $1 AND s.revoked_at IS NULL AND s.expires_at > NOW()`,
[tokenHash],
);
const row = rows[0];
if (!row) {
return null;
}
await pool.query("UPDATE sessions SET last_seen_at = NOW(), expires_at = $2 WHERE id = $1", [
row.id,
addDays(new Date(), config.session.ttlDays),
]);
return {
user: {
id: row.user_id,
email: row.email,
emailVerifiedAt: toIso(row.email_verified_at),
},
csrfTokenHash: row.csrf_token_hash,
};
}
export function csrfMatches(hash: string, token: string): boolean {
return timingSafeEqualHex(hash, sha256Hex(token));
}
export async function verifyEmailToken(token: string): Promise<boolean> {
const tokenHash = sha256Hex(token);
const client = await pool.connect();
try {
await client.query("BEGIN");
const { rows } = await client.query<{ id: string; user_id: string; token_hash: string }>(
`SELECT id, user_id, token_hash
FROM email_verification_tokens
WHERE token_hash = $1 AND used_at IS NULL AND expires_at > NOW()
FOR UPDATE`,
[tokenHash],
);
const row = rows.find((candidate) => timingSafeEqualHex(candidate.token_hash, tokenHash));
if (!row) {
await client.query("ROLLBACK");
return false;
}
const consumed = await client.query(
"UPDATE email_verification_tokens SET used_at = NOW() WHERE id = $1 AND used_at IS NULL RETURNING id",
[row.id],
);
if (consumed.rowCount === 0) {
await client.query("ROLLBACK");
return false;
}
await client.query(
"UPDATE email_verification_tokens SET used_at = NOW() WHERE user_id = $1 AND id <> $2 AND used_at IS NULL",
[row.user_id, row.id],
);
await client.query("UPDATE users SET email_verified_at = COALESCE(email_verified_at, NOW()), updated_at = NOW() WHERE id = $1", [row.user_id]);
await client.query("SELECT pg_advisory_xact_lock(706365)");
const claimed = await client.query("SELECT value FROM app_settings WHERE key = 'orphan_races_claimed_by_user_id' FOR UPDATE");
if (claimed.rows.length === 0) {
await client.query("UPDATE races SET owner_user_id = $1, updated_at = NOW() WHERE owner_user_id IS NULL", [row.user_id]);
await client.query(
`INSERT INTO app_settings (key, value)
VALUES ('orphan_races_claimed_by_user_id', $1)`,
[row.user_id],
);
}
await client.query("COMMIT");
securityLog("email.verified", { userId: row.user_id });
return true;
} catch (error) {
await client.query("ROLLBACK");
throw error;
} finally {
client.release();
}
}
export async function requestPasswordReset(email: string): Promise<void> {
const normalized = normalizeEmail(email);
const user = await findUserByEmail(normalized);
if (!user) {
securityLog("password_reset.requested_missing", { emailHash: anonymizeEmail(normalized) });
return;
}
const client = await pool.connect();
try {
await client.query("BEGIN");
const token = await createResetToken(client, user.id);
await client.query("COMMIT");
await sendResetEmail(user.email, token);
securityLog("password_reset.requested", { userId: user.id });
} catch (error) {
await client.query("ROLLBACK");
throw error;
} finally {
client.release();
}
}
export async function resetPassword(token: string, password: string): Promise<boolean> {
const tokenHash = sha256Hex(token);
const passwordHash = await hashPassword(password);
const client = await pool.connect();
try {
await client.query("BEGIN");
const { rows } = await client.query<{ id: string; user_id: string; token_hash: string }>(
`SELECT id, user_id, token_hash
FROM password_reset_tokens
WHERE token_hash = $1 AND used_at IS NULL AND expires_at > NOW()
FOR UPDATE`,
[tokenHash],
);
const row = rows.find((candidate) => timingSafeEqualHex(candidate.token_hash, tokenHash));
if (!row) {
await client.query("ROLLBACK");
return false;
}
const consumed = await client.query(
"UPDATE password_reset_tokens SET used_at = NOW() WHERE id = $1 AND used_at IS NULL RETURNING id",
[row.id],
);
if (consumed.rowCount === 0) {
await client.query("ROLLBACK");
return false;
}
await client.query(
"UPDATE password_reset_tokens SET used_at = NOW() WHERE user_id = $1 AND id <> $2 AND used_at IS NULL",
[row.user_id, row.id],
);
await client.query("UPDATE users SET password_hash = $2, updated_at = NOW() WHERE id = $1", [row.user_id, passwordHash]);
await client.query("UPDATE sessions SET revoked_at = NOW() WHERE user_id = $1 AND revoked_at IS NULL", [row.user_id]);
await client.query("COMMIT");
securityLog("password_reset.completed", { userId: row.user_id });
return true;
} catch (error) {
await client.query("ROLLBACK");
throw error;
} finally {
client.release();
}
}
export async function resendVerification(email: string): Promise<void> {
const normalized = normalizeEmail(email);
const user = await findUserByEmail(normalized);
if (!user || user.email_verified_at) {
return;
}
const client = await pool.connect();
try {
await client.query("BEGIN");
const token = await createVerificationToken(client, user.id);
await client.query("COMMIT");
await sendVerificationEmail(user.email, token);
} catch (error) {
await client.query("ROLLBACK");
throw error;
} finally {
client.release();
}
}
export async function cleanupExpiredAuthRows(): Promise<void> {
await pool.query("DELETE FROM sessions WHERE expires_at <= NOW() OR revoked_at < NOW() - INTERVAL '30 days'");
await pool.query("DELETE FROM email_verification_tokens WHERE expires_at <= NOW() OR used_at IS NOT NULL");
await pool.query("DELETE FROM password_reset_tokens WHERE expires_at <= NOW() OR used_at IS NOT NULL");
}
export function startAuthCleanupSchedule(intervalHours = config.authCleanupIntervalHours): NodeJS.Timeout {
const runCleanup = () => {
void cleanupExpiredAuthRows().catch((error) => {
console.error("[auth-cleanup] Failed:", error);
});
};
runCleanup();
const safeHours = Number.isFinite(intervalHours) && intervalHours > 0 ? intervalHours : 24;
const timer = setInterval(runCleanup, safeHours * 60 * 60 * 1000);
timer.unref?.();
return timer;
}

View File

@@ -11,30 +11,68 @@ function requireEnv(name: string): string {
return value;
}
const useMockDb =
process.env.CALENDAR_RUN_MOCK_DB === "1" ||
process.env.CALENDAR_RUN_MOCK_DB?.toLowerCase() === "true";
function optionalEnv(name: string): string | null {
const value = process.env[name]?.trim();
return value ? value : null;
}
function parseBoolean(value: string | undefined, fallback: boolean): boolean {
if (value == null || value.trim() === "") {
return fallback;
}
return value === "1" || value.toLowerCase() === "true";
}
const securityProfile = process.env.SECURITY_PROFILE?.trim() || process.env.NODE_ENV || "development";
export function resolveTurnstileBypassToken(params: {
rawBypassToken?: string;
securityProfile: string;
}): string {
const raw = params.rawBypassToken?.trim() ?? "";
if (raw && params.securityProfile === "production") {
throw new Error("TURNSTILE_BYPASS_TOKEN is not allowed in production");
}
return raw;
}
export const config = {
useMockDb,
db: useMockDb
? {
host: "mock",
port: 5432,
database: "mock",
user: "mock",
password: "mock",
}
: {
host: requireEnv("DB_HOST"),
port: parseInt(requireEnv("DB_PORT"), 10),
database: requireEnv("DB_NAME"),
user: requireEnv("DB_USER"),
password: requireEnv("DB_PASSWORD"),
},
db: {
host: requireEnv("DB_HOST"),
port: parseInt(requireEnv("DB_PORT"), 10),
database: requireEnv("DB_NAME"),
user: requireEnv("DB_USER"),
password: requireEnv("DB_PASSWORD"),
},
apiPort: parseInt(process.env.PORT || process.env.API_PORT || "3001", 10),
/** Одно значение или несколько через запятую (прод: https://домен) */
corsOrigin: parseCorsOrigins(),
appBaseUrl: process.env.APP_BASE_URL?.trim() || "http://localhost:5173",
session: {
secret: requireEnv("SESSION_SECRET"),
cookieName:
process.env.SESSION_COOKIE_NAME?.trim() ||
(process.env.NODE_ENV === "production" ? "__Host-sid" : "sid"),
secure: parseBoolean(process.env.SESSION_COOKIE_SECURE, process.env.NODE_ENV === "production"),
ttlDays: parseInt(process.env.SESSION_TTL_DAYS || "30", 10),
},
smtp: {
host: optionalEnv("SMTP_HOST"),
port: parseInt(process.env.SMTP_PORT || "587", 10),
secure: parseBoolean(process.env.SMTP_SECURE, false),
user: optionalEnv("SMTP_USER"),
password: optionalEnv("SMTP_PASSWORD"),
from: process.env.SMTP_FROM?.trim() || "Calendar Run <no-reply@example.com>",
},
turnstile: {
secretKey: process.env.TURNSTILE_SECRET_KEY?.trim() || "",
bypassToken: resolveTurnstileBypassToken({
rawBypassToken: process.env.TURNSTILE_BYPASS_TOKEN,
securityProfile,
}),
},
authCleanupIntervalHours: parseInt(process.env.AUTH_CLEANUP_INTERVAL_HOURS || "24", 10),
securityProfile,
};
function parseCorsOrigins(): string | string[] {

View File

@@ -1,6 +1,5 @@
import { Pool, PoolConfig, QueryResult, QueryResultRow } from "pg";
import { Pool, PoolConfig } from "pg";
import { config } from "./config";
import type { RaceRow } from "./mappers/race";
const poolConfig: PoolConfig = {
host: config.db.host,
@@ -13,172 +12,13 @@ const poolConfig: PoolConfig = {
connectionTimeoutMillis: 5_000,
};
function mockRowFromInsert(sql: string, params: unknown[]): RaceRow {
const match = sql.match(/INSERT INTO races\s*\(([^)]+)\)\s*VALUES/i);
const now = new Date();
if (!match) {
return {
id: String(params[0] ?? ""),
race_date: "",
title: "",
distance_km: "0",
status: null,
official_url: null,
cover_image_url: null,
start_time: null,
cluster_schedule: null,
bib_pickup: null,
bib_number: null,
finish_time: null,
finish_place: null,
notes: null,
created_at: now,
updated_at: null,
};
}
const cols = match[1].split(",").map((c) => c.trim());
const row: Record<string, unknown> = {};
cols.forEach((col, i) => {
row[col] = params[i];
});
return {
id: String(row.id ?? ""),
race_date: String(row.race_date ?? ""),
title: String(row.title ?? ""),
distance_km: String(row.distance_km ?? "0"),
status: row.status != null ? String(row.status) : null,
official_url: row.official_url != null ? String(row.official_url) : null,
cover_image_url: row.cover_image_url != null ? String(row.cover_image_url) : null,
start_time: row.start_time != null ? String(row.start_time) : null,
cluster_schedule: row.cluster_schedule != null ? String(row.cluster_schedule) : null,
bib_pickup: row.bib_pickup != null ? String(row.bib_pickup) : null,
bib_number: row.bib_number != null ? String(row.bib_number) : null,
finish_time: row.finish_time != null ? String(row.finish_time) : null,
finish_place: row.finish_place != null ? String(row.finish_place) : null,
notes: row.notes != null ? String(row.notes) : null,
created_at: now,
updated_at: null,
};
}
export const pool = new Pool(poolConfig);
function createMockPool(): Pool {
const emptyResult = <T extends QueryResultRow>(): QueryResult<T> =>
({
rows: [],
rowCount: 0,
command: "",
oid: 0,
fields: [],
}) as QueryResult<T>;
const store = new Map<string, RaceRow>();
const mockQuery = async <T extends QueryResultRow>(
text: string,
params?: unknown[],
): Promise<QueryResult<T>> => {
const sql = text.replace(/\s+/g, " ").trim();
const p = params ?? [];
if (sql.includes("INSERT INTO races") && sql.includes("RETURNING")) {
const row = mockRowFromInsert(text, p);
store.set(row.id, row);
return {
rows: [row as unknown as T],
rowCount: 1,
command: "INSERT",
oid: 0,
fields: [],
} as QueryResult<T>;
}
if (sql.includes("DELETE FROM races")) {
const id = String(p[0] ?? "");
const existed = store.delete(id);
return {
rows: [],
rowCount: existed ? 1 : 0,
command: "DELETE",
oid: 0,
fields: [],
} as QueryResult<T>;
}
if (sql.includes("UPDATE races") && sql.includes("RETURNING")) {
const id = String(p[p.length - 1] ?? "");
const existing = store.get(id);
if (!existing) {
return emptyResult();
}
const setMatch = sql.match(/UPDATE races SET (.+) WHERE id =/);
const updated = { ...existing, updated_at: new Date() };
const setColumns =
setMatch?.[1]
.split(",")
.map((part) => part.trim())
.filter((part) => !part.startsWith("updated_at"))
.map((part) => part.split("=")[0]?.trim())
.filter((col): col is string => Boolean(col)) ?? [];
setColumns.forEach((col, index) => {
(updated as unknown as Record<string, unknown>)[col] = p[index] ?? null;
});
store.set(id, updated);
return {
rows: [updated as unknown as T],
rowCount: 1,
command: "UPDATE",
oid: 0,
fields: [],
} as QueryResult<T>;
}
if (sql.includes("SELECT * FROM races WHERE id =")) {
const id = String(p[0] ?? "");
const row = store.get(id);
return row
? { rows: [row as unknown as T], rowCount: 1, command: "SELECT", oid: 0, fields: [] } as QueryResult<T>
: emptyResult();
}
if (sql.includes("SELECT * FROM races")) {
const rows = Array.from(store.values());
return { rows: rows as unknown as T[], rowCount: rows.length, command: "SELECT", oid: 0, fields: [] } as QueryResult<T>;
}
return emptyResult();
};
const mockPool = {
query: mockQuery,
connect: async () => {
throw new Error(
"CALENDAR_RUN_MOCK_DB is enabled: migrate/seed require a real database; unset CALENDAR_RUN_MOCK_DB and configure DB_*.",
);
},
end: async () => {},
on() {
return mockPool;
},
};
return mockPool as unknown as Pool;
}
export const pool = config.useMockDb ? createMockPool() : new Pool(poolConfig);
if (!config.useMockDb) {
pool.on("error", (err) => {
console.error("[db] Unexpected pool error:", err.message);
});
} else {
console.warn("[db] Mock database enabled (CALENDAR_RUN_MOCK_DB); no PostgreSQL connection is used.");
}
pool.on("error", (err) => {
console.error("[db] Unexpected pool error:", err.message);
});
export async function checkDbConnection(): Promise<boolean> {
if (config.useMockDb) {
return true;
}
try {
const client = await pool.connect();
client.release();

View File

@@ -1,7 +1,9 @@
import { config } from "./config";
import { createApp } from "./app";
import { startAuthCleanupSchedule } from "./authService";
const app = createApp();
startAuthCleanupSchedule();
app.listen(config.apiPort, () => {
console.log(`[api] Listening on http://localhost:${config.apiPort}`);

26
backend/src/mailer.ts Normal file
View File

@@ -0,0 +1,26 @@
import nodemailer from "nodemailer";
import { config } from "./config";
export async function sendMail(to: string, subject: string, text: string): Promise<void> {
if (!config.smtp.host) {
console.info("[mail] SMTP is not configured; email content follows.", { to, subject, text });
return;
}
const transporter = nodemailer.createTransport({
host: config.smtp.host,
port: config.smtp.port,
secure: config.smtp.secure,
auth:
config.smtp.user && config.smtp.password
? { user: config.smtp.user, pass: config.smtp.password }
: undefined,
});
await transporter.sendMail({
from: config.smtp.from,
to,
subject,
text,
});
}

View File

@@ -4,6 +4,8 @@
*/
export interface RaceRow {
id: string;
slug: string;
owner_user_id: string | null;
race_date: string | Date;
title: string;
distance_km: string;
@@ -24,6 +26,7 @@ export interface RaceRow {
/** API shape (camelCase). */
export interface RaceDto {
id: string;
slug: string;
date: string;
title: string;
distanceKm: number;
@@ -61,6 +64,7 @@ function raceDateToApiValue(value: string | Date): string {
export function rowToDto(row: RaceRow): RaceDto {
return {
id: row.id,
slug: row.slug,
date: raceDateToApiValue(row.race_date),
title: row.title,
distanceKm: parseFloat(row.distance_km),
@@ -81,6 +85,7 @@ export function rowToDto(row: RaceRow): RaceDto {
/** Map incoming camelCase body fields to snake_case column names. */
const FIELD_MAP: Record<string, string> = {
slug: "slug",
date: "race_date",
title: "title",
distanceKm: "distance_km",

View File

@@ -2,7 +2,7 @@ import fs from "fs";
import path from "path";
import { pool } from "./db";
async function migrate() {
export async function migrate() {
console.log("[migrate] Running migrations…");
const migrationsDir = path.resolve(__dirname, "../migrations");
@@ -36,11 +36,14 @@ async function migrate() {
console.log("[migrate] Done.");
} finally {
client.release();
await pool.end();
}
}
migrate().catch((err) => {
console.error("[migrate] FAILED:", err.message);
process.exit(1);
});
if (require.main === module) {
migrate()
.catch((err) => {
console.error("[migrate] FAILED:", err.message);
process.exitCode = 1;
})
.finally(() => pool.end());
}

View File

@@ -1,3 +1,6 @@
import { lookup } from "node:dns/promises";
import { isIP } from "node:net";
const IMAGE_META_KEYS = new Set([
"og:image",
"og:image:url",
@@ -6,6 +9,8 @@ const IMAGE_META_KEYS = new Set([
]);
const FETCH_TIMEOUT_MS = 5_000;
const MAX_REDIRECTS = 3;
const MAX_HTML_BYTES = 1_000_000;
function getAttribute(tag: string, name: string): string | null {
const pattern = new RegExp(`${name}\\s*=\\s*["']([^"']+)["']`, "i");
@@ -21,6 +26,75 @@ function toHttpUrl(value: string, baseUrl: string): string | null {
}
}
function isPrivateIp(address: string): boolean {
if (isIP(address) === 4) {
const [first, second] = address.split(".").map(Number);
return first === 0 || first === 10 || first === 127 || first >= 224 ||
(first === 100 && second >= 64 && second <= 127) ||
(first === 169 && second === 254) ||
(first === 172 && second >= 16 && second <= 31) ||
(first === 192 && (second === 0 || second === 168)) ||
(first === 198 && (second === 18 || second === 19));
}
const normalized = address.toLowerCase();
return normalized === "::" || normalized === "::1" || normalized.startsWith("::ffff:") ||
/^f[cd]/.test(normalized) || /^fe[89ab]/.test(normalized) || normalized.startsWith("ff");
}
async function isSafePublicHttpUrl(value: string): Promise<boolean> {
const normalized = toHttpUrl(value, value);
if (!normalized) {
return false;
}
const hostname = new URL(normalized).hostname.replace(/^\[|\]$/g, "").toLowerCase();
if (hostname === "localhost" || hostname.endsWith(".localhost")) {
return false;
}
if (isIP(hostname)) {
return !isPrivateIp(hostname);
}
try {
const addresses = await lookup(hostname, { all: true, verbatim: true });
return addresses.length > 0 && addresses.every(({ address }) => !isPrivateIp(address));
} catch {
return false;
}
}
async function readHtml(response: Response): Promise<string | null> {
const contentLength = response.headers.get("content-length");
if (contentLength && Number(contentLength) > MAX_HTML_BYTES) {
return null;
}
const reader = response.body?.getReader();
if (!reader) {
return "";
}
const chunks: Uint8Array[] = [];
let size = 0;
try {
while (true) {
const { done, value } = await reader.read();
if (done) {
return new TextDecoder().decode(Buffer.concat(chunks));
}
size += value.byteLength;
if (size > MAX_HTML_BYTES) {
await reader.cancel();
return null;
}
chunks.push(value);
}
} finally {
reader.releaseLock();
}
}
function isRuncRunUrl(value: string): boolean {
try {
const hostname = new URL(value).hostname.toLowerCase();
@@ -84,17 +158,30 @@ export async function extractRaceCoverImage(officialUrl: string): Promise<string
const timeout = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
try {
const response = await fetch(normalizedUrl, {
redirect: "follow",
signal: controller.signal,
});
let pageUrl = normalizedUrl;
for (let redirects = 0; redirects <= MAX_REDIRECTS; redirects += 1) {
if (!(await isSafePublicHttpUrl(pageUrl))) {
return null;
}
if (!response.ok) {
return null;
const response = await fetch(pageUrl, { redirect: "manual", signal: controller.signal });
if (response.status >= 300 && response.status < 400) {
const location = response.headers.get("location");
const nextUrl = location ? toHttpUrl(location, pageUrl) : null;
if (!nextUrl) {
return null;
}
pageUrl = nextUrl;
continue;
}
if (!response.ok) {
return null;
}
const html = await readHtml(response);
return html == null ? null : extractRaceCoverImageFromHtml(html, pageUrl);
}
const html = await response.text();
return extractRaceCoverImageFromHtml(html, response.url || normalizedUrl);
return null;
} catch {
return null;
} finally {

271
backend/src/routes/auth.ts Normal file
View File

@@ -0,0 +1,271 @@
import { Router, Request, Response } from "express";
import rateLimit from "express-rate-limit";
import { z } from "zod";
import {
changePassword,
listActiveSessions,
loginUser,
registerUser,
requestPasswordReset,
resendVerification,
resetPassword,
revokeOtherSessions,
revokeSessionById,
rotateCsrf,
revokeSession,
verifyEmailToken,
} from "../authService";
import { clearSessionCookie, requireAuth, setSessionCookie } from "../authMiddleware";
import { isValidPassword, normalizeEmail } from "../security";
import { verifyTurnstileToken } from "../turnstile";
const router = Router();
const genericOk = { ok: true };
const genericAuthError = { error: "invalid_credentials", details: ["Invalid email or password"] };
const registerLimiter = rateLimit({ windowMs: 60 * 60 * 1000, limit: 10, standardHeaders: true, legacyHeaders: false });
const loginLimiter = rateLimit({ windowMs: 60 * 1000, limit: 20, standardHeaders: true, legacyHeaders: false });
const loginEmailLimiter = rateLimit({
windowMs: 60 * 1000,
limit: 5,
standardHeaders: true,
legacyHeaders: false,
keyGenerator: (req) => `login:${normalizeEmail(String(req.body?.email ?? ""))}`,
});
const emailIpLimiter = rateLimit({ windowMs: 60 * 60 * 1000, limit: 10, standardHeaders: true, legacyHeaders: false });
const emailAddressLimiter = rateLimit({
windowMs: 60 * 60 * 1000,
limit: 3,
standardHeaders: true,
legacyHeaders: false,
keyGenerator: (req) => `email:${normalizeEmail(String(req.body?.email ?? ""))}`,
});
const passwordChangeLimiter = rateLimit({
windowMs: 60 * 60 * 1000,
limit: 5,
standardHeaders: true,
legacyHeaders: false,
keyGenerator: (req) => `password:${req.auth?.user.id ?? req.ip}`,
});
const registerSchema = z.object({
email: z.string().email(),
password: z.string().refine(isValidPassword, "Password must be at least 15 characters"),
turnstileToken: z.string().min(1),
});
const loginSchema = z.object({
email: z.string().email(),
password: z.string().min(1),
});
const tokenSchema = z.object({
token: z.string().min(16),
});
const emailSchema = z.object({
email: z.string().email(),
});
const resetSchema = z.object({
token: z.string().min(16),
password: z.string().refine(isValidPassword, "Password must be at least 15 characters"),
});
const passwordChangeSchema = z.object({
currentPassword: z.string().min(1),
newPassword: z.string().refine(isValidPassword, "Password must be at least 15 characters"),
}).strict();
const sessionIdSchema = z.string().uuid();
function validationError(res: Response): void {
res.status(400).json({ error: "validation_error", details: ["Invalid request body"] });
}
router.post("/auth/register", registerLimiter, async (req: Request, res: Response, next) => {
const parsed = registerSchema.safeParse(req.body);
if (!parsed.success) {
validationError(res);
return;
}
try {
const captchaOk = await verifyTurnstileToken(parsed.data.turnstileToken, req.ip);
if (!captchaOk) {
res.status(400).json({ error: "captcha_failed", details: ["Captcha verification failed"] });
return;
}
await registerUser(parsed.data.email, parsed.data.password);
res.status(202).json(genericOk);
} catch (error) {
next(error);
}
});
router.post("/auth/login", loginLimiter, loginEmailLimiter, async (req: Request, res: Response, next) => {
const parsed = loginSchema.safeParse(req.body);
if (!parsed.success) {
res.status(401).json(genericAuthError);
return;
}
try {
const result = await loginUser(parsed.data.email, parsed.data.password);
if (!result.ok) {
res.status(401).json(genericAuthError);
return;
}
setSessionCookie(res, result.sessionToken);
res.json({ user: result.user, csrfToken: result.csrfToken });
} catch (error) {
next(error);
}
});
router.post("/auth/logout", async (req: Request, res: Response, next) => {
try {
if (req.auth) {
await revokeSession(req.auth.sessionToken);
}
clearSessionCookie(res);
res.status(204).end();
} catch (error) {
next(error);
}
});
router.get("/auth/me", async (req: Request, res: Response, next) => {
if (!req.auth) {
res.status(401).json({ error: "unauthorized", details: ["Authentication required"] });
return;
}
try {
const csrfToken = await rotateCsrf(req.auth.sessionToken);
res.json({ user: req.auth.user, csrfToken });
} catch (error) {
next(error);
}
});
router.get("/auth/sessions", requireAuth, async (req: Request, res: Response, next) => {
try {
res.json({ sessions: await listActiveSessions(req.auth!.user.id, req.auth!.sessionToken) });
} catch (error) {
next(error);
}
});
router.post("/auth/password", requireAuth, passwordChangeLimiter, async (req: Request, res: Response, next) => {
const parsed = passwordChangeSchema.safeParse(req.body);
if (!parsed.success) {
validationError(res);
return;
}
try {
const changed = await changePassword(req.auth!.user.id, req.auth!.sessionToken, parsed.data.currentPassword, parsed.data.newPassword);
if (!changed) {
res.status(400).json({ error: "invalid_current_password", details: ["Current password is incorrect"] });
return;
}
res.json(genericOk);
} catch (error) {
next(error);
}
});
router.delete("/auth/sessions/:id", requireAuth, async (req: Request, res: Response, next) => {
const parsed = sessionIdSchema.safeParse(req.params.id);
if (!parsed.success) {
validationError(res);
return;
}
try {
const result = await revokeSessionById(req.auth!.user.id, parsed.data, req.auth!.sessionToken);
if (!result.revoked) {
res.status(404).json({ error: "not_found", details: ["Session not found"] });
return;
}
if (result.current) {
clearSessionCookie(res);
}
res.status(204).end();
} catch (error) {
next(error);
}
});
router.post("/auth/sessions/revoke-others", requireAuth, async (req: Request, res: Response, next) => {
try {
await revokeOtherSessions(req.auth!.user.id, req.auth!.sessionToken);
res.json(genericOk);
} catch (error) {
next(error);
}
});
router.post("/auth/verify-email", emailIpLimiter, async (req: Request, res: Response, next) => {
const parsed = tokenSchema.safeParse(req.body);
if (!parsed.success) {
validationError(res);
return;
}
try {
const ok = await verifyEmailToken(parsed.data.token);
if (!ok) {
res.status(410).json({ error: "invalid_token", details: ["Token is invalid or expired"] });
return;
}
res.json(genericOk);
} catch (error) {
next(error);
}
});
router.post("/auth/resend-verification", emailIpLimiter, emailAddressLimiter, async (req: Request, res: Response, next) => {
const parsed = emailSchema.safeParse(req.body);
if (!parsed.success) {
validationError(res);
return;
}
try {
await resendVerification(parsed.data.email);
res.status(202).json(genericOk);
} catch (error) {
next(error);
}
});
router.post("/auth/forgot-password", emailIpLimiter, emailAddressLimiter, async (req: Request, res: Response, next) => {
const parsed = emailSchema.safeParse(req.body);
if (!parsed.success) {
validationError(res);
return;
}
try {
await requestPasswordReset(parsed.data.email);
res.status(202).json(genericOk);
} catch (error) {
next(error);
}
});
router.post("/auth/reset-password", emailIpLimiter, async (req: Request, res: Response, next) => {
const parsed = resetSchema.safeParse(req.body);
if (!parsed.success) {
validationError(res);
return;
}
try {
const ok = await resetPassword(parsed.data.token, parsed.data.password);
if (!ok) {
res.status(410).json({ error: "invalid_token", details: ["Token is invalid or expired"] });
return;
}
clearSessionCookie(res);
res.json(genericOk);
} catch (error) {
next(error);
}
});
export default router;

View File

@@ -1,9 +1,12 @@
import { Router, Request, Response } from "express";
import { z } from "zod";
import { pool } from "../db";
import { rowToDto, bodyToColumns, RaceRow } from "../mappers/race";
import { extractRaceCoverImage } from "../raceCoverImage";
import { requireAuth } from "../authMiddleware";
const router = Router();
router.use(requireAuth);
type ValidationErrorBody = {
error: "validation_error";
@@ -19,6 +22,87 @@ function validationError(res: Response, details: string[]) {
res.status(400).json(body);
}
const optionalText = (max: number) => z.string().trim().max(max).nullable().optional();
const httpUrl = z.string().trim().max(2048).url().refine(
(value) => {
try {
const { protocol } = new URL(value);
return protocol === "http:" || protocol === "https:";
} catch {
return false;
}
},
"Must be an HTTP(S) URL",
);
const date = z.string().regex(/^\d{4}-\d{2}-\d{2}$/, "Must be YYYY-MM-DD").refine((value) => {
const parsed = new Date(`${value}T00:00:00.000Z`);
return !Number.isNaN(parsed.valueOf()) && parsed.toISOString().slice(0, 10) === value;
}, "Must be a valid calendar date");
const raceFields = {
slug: z.string().trim().min(1).max(120).regex(/^[\p{L}\p{N}]+(?:-[\p{L}\p{N}]+)*$/u, "Must be a slug"),
date,
title: z.string().trim().min(1).max(200),
distanceKm: z.number().finite().gt(0).max(999.999),
status: z.enum(["planned", "registered", "completed"]).nullable(),
officialUrl: httpUrl.nullable(),
coverImageUrl: httpUrl.nullable(),
startTime: z.string().regex(/^(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d)?$/, "Must be HH:MM or HH:MM:SS").nullable(),
clusterSchedule: optionalText(1_000),
bibPickup: optionalText(500),
bibNumber: optionalText(100),
finishTime: z.string().regex(/^(?:[0-5]\d|[0-5]?\d:[0-5]\d|\d+:[0-5]\d:[0-5]\d)$/, "Must be SS, MM:SS or H:MM:SS").nullable(),
finishPlace: optionalText(100),
notes: optionalText(5_000),
};
const createRaceSchema = z.object({
slug: raceFields.slug,
date: raceFields.date,
title: raceFields.title,
distanceKm: raceFields.distanceKm,
status: raceFields.status.optional(),
officialUrl: raceFields.officialUrl.optional(),
coverImageUrl: raceFields.coverImageUrl.optional(),
startTime: raceFields.startTime.optional(),
clusterSchedule: raceFields.clusterSchedule,
bibPickup: raceFields.bibPickup,
bibNumber: raceFields.bibNumber,
finishTime: raceFields.finishTime.optional(),
finishPlace: raceFields.finishPlace,
notes: raceFields.notes,
}).strict();
const updateRaceSchema = z.object({
slug: raceFields.slug.optional(),
date: raceFields.date.optional(),
title: raceFields.title.optional(),
distanceKm: raceFields.distanceKm.optional(),
status: raceFields.status.optional(),
officialUrl: raceFields.officialUrl.optional(),
coverImageUrl: raceFields.coverImageUrl.optional(),
startTime: raceFields.startTime.optional(),
clusterSchedule: raceFields.clusterSchedule,
bibPickup: raceFields.bibPickup,
bibNumber: raceFields.bibNumber,
finishTime: raceFields.finishTime.optional(),
finishPlace: raceFields.finishPlace,
notes: raceFields.notes,
}).strict().refine((body) => Object.keys(body).length > 0, "No updatable fields provided");
function schemaDetails(error: z.ZodError): string[] {
return error.issues.map((issue) => `${issue.path.join(".") || "body"}: ${issue.message}`);
}
function raceIdFromRequest(req: Request, res: Response): string | null {
const parsed = z.string().uuid().safeParse(req.params.id);
if (parsed.success) {
return parsed.data;
}
validationError(res, ["id: Must be a UUID"]);
return null;
}
function parseOptionalIntegerQuery(
value: unknown,
fieldName: string,
@@ -69,6 +153,9 @@ router.get("/races", async (req: Request, res: Response) => {
const params: unknown[] = [];
let idx = 1;
conditions.push(`owner_user_id = $${idx++}`);
params.push(req.auth!.user.id);
if (yearResult.value != null) {
conditions.push(`EXTRACT(YEAR FROM race_date) = $${idx++}`);
params.push(yearResult.value);
@@ -92,10 +179,14 @@ router.get("/races", async (req: Request, res: Response) => {
/* ─── GET /races/:id ──────────────────────────────────────── */
router.get("/races/:id", async (req: Request, res: Response) => {
const id = raceIdFromRequest(req, res);
if (!id) {
return;
}
try {
const { rows } = await pool.query<RaceRow>(
"SELECT * FROM races WHERE id = $1",
[req.params.id],
"SELECT * FROM races WHERE id = $1 AND owner_user_id = $2",
[id, req.auth!.user.id],
);
if (rows.length === 0) {
res.status(404).json({ error: "not_found", details: ["Race not found"] });
@@ -111,25 +202,24 @@ router.get("/races/:id", async (req: Request, res: Response) => {
/* ─── POST /races ─────────────────────────────────────────── */
router.post("/races", async (req: Request, res: Response) => {
const body = req.body;
if (!body.id || !body.date || !body.title || body.distanceKm == null) {
validationError(res, ["Fields id, date, title, distanceKm are required"]);
const parsed = createRaceSchema.safeParse(req.body);
if (!parsed.success) {
validationError(res, schemaDetails(parsed.error));
return;
}
const payload = { ...body };
const payload = { ...parsed.data };
const hasManualCover = typeof payload.coverImageUrl === "string" && payload.coverImageUrl.trim() !== "";
const hasOfficialUrl = typeof payload.officialUrl === "string" && payload.officialUrl.trim() !== "";
const officialUrl = payload.officialUrl;
const hasOfficialUrl = typeof officialUrl === "string" && officialUrl.trim() !== "";
if (!hasManualCover && hasOfficialUrl) {
payload.coverImageUrl = await extractRaceCoverImage(payload.officialUrl);
payload.coverImageUrl = await extractRaceCoverImage(officialUrl);
}
const { columns, values } = bodyToColumns(payload);
columns.unshift("id");
values.unshift(body.id);
const { columns, values } = bodyToColumns(payload as Record<string, unknown>);
columns.unshift("owner_user_id");
values.unshift(req.auth!.user.id);
const placeholders = values.map((_, i) => `$${i + 1}`).join(", ");
const sql = `INSERT INTO races (${columns.join(", ")}) VALUES (${placeholders}) RETURNING *`;
@@ -140,7 +230,7 @@ router.post("/races", async (req: Request, res: Response) => {
if (err.code === "23505") {
res.status(409).json({
error: "conflict",
details: ["Race with this id already exists"],
details: ["Race with this slug already exists"],
});
return;
}
@@ -152,17 +242,22 @@ router.post("/races", async (req: Request, res: Response) => {
/* ─── PATCH /races/:id ────────────────────────────────────── */
router.patch("/races/:id", async (req: Request, res: Response) => {
const { columns, values } = bodyToColumns(req.body);
if (columns.length === 0) {
validationError(res, ["No updatable fields provided"]);
const id = raceIdFromRequest(req, res);
if (!id) {
return;
}
const parsed = updateRaceSchema.safeParse(req.body);
if (!parsed.success) {
validationError(res, schemaDetails(parsed.error));
return;
}
const { columns, values } = bodyToColumns(parsed.data as Record<string, unknown>);
const sets = columns.map((col, i) => `${col} = $${i + 1}`);
sets.push(`updated_at = NOW()`);
values.push(req.params.id);
const sql = `UPDATE races SET ${sets.join(", ")} WHERE id = $${values.length} RETURNING *`;
values.push(id);
values.push(req.auth!.user.id);
const sql = `UPDATE races SET ${sets.join(", ")} WHERE id = $${values.length - 1} AND owner_user_id = $${values.length} RETURNING *`;
try {
const { rows } = await pool.query<RaceRow>(sql, values);
@@ -180,10 +275,14 @@ router.patch("/races/:id", async (req: Request, res: Response) => {
/* ─── DELETE /races/:id ───────────────────────────────────── */
router.delete("/races/:id", async (req: Request, res: Response) => {
const id = raceIdFromRequest(req, res);
if (!id) {
return;
}
try {
const { rowCount } = await pool.query(
"DELETE FROM races WHERE id = $1",
[req.params.id],
"DELETE FROM races WHERE id = $1 AND owner_user_id = $2",
[id, req.auth!.user.id],
);
if (rowCount === 0) {
res.status(404).json({ error: "not_found", details: ["Race not found"] });

63
backend/src/security.ts Normal file
View File

@@ -0,0 +1,63 @@
import crypto from "crypto";
import argon2 from "argon2";
export const PASSWORD_MIN_LENGTH = 15;
export const PASSWORD_MAX_LENGTH = 256;
export const ARGON2_OPTIONS: argon2.Options & { raw?: false } = {
type: argon2.argon2id,
memoryCost: 65_536,
timeCost: 3,
parallelism: 1,
hashLength: 32,
};
const DUMMY_PASSWORD_HASH =
"$argon2id$v=19$m=65536,t=3,p=1$Jkdr1qT0c9cPK5v8m0tEMQ$SLnLmyorTDzBK74I1lrEF92E7S0c6DAm8iMG0YOyAIo";
export function normalizeEmail(value: string): string {
return value.trim().toLowerCase();
}
export function isValidPassword(value: string): boolean {
return value.length >= PASSWORD_MIN_LENGTH && value.length <= PASSWORD_MAX_LENGTH;
}
export async function hashPassword(password: string): Promise<string> {
return argon2.hash(password, ARGON2_OPTIONS);
}
export async function verifyPassword(hash: string | null, password: string): Promise<boolean> {
const candidate = hash ?? DUMMY_PASSWORD_HASH;
try {
const ok = await argon2.verify(candidate, password);
return hash ? ok : false;
} catch {
return false;
}
}
export function randomToken(bytes = 32): string {
return crypto.randomBytes(bytes).toString("base64url");
}
export function sha256Hex(value: string): string {
return crypto.createHash("sha256").update(value).digest("hex");
}
export function timingSafeEqualHex(left: string, right: string): boolean {
const a = Buffer.from(left, "hex");
const b = Buffer.from(right, "hex");
if (a.length !== b.length) {
return false;
}
return crypto.timingSafeEqual(a, b);
}
export function addDays(date: Date, days: number): Date {
return new Date(date.getTime() + days * 24 * 60 * 60 * 1000);
}
export function addHours(date: Date, hours: number): Date {
return new Date(date.getTime() + hours * 60 * 60 * 1000);
}

View File

@@ -0,0 +1,9 @@
import { sha256Hex } from "./security";
export function anonymizeEmail(email: string): string {
return sha256Hex(email).slice(0, 16);
}
export function securityLog(event: string, details: Record<string, unknown> = {}): void {
console.info("[security]", event, details);
}

View File

@@ -24,6 +24,31 @@ function makeId(date: string, title: string): string {
return `${date}-${slugify(title)}`;
}
async function resolveSeedOwnerUserId(client: { query: typeof pool.query }): Promise<string | null> {
const explicit = process.env.SEED_OWNER_USER_ID?.trim();
if (explicit) {
return explicit;
}
const email = process.env.SEED_OWNER_EMAIL?.trim().toLowerCase();
if (email) {
const { rows } = await client.query<{ id: string }>(
"SELECT id FROM users WHERE LOWER(BTRIM(email)) = $1",
[email],
);
if (rows[0]) {
return rows[0].id;
}
throw new Error("SEED_OWNER_EMAIL does not match an existing user");
}
const { rows } = await client.query<{ count: string }>("SELECT COUNT(*)::text AS count FROM users");
const usersCount = Number(rows[0]?.count ?? "0");
if (usersCount > 0) {
throw new Error("Refusing to seed without SEED_OWNER_USER_ID or SEED_OWNER_EMAIL after users exist");
}
return null;
}
const CSV_NAME = "races_2026_calendar.csv";
function resolveCsvPath(): string | null {
@@ -56,23 +81,28 @@ async function seed() {
const client = await pool.connect();
try {
const ownerUserId = await resolveSeedOwnerUserId(client);
for (const row of records) {
const id = makeId(row.date, row.event);
const slug = makeId(row.date, row.event);
const distanceKm = parseFloat(row.distance_km);
await client.query(
`INSERT INTO races (id, race_date, title, distance_km, status)
VALUES ($1, $2, $3, $4, $5)
ON CONFLICT (id) DO UPDATE SET
race_date = EXCLUDED.race_date,
title = EXCLUDED.title,
distance_km = EXCLUDED.distance_km,
status = EXCLUDED.status,
updated_at = NOW()`,
[id, row.date, row.event, distanceKm, "planned"],
);
if (ownerUserId) {
await client.query(
`INSERT INTO races (slug, owner_user_id, race_date, title, distance_km, status, source)
VALUES ($1, $2, $3, $4, $5, $6, 'seed')
ON CONFLICT DO NOTHING`,
[slug, ownerUserId, row.date, row.event, distanceKm, "planned"],
);
} else {
await client.query(
`INSERT INTO races (slug, race_date, title, distance_km, status, source)
VALUES ($1, $2, $3, $4, $5, 'seed')
ON CONFLICT DO NOTHING`,
[slug, row.date, row.event, distanceKm, "planned"],
);
}
console.log(`[seed] Upserted: ${id}`);
console.log(`[seed] Inserted if missing: ${slug}`);
}
console.log("[seed] Done.");

33
backend/src/turnstile.ts Normal file
View File

@@ -0,0 +1,33 @@
import { config } from "./config";
export async function verifyTurnstileToken(token: string, remoteIp?: string): Promise<boolean> {
if (config.turnstile.bypassToken && token === config.turnstile.bypassToken) {
return true;
}
if (!config.turnstile.secretKey) {
return false;
}
const body = new URLSearchParams();
body.set("secret", config.turnstile.secretKey);
body.set("response", token);
if (remoteIp) {
body.set("remoteip", remoteIp);
}
try {
const response = await fetch("https://challenges.cloudflare.com/turnstile/v0/siteverify", {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body,
signal: AbortSignal.timeout(5_000),
});
if (!response.ok) {
return false;
}
const payload = (await response.json()) as { success?: boolean };
return payload.success === true;
} catch {
return false;
}
}

View File

@@ -1,10 +1,82 @@
import assert from "node:assert/strict";
import { test } from "node:test";
import fs from "node:fs";
import path from "node:path";
import { after, test } from "node:test";
import request from "supertest";
import { createApp } from "../src/app";
import { extractRaceCoverImageFromHtml } from "../src/raceCoverImage";
import { buildHelmetOptions, createApp } from "../src/app";
import {
cleanupExpiredAuthRows,
createResetToken,
createVerificationToken,
resetPassword,
verifyEmailToken,
} from "../src/authService";
import { resolveTurnstileBypassToken } from "../src/config";
import { pool } from "../src/db";
import { extractRaceCoverImage, extractRaceCoverImageFromHtml } from "../src/raceCoverImage";
import { hashPassword, normalizeEmail } from "../src/security";
const app = createApp();
let userCounter = 0;
after(() => pool.end());
async function authAgent() {
userCounter += 1;
const email = normalizeEmail(`runner${userCounter}@example.com`);
const password = "correct horse battery staple";
const passwordHash = await hashPassword(password);
const inserted = await pool.query<{ id: string }>(
`INSERT INTO users (email, password_hash)
VALUES ($1, $2)
RETURNING id`,
[email, passwordHash],
);
await pool.query("UPDATE users SET email_verified_at = COALESCE(email_verified_at, NOW()) WHERE id = $1", [
inserted.rows[0].id,
]);
const agent = request.agent(app);
const login = await agent.post("/api/auth/login").send({ email, password }).expect(200);
return { agent, csrfToken: login.body.csrfToken as string };
}
async function createVerifiedUser(email: string, password: string) {
return createUser(email, password, true);
}
async function createUnverifiedUser(email: string, password: string) {
return createUser(email, password, false);
}
async function createUser(email: string, password: string, verified: boolean) {
const passwordHash = await hashPassword(password);
const inserted = await pool.query<{ id: string }>(
`INSERT INTO users (email, password_hash)
VALUES ($1, $2)
RETURNING id`,
[normalizeEmail(email), passwordHash],
);
if (verified) {
await pool.query("UPDATE users SET email_verified_at = COALESCE(email_verified_at, NOW()) WHERE id = $1", [
inserted.rows[0].id,
]);
}
return inserted.rows[0].id;
}
async function loginAgent(email: string, password: string) {
const agent = request.agent(app);
const login = await agent.post("/api/auth/login").send({ email, password }).expect(200);
return { agent, csrfToken: login.body.csrfToken as string };
}
async function countByTokenHash(table: "sessions" | "email_verification_tokens" | "password_reset_tokens", tokenHash: string) {
const { rows } = await pool.query<{ count: string }>(
`SELECT COUNT(*)::text AS count FROM ${table} WHERE token_hash = $1`,
[tokenHash],
);
return Number(rows[0]?.count ?? "0");
}
test("GET /api/health returns ok", async () => {
const res = await request(app).get("/api/health").expect(200);
@@ -19,34 +91,260 @@ test("GET /api/meta returns version for UI footer", async () => {
assert.ok(res.body.version.length > 0);
});
test("GET /api/ready succeeds with mock database", async () => {
test("GET /api/ready succeeds with PostgreSQL", async () => {
const res = await request(app).get("/api/ready").expect(200);
assert.equal(res.body.status, "ready");
assert.equal(res.body.db, "connected");
});
test("production config rejects Turnstile bypass token", () => {
assert.throws(
() =>
resolveTurnstileBypassToken({
rawBypassToken: "unsafe-bypass",
securityProfile: "production",
}),
/TURNSTILE_BYPASS_TOKEN/,
);
assert.equal(
resolveTurnstileBypassToken({
rawBypassToken: "dev-bypass",
securityProfile: "development",
}),
"dev-bypass",
);
});
test("production CSP allows Turnstile without unsafe script directives", () => {
const options = buildHelmetOptions("production") as any;
const directives = options.contentSecurityPolicy.directives;
assert.ok(directives.scriptSrc.includes("https://challenges.cloudflare.com"));
assert.ok(directives.frameSrc.includes("https://challenges.cloudflare.com"));
assert.ok(directives.connectSrc.includes("https://challenges.cloudflare.com"));
assert.ok(!directives.scriptSrc.includes("'unsafe-inline'"));
assert.ok(!directives.scriptSrc.includes("'unsafe-eval'"));
assert.deepEqual(directives.frameAncestors, ["'none'"]);
assert.deepEqual(directives.objectSrc, ["'none'"]);
});
test("duplicate registration keeps generic accepted response", async () => {
const payload = {
email: "duplicate-register@example.com",
password: "correct horse battery staple",
turnstileToken: "mock-turnstile-token",
};
const first = await request(app).post("/api/auth/register").send(payload).expect(202);
const second = await request(app).post("/api/auth/register").send(payload).expect(202);
assert.deepEqual(first.body, { ok: true });
assert.deepEqual(second.body, { ok: true });
});
test("GET /api/races rejects invalid year", async () => {
const res = await request(app).get("/api/races?year=bad").expect(400);
const { agent } = await authAgent();
const res = await agent.get("/api/races?year=bad").expect(400);
assert.equal(res.body.error, "validation_error");
assert.ok(Array.isArray(res.body.details));
});
test("GET /api/races rejects month out of range", async () => {
const res = await request(app).get("/api/races?month=13").expect(400);
const { agent } = await authAgent();
const res = await agent.get("/api/races?month=13").expect(400);
assert.equal(res.body.error, "validation_error");
});
test("GET /api/races accepts year and month", async () => {
const res = await request(app).get("/api/races?year=2026&month=5").expect(200);
const { agent } = await authAgent();
const res = await agent.get("/api/races?year=2026&month=5").expect(200);
assert.ok(Array.isArray(res.body));
});
test("GET /api/races/:id returns not_found", async () => {
const res = await request(app).get("/api/races/does-not-exist").expect(404);
assert.equal(res.body.error, "not_found");
test("GET /api/races/:id rejects a non-UUID id", async () => {
const { agent } = await authAgent();
const res = await agent.get("/api/races/does-not-exist").expect(400);
assert.equal(res.body.error, "validation_error");
assert.ok(Array.isArray(res.body.details));
});
test("GET /api/races requires authentication", async () => {
const res = await request(app).get("/api/races").expect(401);
assert.equal(res.body.error, "unauthorized");
});
test("login uses generic response for missing user and wrong password", async () => {
const password = "correct horse battery staple";
await createVerifiedUser("generic@example.com", password);
const wrongPassword = await request(app)
.post("/api/auth/login")
.send({ email: "generic@example.com", password: "wrong password" })
.expect(401);
const missingUser = await request(app)
.post("/api/auth/login")
.send({ email: "missing@example.com", password })
.expect(401);
assert.deepEqual(missingUser.body, wrongPassword.body);
});
test("account session endpoints list and revoke only the current user's sessions", async () => {
userCounter += 1;
const email = `sessions${userCounter}@example.com`;
const password = "correct horse battery staple";
await createVerifiedUser(email, password);
const first = await loginAgent(email, password);
const second = await loginAgent(email, password);
const listed = await first.agent.get("/api/auth/sessions").expect(200);
assert.equal(listed.body.sessions.length, 2);
assert.ok(listed.body.sessions.every((session: { tokenHash?: unknown }) => session.tokenHash === undefined));
const other = listed.body.sessions.find((session: { current: boolean }) => !session.current);
assert.ok(other);
const outsider = await authAgent();
const foreign = (await outsider.agent.get("/api/auth/sessions").expect(200)).body.sessions[0];
await first.agent
.delete(`/api/auth/sessions/${foreign.id}`)
.set("X-CSRF-Token", first.csrfToken)
.expect(404);
await first.agent
.delete(`/api/auth/sessions/${other.id}`)
.set("X-CSRF-Token", first.csrfToken)
.expect(204);
await second.agent.get("/api/auth/me").expect(401);
await first.agent.get("/api/auth/sessions").expect(200).then((res) => assert.equal(res.body.sessions.length, 1));
});
test("password change checks the current password and revokes other sessions", async () => {
userCounter += 1;
const email = `password${userCounter}@example.com`;
const password = "correct horse battery staple";
const nextPassword = "another correct horse battery staple";
await createVerifiedUser(email, password);
const first = await loginAgent(email, password);
const second = await loginAgent(email, password);
await first.agent
.post("/api/auth/password")
.set("X-CSRF-Token", first.csrfToken)
.send({ currentPassword: "wrong password", newPassword: nextPassword })
.expect(400);
await first.agent
.post("/api/auth/password")
.set("X-CSRF-Token", first.csrfToken)
.send({ currentPassword: password, newPassword: nextPassword })
.expect(200);
await second.agent.get("/api/auth/me").expect(401);
await request(app).post("/api/auth/login").send({ email, password }).expect(401);
await request(app).post("/api/auth/login").send({ email, password: nextPassword }).expect(200);
});
test("revoke other sessions retains the current session", async () => {
userCounter += 1;
const email = `revoke-others${userCounter}@example.com`;
const password = "correct horse battery staple";
await createVerifiedUser(email, password);
const first = await loginAgent(email, password);
const second = await loginAgent(email, password);
await first.agent
.post("/api/auth/sessions/revoke-others")
.set("X-CSRF-Token", first.csrfToken)
.expect(200);
await first.agent.get("/api/auth/me").expect(200);
await second.agent.get("/api/auth/me").expect(401);
});
test("GET /api/races/:id returns not_found for another user's race", async () => {
const first = await authAgent();
const created = await first.agent
.post("/api/races")
.set("X-CSRF-Token", first.csrfToken)
.send({
slug: "2026-07-01-private-race",
date: "2026-07-01",
title: "Private Race",
distanceKm: 10,
})
.expect(201);
const second = await authAgent();
const res = await second.agent.get(`/api/races/${created.body.id}`).expect(404);
assert.equal(res.body.error, "not_found");
});
test("new password reset token invalidates previous token", async () => {
const userId = await createVerifiedUser("reset@example.com", "correct horse battery staple");
const client = await pool.connect();
const first = await createResetToken(client, userId);
const second = await createResetToken(client, userId);
client.release();
assert.equal(await resetPassword(first, "new correct horse battery staple"), false);
assert.equal(await resetPassword(second, "new correct horse battery staple"), true);
assert.equal(await resetPassword(second, "new correct horse battery staple"), false);
});
test("email verification token is single use", async () => {
const userId = await createUnverifiedUser("verify-once@example.com", "correct horse battery staple");
const client = await pool.connect();
const token = await createVerificationToken(client, userId);
client.release();
assert.equal(await verifyEmailToken(token), true);
assert.equal(await verifyEmailToken(token), false);
});
test("auth cleanup removes expired rows and keeps active rows", async () => {
const userId = await createVerifiedUser("cleanup@example.com", "correct horse battery staple");
const past = new Date(Date.now() - 60_000);
const future = new Date(Date.now() + 60_000);
const expiredSession = "expired-session-token-hash";
const activeSession = "active-session-token-hash";
const expiredVerify = "expired-verify-token-hash";
const activeVerify = "active-verify-token-hash";
const expiredReset = "expired-reset-token-hash";
const activeReset = "active-reset-token-hash";
await pool.query(
"INSERT INTO sessions (user_id, token_hash, csrf_token_hash, expires_at) VALUES ($1, $2, $3, $4)",
[userId, expiredSession, "expired-csrf", past],
);
await pool.query(
"INSERT INTO sessions (user_id, token_hash, csrf_token_hash, expires_at) VALUES ($1, $2, $3, $4)",
[userId, activeSession, "active-csrf", future],
);
await pool.query(
"INSERT INTO email_verification_tokens (user_id, token_hash, expires_at) VALUES ($1, $2, $3)",
[userId, expiredVerify, past],
);
await pool.query(
"INSERT INTO email_verification_tokens (user_id, token_hash, expires_at) VALUES ($1, $2, $3)",
[userId, activeVerify, future],
);
await pool.query(
"INSERT INTO password_reset_tokens (user_id, token_hash, expires_at) VALUES ($1, $2, $3)",
[userId, expiredReset, past],
);
await pool.query(
"INSERT INTO password_reset_tokens (user_id, token_hash, expires_at) VALUES ($1, $2, $3)",
[userId, activeReset, future],
);
await cleanupExpiredAuthRows();
assert.equal(await countByTokenHash("sessions", expiredSession), 0);
assert.equal(await countByTokenHash("sessions", activeSession), 1);
assert.equal(await countByTokenHash("email_verification_tokens", expiredVerify), 0);
assert.equal(await countByTokenHash("email_verification_tokens", activeVerify), 1);
assert.equal(await countByTokenHash("password_reset_tokens", expiredReset), 0);
assert.equal(await countByTokenHash("password_reset_tokens", activeReset), 1);
});
test("extractRaceCoverImageFromHtml prefers runc.run intro image", () => {
const html = `
<meta property="og:image" content="https://example.com/og.jpg">
@@ -81,11 +379,13 @@ test("extractRaceCoverImageFromHtml reads Open Graph and Twitter images", () =>
});
test("POST /api/races stores manual coverImageUrl", async () => {
const { agent, csrfToken } = await authAgent();
const coverImageUrl = "https://example.com/manual.jpg";
const res = await request(app)
const res = await agent
.post("/api/races")
.set("X-CSRF-Token", csrfToken)
.send({
id: "2026-06-01-manual-cover",
slug: "2026-06-01-manual-cover",
date: "2026-06-01",
title: "Manual Cover",
distanceKm: 10,
@@ -97,6 +397,44 @@ test("POST /api/races stores manual coverImageUrl", async () => {
assert.equal(res.body.coverImageUrl, coverImageUrl);
});
test("POST /api/races rejects unknown and malformed fields", async () => {
const { agent, csrfToken } = await authAgent();
const res = await agent
.post("/api/races")
.set("X-CSRF-Token", csrfToken)
.send({
slug: "invalid-race",
date: "2026-02-30",
title: "Invalid Race",
distanceKm: 10,
startTime: "24:00",
unexpected: true,
})
.expect(400);
assert.equal(res.body.error, "validation_error");
assert.ok(res.body.details.some((detail: string) => detail.includes("date")));
assert.ok(res.body.details.some((detail: string) => detail.includes("startTime")));
assert.ok(res.body.details.some((detail: string) => detail.includes("Unrecognized")));
});
test("POST /api/races accepts a seconds-only finish time", async () => {
const { agent, csrfToken } = await authAgent();
const res = await agent
.post("/api/races")
.set("X-CSRF-Token", csrfToken)
.send({
slug: "2026-06-01-seconds-only",
date: "2026-06-01",
title: "Seconds Only",
distanceKm: 1,
finishTime: "05",
})
.expect(201);
assert.equal(res.body.finishTime, "05");
});
test("POST /api/races auto extracts coverImageUrl from officialUrl", async () => {
const originalFetch = globalThis.fetch;
globalThis.fetch = async () =>
@@ -106,10 +444,12 @@ test("POST /api/races auto extracts coverImageUrl from officialUrl", async () =>
});
try {
const res = await request(app)
const { agent, csrfToken } = await authAgent();
const res = await agent
.post("/api/races")
.set("X-CSRF-Token", csrfToken)
.send({
id: "2026-06-02-auto-cover",
slug: "2026-06-02-auto-cover",
date: "2026-06-02",
title: "Auto Cover",
distanceKm: 21.1,
@@ -130,10 +470,12 @@ test("POST /api/races succeeds when cover extraction fails", async () => {
};
try {
const res = await request(app)
const { agent, csrfToken } = await authAgent();
const res = await agent
.post("/api/races")
.set("X-CSRF-Token", csrfToken)
.send({
id: "2026-06-03-cover-fail",
slug: "2026-06-03-cover-fail",
date: "2026-06-03",
title: "Cover Fail",
distanceKm: 5,
@@ -147,12 +489,45 @@ test("POST /api/races succeeds when cover extraction fails", async () => {
}
});
test("cover extraction never fetches loopback URLs", async () => {
const originalFetch = globalThis.fetch;
let calls = 0;
globalThis.fetch = async () => {
calls += 1;
return new Response("");
};
try {
assert.equal(await extractRaceCoverImage("http://127.0.0.1/private"), null);
assert.equal(calls, 0);
} finally {
globalThis.fetch = originalFetch;
}
});
test("cover extraction rejects a redirect to a private URL", async () => {
const originalFetch = globalThis.fetch;
let calls = 0;
globalThis.fetch = async () => {
calls += 1;
return new Response(null, { status: 302, headers: { location: "http://127.0.0.1/private" } });
};
try {
assert.equal(await extractRaceCoverImage("https://8.8.8.8/race"), null);
assert.equal(calls, 1);
} finally {
globalThis.fetch = originalFetch;
}
});
test("PATCH /api/races/:id updates coverImageUrl explicitly", async () => {
const id = "2026-06-04-patch-cover";
await request(app)
const { agent, csrfToken } = await authAgent();
const created = await agent
.post("/api/races")
.set("X-CSRF-Token", csrfToken)
.send({
id,
slug: "2026-06-04-patch-cover",
date: "2026-06-04",
title: "Patch Cover",
distanceKm: 10,
@@ -160,10 +535,57 @@ test("PATCH /api/races/:id updates coverImageUrl explicitly", async () => {
.expect(201);
const coverImageUrl = "https://example.com/patched.jpg";
const res = await request(app)
.patch(`/api/races/${id}`)
const res = await agent
.patch(`/api/races/${created.body.id}`)
.set("X-CSRF-Token", csrfToken)
.send({ coverImageUrl })
.expect(200);
assert.equal(res.body.coverImageUrl, coverImageUrl);
});
test("004 migrates legacy text race ids to UUIDs", async () => {
const client = await pool.connect();
try {
await client.query("BEGIN");
await client.query("DROP TABLE races");
await client.query(`
CREATE TABLE races (
id TEXT PRIMARY KEY,
race_date DATE NOT NULL,
title TEXT NOT NULL,
distance_km NUMERIC(6,3) NOT NULL,
status TEXT CHECK (status IS NULL OR status IN ('planned', 'registered', 'completed')),
official_url TEXT,
start_time TEXT,
cluster_schedule TEXT,
bib_pickup TEXT,
bib_number TEXT,
finish_time TEXT,
notes TEXT,
finish_place TEXT,
cover_image_url TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ
)
`);
await client.query(
"INSERT INTO races (id, race_date, title, distance_km) VALUES ($1, $2, $3, $4)",
["legacy-race", "2026-01-01", "Legacy Race", 10],
);
const migration = fs.readFileSync(path.resolve(__dirname, "../migrations/004_auth_and_race_ownership.sql"), "utf8");
await client.query(migration);
const { rows } = await client.query<{ id: string; slug: string; source: string }>(
"SELECT id, slug, source FROM races WHERE slug = $1",
["legacy-race"],
);
assert.equal(rows[0]?.slug, "legacy-race");
assert.equal(rows[0]?.source, "user");
assert.match(rows[0]?.id ?? "", /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i);
} finally {
await client.query("ROLLBACK");
client.release();
}
});

21
backend/test/setup.ts Normal file
View File

@@ -0,0 +1,21 @@
process.env.NODE_ENV ??= "test";
process.env.SECURITY_PROFILE ??= "test";
process.env.SESSION_SECRET ??= "test-session-secret";
process.env.TURNSTILE_BYPASS_TOKEN ??= "mock-turnstile-token";
process.env.DB_HOST ??= "127.0.0.1";
process.env.DB_PORT ??= "5432";
process.env.DB_NAME ??= "calendar_run_test";
process.env.DB_USER ??= "postgres";
process.env.DB_PASSWORD ??= "postgres";
if (process.env.DB_NAME !== "calendar_run_test") {
throw new Error("Tests require DB_NAME=calendar_run_test");
}
const [{ migrate }, { pool }] = await Promise.all([
import("../src/migrate"),
import("../src/db"),
]);
await migrate();
await pool.query("TRUNCATE app_settings, races, users RESTART IDENTITY CASCADE");

13
docs/auth-rollout.md Normal file
View File

@@ -0,0 +1,13 @@
# Auth rollout
Before applying auth and ownership migrations in production:
1. Run `pg_dump` for the target database and keep the dump until verification is complete.
2. Record the rollback command for restoring that dump if migration or legacy race claiming fails.
3. Record the current race count with `SELECT count(*) FROM races;`.
4. Run migrations.
5. After the first email-verified user claims legacy races, verify:
- `SELECT count(*) FROM races;` matches the pre-migration count.
- `SELECT count(*) FROM races WHERE owner_user_id IS NULL;` is `0` before planning the follow-up `NOT NULL` migration.
Seed/import after auth requires `SEED_OWNER_USER_ID` or `SEED_OWNER_EMAIL` once users exist. It inserts missing seed races for that owner and does not overwrite user-edited races.

View File

@@ -1,240 +1,167 @@
# Backend API — шпаргалка для фронтенда
# Backend API — контракт для frontend
## 1. Base URL
## Base URL и авторизация
SPA всегда отправляет запросы на относительный префикс `/api` текущего origin.
SPA обращается к относительному префиксу `/api`. В dev Vite проксирует его на
`http://localhost:3001/api`; в Docker nginx проксирует на
`runners-calendar-backend:3000`.
- В dev (`npm run dev`): Vite proxy отправляет `/api/*` на `http://localhost:3001/api/*`.
- В Docker/проде: nginx фронта проксирует `/api/*` на хост `runners-calendar-backend:3000` в той же сети (уникальное имя сервиса Compose, без коллизий с чужими стеками).
Все маршруты `/api/races` требуют сессионную cookie авторизованного пользователя
с подтверждённым email. Записи принадлежат текущему пользователю: чужая запись
не видна и отвечает `404`.
## 2. CORS
После `POST /api/auth/login` или `GET /api/auth/me` клиент получает
`csrfToken`. Для каждого `POST`, `PATCH` и `DELETE` с активной сессией его нужно
передавать в заголовке `X-CSRF-Token`. Cookie передаётся браузером автоматически;
при отдельном origin запрос должен использовать credentials.
В dev-режиме бэкенд ожидает переменную:
## Аккаунт и сессии
```
CORS_ORIGIN=http://localhost:5173
```
Все маршруты этого раздела требуют авторизации и подтверждённого email. Для
изменяющих состояние запросов обязателен `X-CSRF-Token`.
Разрешены методы `GET`, `POST`, `PATCH`, `DELETE` и заголовок `Content-Type: application/json`.
### `GET /api/auth/sessions`
## 3. Эндпоинты
### `GET /api/health`
Liveness-проверка (без обращения к БД).
**Ответ 200:**
```json
{ "status": "ok" }
```
---
### `GET /api/ready`
Readiness-проверка (проверяет подключение к БД).
**Ответ 200:**
```json
{ "status": "ready", "db": "connected" }
```
**Ответ 503:**
```json
{ "error": "database_unavailable", "db": "disconnected" }
```
---
### `GET /api/races`
Список забегов, отсортированных по дате.
**Query-параметры (опциональные):**
| Параметр | Тип | Описание |
|---|---|---|
| `year` | number | Фильтр по году (напр. `2026`) |
| `month` | number | Фильтр по месяцу (112) |
- Без параметров — возвращает все забеги.
- Можно указать только `year`, только `month` или оба.
- `month` без `year` фильтрует по месяцу **всех** лет.
**Пример запроса:**
```
GET /api/races?year=2026&month=5
```
**Ответ 200:**
```json
[
{
"id": "2026-05-03-kazanskii-marafon",
"date": "2026-05-03",
"title": "Казанский марафон",
"distanceKm": 42.195,
"status": "planned",
"officialUrl": null,
"coverImageUrl": null,
"startTime": null,
"clusterSchedule": null,
"bibPickup": null,
"bibNumber": null,
"finishTime": null,
"finishPlace": null,
"notes": null,
"createdAt": "2026-03-31T12:00:00.000Z",
"updatedAt": null
}
]
```
---
### `GET /api/races/:id`
Одна запись по `id`.
**Ответ 200:** объект `Race` (см. модель ниже).
**Ответ 404:** тело JSON, поле `details` — массив пояснений (можно показывать в UI или игнорировать).
```json
{ "error": "not_found", "details": ["Race not found"] }
```
---
### `POST /api/races`
Создание забега.
**Тело запроса (JSON):**
Возвращает активные сессии текущего пользователя. Хеши и сами токены не
возвращаются.
```json
{
"id": "2026-06-01-my-race",
"sessions": [{
"id": "UUID",
"createdAt": "ISO datetime",
"lastSeenAt": "ISO datetime",
"expiresAt": "ISO datetime",
"current": true
}]
}
```
### `POST /api/auth/password`
```json
{ "currentPassword": "…", "newPassword": "не менее 15 символов" }
```
Проверяет текущий пароль, сохраняет новый и отзывает все остальные активные
сессии. Успех: `200 { "ok": true }`; неверный текущий пароль: `400`
`invalid_current_password`.
### `DELETE /api/auth/sessions/:id`
Отзывает указанную активную сессию текущего пользователя. Успех: `204`. Если
отозвана текущая сессия, cookie очищается. Чужая, неактивная или отсутствующая
сессия отвечает `404`.
### `POST /api/auth/sessions/revoke-others`
Отзывает все активные сессии, кроме текущей. Успех: `200 { "ok": true }`.
## Служебные маршруты
### `GET /api/health`
Liveness без проверки БД:
```json
{ "status": "ok", "version": "<backend version>" }
```
### `GET /api/meta`
Версия для footer:
```json
{ "version": "<backend version>" }
```
### `GET /api/ready`
Проверяет БД. Успех: `{ "status": "ready", "db": "connected" }`.
Недоступная БД: `503 { "error": "database_unavailable", "db": "disconnected" }`.
## Забеги
### `GET /api/races`
Возвращает только забеги текущего пользователя, отсортированные по дате. Опциональные
query-параметры: `year` (целое число) и `month` (112); без результатов возвращается
`[]`.
### `GET /api/races/:id`
Возвращает один забег. `id` — UUID, который создаёт сервер. Не путайте его с `slug`:
`slug` создаёт клиент как читаемый ключ и он уникален в рамках одного пользователя.
### `POST /api/races`
Создаёт забег. Обязательные поля: `slug`, `date`, `title`, `distanceKm`.
Неизвестные поля отклоняются.
```json
{
"slug": "2026-06-01-my-race",
"date": "2026-06-01",
"title": "Мой забег",
"distanceKm": 10,
"status": "planned",
"officialUrl": "https://example.com",
"coverImageUrl": "https://example.com/cover.jpg",
"startTime": "09:30",
"clusterSchedule": null,
"bibPickup": null,
"bibNumber": null,
"finishTime": null,
"finishPlace": null,
"notes": null
"startTime": "09:30"
}
```
**Обязательные поля:** `id`, `date`, `title`, `distanceKm`.
**Ответ 201:** созданный объект `Race`.
**Ответ 400:**
```json
{ "error": "validation_error", "details": ["Fields id, date, title, distanceKm are required"] }
```
**Ответ 409:**
```json
{ "error": "conflict", "details": ["Race with this id already exists"] }
```
---
Успех: `201` и объект `Race`. Одинаковый `slug` у того же пользователя: `409`
`{ "error": "conflict", "details": ["Race with this slug already exists"] }`.
Если `coverImageUrl` не передан, backend может найти Open Graph-обложку по
`officialUrl`; это best-effort и не мешает созданию при ошибке внешнего сайта.
### `PATCH /api/races/:id`
Частичное обновление — передавать **только** изменяемые поля.
**Тело запроса (JSON):**
```json
{
"finishTime": "1:45:30",
"finishPlace": "12/340",
"bibNumber": "1234",
"status": "completed"
}
```
**Допустимые поля:** `date`, `title`, `distanceKm`, `status`, `officialUrl`, `coverImageUrl`, `startTime`, `clusterSchedule`, `bibPickup`, `bibNumber`, `finishTime`, `finishPlace`, `notes`.
**Ответ 200:** обновлённый объект `Race`.
**Ответ 400:**
```json
{ "error": "validation_error", "details": ["No updatable fields provided"] }
```
**Ответ 404:**
```json
{ "error": "not_found", "details": ["Race not found"] }
```
---
Частично обновляет забег. Передайте хотя бы одно поле из модели ниже; неизвестные
поля и пустой объект отклоняются. Успех: `200` и обновлённый `Race`.
### `DELETE /api/races/:id`
Удаление забега.
Удаляет забег. Успех: `204` без тела.
**Ответ 204:** пустое тело.
## Модель `Race`
**Ответ 404:**
| Поле | Тип | POST | PATCH | Ограничение |
|---|---|---:|---:|---|
| `id` | UUID | сервер | — | Read-only UUID сервера |
| `slug` | string | да | да | 1120 символов: буквы/цифры, разделённые `-` |
| `date` | string | да | да | Реальная дата `YYYY-MM-DD` |
| `title` | string | да | да | 1200 символов |
| `distanceKm` | number | да | да | Больше 0, не более 999.999 |
| `status` | string/null | нет | да | `planned`, `registered` или `completed` |
| `officialUrl` | HTTP(S) URL/null | нет | да | До 2048 символов |
| `coverImageUrl` | HTTP(S) URL/null | нет | да | До 2048 символов |
| `startTime` | string/null | нет | да | `HH:MM` или `HH:MM:SS` |
| `clusterSchedule` | string/null | нет | да | До 1000 символов |
| `bibPickup` | string/null | нет | да | До 500 символов |
| `bibNumber` | string/null | нет | да | До 100 символов |
| `finishTime` | string/null | нет | да | `SS`, `MM:SS` или `H:MM:SS` |
| `finishPlace` | string/null | нет | да | До 100 символов |
| `notes` | string/null | нет | да | До 5000 символов |
| `createdAt` | ISO datetime | сервер | — | Read-only |
| `updatedAt` | ISO datetime/null | сервер | — | Read-only |
```json
{ "error": "not_found", "details": ["Race not found"] }
```
## Ошибки
## 4. Модель `Race` (camelCase)
| Статус | Тело | Когда |
|---:|---|---|
| 400 | `{ "error": "validation_error", "details": ["…"] }` | Неверный UUID, query или тело запроса |
| 401 | `{ "error": "unauthorized", "details": ["Authentication required"] }` | Нет сессии |
| 403 | `csrf_error` / `email_not_verified` | Неверный CSRF-токен или email не подтверждён |
| 404 | `{ "error": "not_found", "details": ["Race not found"] }` | Забега нет либо он принадлежит другому пользователю |
| 409 | `conflict` | Дублирующий `slug` пользователя |
| 500 | `unknown_error` | Непредвиденная ошибка приложения |
| 503 | `database_unavailable` | База недоступна |
| Поле | Тип | POST обяз. | PATCH | Описание |
|---|---|---|---|---|
| `id` | string | да | — | Стабильный ключ, напр. `2026-05-03-kazan-marathon` |
| `date` | string | да | да | `YYYY-MM-DD` |
| `title` | string | да | да | Название забега |
| `distanceKm` | number | да | да | Дистанция в км |
| `status` | string \| null | нет | да | `"planned"` / `"registered"` / `"completed"` |
| `officialUrl` | string \| null | нет | да | URL организатора |
| `coverImageUrl` | string \| null | нет | да | URL обложки забега. При `POST` может быть найден автоматически по `officialUrl`, если не передан вручную |
| `startTime` | string \| null | нет | да | Время старта, напр. `"09:30"` или `"09:30:00"` (часы:минуты:секунды) |
| `clusterSchedule` | string \| null | нет | да | Расписание кластеров |
| `bibPickup` | string \| null | нет | да | Выдача номеров |
| `bibNumber` | string \| null | нет | да | Стартовый номер |
| `finishTime` | string \| null | нет | да | Финишное время `H:MM:SS` или `MM:SS` |
| `finishPlace` | string \| null | нет | да | Место на финише (произвольная строка) |
| `notes` | string \| null | нет | да | Заметки |
| `createdAt` | string | — | — | ISO timestamp (read-only) |
| `updatedAt` | string \| null | — | — | ISO timestamp (read-only) |
Некорректный JSON также отвечает `400 validation_error`.
## 5. Фильтрация списка (`GET /api/races`)
## Seed
- **`year`** — целое число, фильтрует по `EXTRACT(YEAR FROM race_date)`.
- **`month`** — целое число 112, фильтрует по `EXTRACT(MONTH FROM race_date)`.
- Параметры можно комбинировать (`?year=2026&month=5`) или указывать по одному.
- Без параметров — все забеги без ограничения (пагинация пока не реализована).
- Если по фильтру нет результатов — пустой массив `[]`, статус `200`.
## 6. Идемпотентность seed
Seed-скрипт (`npm run seed` в `backend/`) выполняет **upsert** по полю `id` (`INSERT … ON CONFLICT (id) DO UPDATE`). Источник данных — `import/races_2026_calendar.csv` из корня репозитория. Повторный запуск безопасен.
## 7. Поведение при недоступной БД
- `GET /api/health` — всегда `200` (не проверяет БД).
- `GET /api/ready` — при недоступной БД: `503 { "error": "database_unavailable", "db": "disconnected" }`. В режиме **`CALENDAR_RUN_MOCK_DB`** (dev/CI без Postgres) readiness возвращает успех без реального подключения — см. `docs/backend.md`.
- Все остальные маршруты — `503 { "error": "database_unavailable" }`.
- В логах сервера: строка ошибки с контекстом маршрута.
`npm run seed` выполняет upsert по `slug` и не перезаписывает пользовательские
изменения. После включения авторизации ему нужен `SEED_OWNER_USER_ID` или
`SEED_OWNER_EMAIL`.

View File

@@ -68,27 +68,26 @@ API слушает порт: **`PORT`** (если задан), иначе **`API
| Переменная | Описание | По умолчанию |
|---|---|---|
| `DB_HOST` | Хост PostgreSQL | — (обязательна без mock, см. ниже) |
| `DB_PORT` | Порт PostgreSQL | — (обязательна без mock) |
| `DB_NAME` | Имя базы данных | — (обязательна без mock) |
| `DB_USER` | Пользователь БД | — (обязательна без mock) |
| `DB_PASSWORD` | Пароль БД | — (обязательна без mock) |
| `CALENDAR_RUN_MOCK_DB` | `1` или `true` — режим без PostgreSQL для HTTP API (dev/CI) | выкл. |
| `DB_HOST` | Хост PostgreSQL | — |
| `DB_PORT` | Порт PostgreSQL | — |
| `DB_NAME` | Имя базы данных | — |
| `DB_USER` | Пользователь БД | — |
| `DB_PASSWORD` | Пароль БД | — |
| `PORT` | Порт API (приоритетнее `API_PORT`) | — |
| `API_PORT` | Порт API-сервера | `3001` |
| `CORS_ORIGIN` | Разрешённый origin для CORS | `http://localhost:5173` |
Фронтенд всегда обращается к API по префиксу `/api` на текущем origin. В локальной разработке этот префикс проксирует Vite (`frontend/vite.config.ts`) на `http://localhost:3001`; в Docker-стеке — nginx фронта проксирует на `runners-calendar-backend:3000`.
**Без mock:** при отсутствии любой из `DB_*` процесс падает при старте: `Missing required environment variable: <NAME>`.
При отсутствии любой из `DB_*` процесс падает при старте: `Missing required environment variable: <NAME>`.
**С `CALENDAR_RUN_MOCK_DB=1`:** переменные `DB_*` не обязательны; реальный пул не поднимается. **Не использовать** mock для `npm run db:migrate` и `npm run seed` — нужен настоящий Postgres и корректные `DB_*`.
Для тестов нужен отдельный PostgreSQL database `calendar_run_test`; `npm test` применяет миграции и очищает его перед запуском.
## Поведение при недоступной БД
- **Старт сервера** — проходит успешно (env валидирован, Express слушает порт).
- **`GET /api/health`** — всегда `200 { "status": "ok" }` (liveness, без обращения к БД).
- **`GET /api/ready`** — при обычном режиме пробует подключиться к БД; `200` если ОК, `503 { "error": "database_unavailable", ... }` если нет. В режиме **`CALENDAR_RUN_MOCK_DB`** readiness считается успешным без реального подключения (удобно для CI/smoke API).
- **`GET /api/ready`** — пробует подключиться к БД; `200` если ОК, `503 { "error": "database_unavailable", ... }` если нет.
- **Все остальные маршруты** при ошибке БД возвращают `503 { "error": "database_unavailable" }`.
## Структура каталога
@@ -101,7 +100,7 @@ backend/
├── src/
│ ├── app.ts # фабрика Express (тесты)
│ ├── config.ts # загрузка и валидация env
│ ├── db.ts # pg Pool или mock
│ ├── db.ts # pg Pool
│ ├── index.ts # запуск сервера
│ ├── migrate.ts # раннер миграций
│ ├── seed.ts # разовый импорт CSV

132
docs/product-backlog.md Normal file
View File

@@ -0,0 +1,132 @@
# Backlog Calendar Run
**Срез репозитория: 12 июля 2026.** Это единый продуктовый и технический backlog, составленный по текущему коду, а не список абстрактных идей.
## Текущая база
| Контур | Версия | Уже работает |
|---|---:|---|
| Backend | `1.5.0` | Express + PostgreSQL, миграции и seed, строгий CRUD личных стартов, cookie-сессии, CSRF, подтверждение email, Turnstile, SMTP, смена пароля и управление сессиями, SSRF-защита обложек, `/health`, `/ready`, `/meta`, CI с PostgreSQL. |
| Frontend | `0.8.0` | Защищённые маршруты и auth-экраны, экран аккаунта, нативные поля даты/старта, удобный ввод результатов, дашборд с последними стартами и интерактивным графиком, версия FE/BE в футере. |
## Правила версионирования и выпуска
1. BE и FE выпускаются независимо: задачам без серверного контракта не нужен искусственный bump BE.
2. Патч (`x.y.Z`) — исправление без изменения публичного контракта; минор (`x.Y.0`) — новая обратносуместимая возможность; мажор — только при несовместимом API или модели доступа.
3. Любой релиз с изменением API включает: обновлённый `docs/backend-api-for-frontend.md`, миграцию (если нужна), тесты контракта и проверку футера с фактическими версиями.
4. До миграций в production выполнить чек-лист из [auth-rollout.md](auth-rollout.md): backup, число стартов до/после миграции и проверка владельца legacy-данных.
## План релизов
| Очередь | BE | FE | Цель | Условие выпуска |
|---|---:|---:|---|---|
| P0 | `1.4.2` | `0.7.1` | Исправить риски текущего релиза и привести контракт в соответствие коду. | Нет невалидных записей, SSRF-защита, критические сценарии проверяются на PostgreSQL. |
| P1 | `1.5.0` | `0.8.0` | Дать пользователю контроль над аккаунтом и данными, улучшить работу с историей стартов. | Управление сессиями и экспорт доступны, фильтры не ломают существующий список. |
| P2 | `1.6.0` | `0.9.0` | Подготовка к старту: чек-листы, напоминания и календарная интеграция. | Напоминание отправляется не более одного раза, подписка/токен отзываемы. |
| P3 | `1.7.0` | `1.0.0` | Сделать персональный календарь эксплуатационно готовым. | Восстановление из backup проверено, есть E2E критического пути и аудит доступности. |
`2.0.0` не планируется заранее: его поводом может быть только подтверждённая потребность в совместном доступе, командах или публичных стартах — это изменит модель владельцев и доступа.
## P0 — BE 1.4.2 / FE 0.7.1
### Backend 1.4.2
| ID | Приоритет | Задача | Результат / критерий приёмки |
|---|---|---|---|
| BE-142-04 | P0 | Выполнить backend-часть Ponytail-аудита | Pipeline поднимает чистый PostgreSQL, применяет все миграции и выполняет минимум CRUD + auth, включая legacy-сценарий миграции `004`. Самодельный SQL-эмулятор `CALENDAR_RUN_MOCK_DB` удалён; тесты работают с этой БД. `cross-env` удалён, если Windows не входит в список поддерживаемых сред разработки. |
| BE-142-01 | P0 | Актуализировать контракт API | Документированы `id` (UUID сервера) и `slug` (ключ пользователя), обязательная авторизация для `/races`, CSRF для мутаций, реальный ответ `/health`, все коды ошибок и формат `Race`. Старое описание не обещает несуществующий неавторизованный CRUD. |
| BE-142-02 | P0 | Ввести строгую валидацию создания и изменения старта | Одна схема на `POST`/`PATCH`: корректные дата и дистанция, enum статуса, допустимые форматы `startTime` и `finishTime`, URL, пределы длины текстов; неизвестные поля не игнорируются. Ошибка возвращает `400 validation_error` с полем/причиной. |
| BE-142-03 | P0 | Защитить получение обложки от SSRF | Автозагрузка принимает только безопасные HTTP(S)-адреса, запрещает loopback/private/link-local адреса и опасные редиректы, ограничивает число редиректов, размер и время ответа. При невозможности получить обложку старт всё равно создаётся без неё. Есть тесты на блокировку. |
| BE-142-05 | P1 | Подтвердить достаточность индексов по фактическим запросам | Для списка владельца с фильтром года/месяца снять `EXPLAIN ANALYZE`; индекс `(owner_user_id, race_date)` добавлять только если он нужен по плану запроса или размеру данных. |
### Frontend 0.7.1
| ID | Приоритет | Задача | Результат / критерий приёмки |
|---|---|---|---|
| FE-071-00 | P0 | Выполнить frontend-часть Ponytail-аудита | Удалены артефакты TypeScript/Vite из Git и добавлены в `.gitignore`, неиспользуемый `getHealth` и тип `HealthResponse`. Кастомные поля даты и времени старта заменены нативными `input[type=date]` и `input[type=time]`; каталог названий стартов с внешними картинками удалён, остаются `coverImageUrl` и локальные fallback-изображения. Создание и обновление старта используют один payload полей. |
| FE-071-01 | P0 | Исправить семантику личного рекорда | Карточка «Последний личный рекорд» показывается только для результата, который действительно улучшил прошлое лучшее время на сопоставимой дистанции; иначе карточка называется «Последний результат» либо скрывается. |
| FE-071-02 | P0 | Валидировать время до отправки | Форма не принимает `1:99`, отрицательные и произвольные значения; пользователю показана ошибка у поля. Правило идентично серверному, но сервер остаётся источником истины. |
| FE-071-03 | P0 | Довести ошибки auth-форм до состояния UI | На регистрации, повторной отправке письма, запросе и сбросе пароля есть loading-состояние, обработка ошибки и защита от повторной отправки. Никаких необработанных Promise в браузере. |
| FE-071-04 | P1 | Корректно обработать истёкшую сессию и CSRF | После `401` пользователь возвращается на вход с сохранением целевого маршрута; после `csrf_error` интерфейс предлагает однократно обновить сессию и повторить действие, не теряя заполненную форму. |
| FE-071-05 | P0 | Завершать подтверждение email понятным экраном успеха | После успешной ссылки `/verify-email?token=…` заголовок явно сообщает, что email подтверждён; ввод email, инструкция и кнопка повторной отправки не показываются. Форма повторной отправки остаётся только для недействительной/отсутствующей ссылки. |
| FE-071-06 | P0 | Упростить ввод результата забега | Время вводится отдельными цифровыми полями «часы», «минуты», «секунды» без ручного ввода `:`. Часы — целое число ≥ 0, минуты и секунды — от 0 до 59; пустые минуты/секунды автоматически нормализуются в `0`. Итоговое время не показывает незаполненные старшие части: без часов — `MM:SS`, без часов и минут — `SS`. Место вводится числами «место» и «всего участников» (второе необязательно), проверяется, что место ≥ 1 и не больше общего числа; в текущий `finishPlace` без изменения API сохраняется `место/всего` либо только место. Старые значения `finishPlace` не теряются до редактирования результата. |
## P1 — BE 1.5.0 / FE 0.8.0
### Backend 1.5.0
| ID | Приоритет | Задача | Результат / критерий приёмки |
|---|---|---|---|
| BE-150-01 | Готово | Управление паролем и сессиями | Выполнено в `1.5.0`: авторизованный пользователь меняет пароль после проверки текущего; все другие сессии отзываются. Есть список активных сессий с отзывом выбранной и «выйти везде». Токены и пароли в ответах/логах не появляются. |
| BE-150-02 | P1 | Удаление аккаунта и данных | Явно подтверждённый запрос удаляет аккаунт, его сессии и личные старты транзакционно; повторный запрос идемпотентен. Перед реализацией согласовать необходимость периода восстановления. |
| BE-150-03 | P1 | Обратносуместимая фильтрация стартов | Добавить документированные фильтры `status`, диапазон дат и диапазон дистанции, не меняя текущий ответ-массив `GET /races`. Валидация параметров и покрытие индексами — по фактическому query plan. |
### Frontend 0.8.0
| ID | Приоритет | Задача | Результат / критерий приёмки |
|---|---|---|---|
| FE-080-01 | Готово | Экран «Аккаунт» | Выполнено в `0.8.0`: пользователь видит подтверждённый email, меняет пароль и видит/отзывает другие сессии. Удаление аккаунта остаётся отдельной задачей BE-150-02. |
| FE-080-02 | P1 | Поиск, фильтры и сортировка истории | Фильтры по статусу, периоду и дистанции синхронизированы с URL; поиск по названию работает без новой зависимости. Годы в селекторе строятся из данных, а не ограничены окном «-2/+4». |
| FE-080-03 | P1 | Выгрузка личных данных | Из текущей отфильтрованной выборки пользователь скачивает CSV и одноразовый `.ics` в браузере. Сервер не нужен: данные уже принадлежат пользователю и загружены в UI. |
| FE-080-04 | Готово | Сделать на дашборде список последних стартов | Выполнено в `0.8.0`: «Последние старты» показывают до 5 завершённых стартов с клиентской пагинацией, фильтрами по дистанции и году и доступным переходом в старт. |
| FE-080-05 | Готово | Связать рекорды по дистанциям со стартами | Выполнено в `0.8.0`: карточка рекорда с найденным стартом ведёт на `/races/:id` и использует существующую анимацию связанных карточек. |
| FE-080-06 | Готово | Сделать график прогресса информативным и интерактивным | Выполнено в `0.8.0`: график имеет подписанные оси, компактные подписи, tooltip по наведению/фокусу и доступный переход в старт. |
## P2 — BE 1.6.0 / FE 0.9.0
### Backend 1.6.0
| ID | Приоритет | Задача | Результат / критерий приёмки |
|---|---|---|---|
| BE-160-01 | P1 | Чек-листы подготовки к старту | Новая сущность задач старта: текст, состояние, срок и порядок. Задачи принадлежат тому же пользователю, что и старт; каскадно удаляются вместе со стартом. |
| BE-160-02 | P1 | Надёжные email-напоминания | Настройки пользователя и очередь напоминаний хранятся в БД; отправка идемпотентна, ошибки ретраятся ограниченно и логируются без PII. Расписание запуска — внешний cron/worker, а не `setInterval` в каждом web-инстансе. |
| BE-160-03 | P2 | Подписка на персональный iCalendar | Выдать отдельный отзывной секретный URL только для read-only календаря пользователя. Нужны лимитирование, отсутствие email/заметок в ICS и отзыв/ротация токена. |
### Frontend 0.9.0
| ID | Приоритет | Задача | Результат / критерий приёмки |
|---|---|---|---|
| FE-090-01 | P1 | Чек-лист в карточке старта | Пользователь добавляет, завершает и переставляет задачи подготовки; на дашборде видит ближайшие незавершённые пункты. Пустое состояние объясняет, что делать. |
| FE-090-02 | P1 | Настройки напоминаний | Опт-ин по email и выбор сроков до старта; понятно отображаются часовой пояс и факт отключения рассылки. |
| FE-090-03 | P2 | Управление iCalendar-подпиской | Экран показывает URL только по явному действию, даёт скопировать, отозвать и выпустить новый. Одноразовый экспорт из `0.8.0` остаётся доступным без подписки. |
## P3 — BE 1.7.0 / FE 1.0.0
### Backend 1.7.0
| ID | Приоритет | Задача | Результат / критерий приёмки |
|---|---|---|---|
| BE-170-01 | P0 | Эксплуатационный runbook | Описаны deploy, миграция, rollback, backup, restore, ротация секретов, SMTP/Turnstile и реакция на недоступную БД. Restore протестирован на копии production-данных. |
| BE-170-02 | P0 | Наблюдаемость без утечки данных | Структурированные логи с request ID, готовность БД, счётчики ошибок и alert на недоступность. Email, пароли, токены, cookie и текст заметок не логируются. |
| BE-170-03 | P1 | Машиночитаемый API-контракт | OpenAPI-спецификация синхронизирована с маршрутам и используется в CI для проверки ключевых ответов. Это предотвращает повторное расхождение документации и реализации. |
### Frontend 1.0.0
| ID | Приоритет | Задача | Результат / критерий приёмки |
|---|---|---|---|
| FE-100-01 | P0 | Критический E2E-путь | Автоматически проверяются: регистрация/подтверждение (с тестовым адаптером), вход, создание старта, внесение результата, экспорт и выход. Тест запускается в CI против реального API и PostgreSQL. |
| FE-100-02 | P0 | Accessibility и мобильный аудит | Полный путь проходится клавиатурой; формы сообщают ошибки screen reader'у, focus не теряется после действий, контраст и touch-targets соответствуют WCAG 2.2 AA. Проверены малый экран и медленное соединение. |
| FE-100-03 | P1 | Полировка надёжности UI | Единые skeleton/empty/error состояния, retry для безопасных загрузок, защита от двойной отправки мутаций, проверка загрузки внешних обложек и сохранение версии FE/BE в футере. |
## Зависимости и решения до начала P2
| Решение | Нужен ответ | Почему блокирует |
|---|---|---|
| Канал напоминаний | Только email или ещё push/мессенджер? | Определяет модель согласия, worker и стоимость эксплуатации. |
| Планировщик | Какой внешний cron/worker доступен в production? | Нельзя надёжно рассылать напоминания таймером в web-процессе. |
| Удаление аккаунта | Немедленное удаление или 30-дневное восстановление? | Меняет схему данных и обязанность хранить архив. |
| Политика данных | Для каких стран и пользователей запускается сервис? | До публичного запуска нужны политика конфиденциальности, срок хранения и процесс обращения пользователя. |
## Явно не берём до подтверждения спроса
- общий доступ к календарям, клубы, тренеры и публичные профили;
- поиск и парсинг стартов из сайтов организаторов, кроме уже существующей необязательной обложки;
- нативные iOS/Android-приложения, платежи и социальная лента;
- серверную пагинацию: личный календарь пока мал, а изменение формы ответа `GET /races` без измеренной необходимости только усложнит FE и API.
## Рекомендуемый первый спринт
1. `BE-142-04` и `FE-071-00`: выполнить выводы Ponytail-аудита — удалить самодельную инфраструктуру и использовать возможности платформы.
2. `BE-142-01` и `BE-142-02`: зафиксировать настоящий контракт и закрыть trust boundary.
3. `BE-142-03`: убрать SSRF-риск до любого расширения автозагрузки обложек.
4. `FE-071-01``FE-071-06`: устранить неверную аналитику и ошибки критических форм, включая понятное завершение подтверждения email и ввод результата.

View File

@@ -1,2 +1,4 @@
# Для локального npm run dev дополнительных VITE-переменных не требуется.
# Для production-регистрации укажите публичный site key Cloudflare Turnstile.
# Без значения локально используется dev bypass token, если он разрешён бэкендом.
# VITE_TURNSTILE_SITE_KEY=
# Полный список переменных окружения — в корневом .env.example репозитория.

View File

@@ -1,12 +1,12 @@
{
"name": "calendar-run-frontend",
"version": "0.6.1",
"version": "0.8.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "calendar-run-frontend",
"version": "0.6.1",
"version": "0.8.0",
"dependencies": {
"react": "^18.3.1",
"react-dom": "^18.3.1",

View File

@@ -1,7 +1,7 @@
{
"name": "calendar-run-frontend",
"private": true,
"version": "0.6.1",
"version": "0.8.0",
"type": "module",
"scripts": {
"dev": "vite",

89
frontend/src/api/auth.ts Normal file
View File

@@ -0,0 +1,89 @@
import { requestJson, setCsrfToken } from "./http";
import type { AuthSession, AuthUser } from "./types";
interface AuthResponse {
user: AuthUser;
csrfToken: string | null;
}
function applyAuthResponse(response: AuthResponse): AuthResponse {
setCsrfToken(response.csrfToken);
return response;
}
export async function getCurrentUser(): Promise<AuthResponse> {
return applyAuthResponse(await requestJson<AuthResponse>("/auth/me"));
}
export async function register(payload: {
email: string;
password: string;
turnstileToken: string;
}): Promise<void> {
await requestJson<void>("/auth/register", {
method: "POST",
body: JSON.stringify(payload),
});
}
export async function login(payload: { email: string; password: string }): Promise<AuthResponse> {
return applyAuthResponse(
await requestJson<AuthResponse>("/auth/login", {
method: "POST",
body: JSON.stringify(payload),
}),
);
}
export async function logout(): Promise<void> {
await requestJson<void>("/auth/logout", { method: "POST" });
setCsrfToken(null);
}
export async function verifyEmail(token: string): Promise<void> {
await requestJson<void>("/auth/verify-email", {
method: "POST",
body: JSON.stringify({ token }),
});
}
export async function resendVerification(email: string): Promise<void> {
await requestJson<void>("/auth/resend-verification", {
method: "POST",
body: JSON.stringify({ email }),
});
}
export async function forgotPassword(email: string): Promise<void> {
await requestJson<void>("/auth/forgot-password", {
method: "POST",
body: JSON.stringify({ email }),
});
}
export async function resetPassword(token: string, password: string): Promise<void> {
await requestJson<void>("/auth/reset-password", {
method: "POST",
body: JSON.stringify({ token, password }),
});
}
export async function getSessions(): Promise<AuthSession[]> {
const response = await requestJson<{ sessions: AuthSession[] }>("/auth/sessions");
return response.sessions;
}
export async function changePassword(payload: { currentPassword: string; newPassword: string }): Promise<void> {
await requestJson<void>("/auth/password", {
method: "POST",
body: JSON.stringify(payload),
});
}
export async function revokeSession(sessionId: string): Promise<void> {
await requestJson<void>(`/auth/sessions/${sessionId}`, { method: "DELETE" });
}
export async function revokeOtherSessions(): Promise<void> {
await requestJson<void>("/auth/sessions/revoke-others", { method: "POST" });
}

View File

@@ -3,6 +3,12 @@ export type ApiErrorCode =
| "not_found"
| "database_unavailable"
| "conflict"
| "unauthorized"
| "email_not_verified"
| "csrf_error"
| "captcha_failed"
| "invalid_credentials"
| "invalid_token"
| "network_error"
| "unknown_error";
@@ -36,6 +42,12 @@ function normalizeApiCode(value: string | undefined): ApiErrorCode {
value === "not_found" ||
value === "database_unavailable" ||
value === "conflict" ||
value === "unauthorized" ||
value === "email_not_verified" ||
value === "csrf_error" ||
value === "captcha_failed" ||
value === "invalid_credentials" ||
value === "invalid_token" ||
value === "unknown_error"
) {
return value;
@@ -98,6 +110,18 @@ export function getApiErrorMessage(code: ApiErrorCode): string {
return "Сервис временно недоступен. Попробуйте позже.";
case "conflict":
return "Запись с таким идентификатором уже существует.";
case "unauthorized":
return "Нужно войти в аккаунт.";
case "email_not_verified":
return "Подтвердите email, чтобы продолжить.";
case "csrf_error":
return "Сессия устарела. Обновите страницу и попробуйте снова.";
case "captcha_failed":
return "Проверка капчи не пройдена.";
case "invalid_credentials":
return "Неверный email или пароль.";
case "invalid_token":
return "Ссылка недействительна или устарела.";
case "network_error":
return "Не удалось связаться с сервером.";
case "unknown_error":

View File

@@ -1,18 +1,9 @@
import { requestJson } from "./http";
export type HealthResponse = {
status: string;
version: string;
};
export type BackendMetaResponse = {
version: string;
};
export async function getHealth(init?: RequestInit): Promise<HealthResponse> {
return requestJson<HealthResponse>("/health", init);
}
/** Версия бэкенда для футера (отдельный путь от /health — меньше ложных блокировок). */
export async function getBackendMeta(init?: RequestInit): Promise<BackendMetaResponse> {
return requestJson<BackendMetaResponse>("/meta", init);

View File

@@ -1,6 +1,11 @@
import { ApiError, isStructuredApiErrorPayload, toApiError } from "./errors";
const API_ROOT = "/api";
let csrfToken: string | null = null;
export function setCsrfToken(token: string | null): void {
csrfToken = token;
}
function buildUrl(path: string): string {
const normalizedPath = path.startsWith("/") ? path : `/${path}`;
@@ -61,9 +66,13 @@ export async function requestJson<T>(path: string, init?: RequestInit): Promise<
if (method !== "GET" && method !== "HEAD") {
defaultHeaders["Content-Type"] = "application/json";
}
if (csrfToken && ["POST", "PUT", "PATCH", "DELETE"].includes(method)) {
defaultHeaders["X-CSRF-Token"] = csrfToken;
}
const response = await fetch(buildUrl(path), {
...init,
credentials: "include",
headers: {
...defaultHeaders,
...(init?.headers ?? {}),

View File

@@ -1,5 +1,19 @@
export type { CreateRacePayload, Race, RacesQuery, RaceStatus, UpdateRacePayload } from "./types";
export type { AuthSession, AuthUser, CreateRacePayload, Race, RacesQuery, RaceStatus, UpdateRacePayload } from "./types";
export { ApiError, getApiErrorMessage } from "./errors";
export type { BackendMetaResponse, HealthResponse } from "./health";
export { getBackendMeta, getHealth } from "./health";
export type { BackendMetaResponse } from "./health";
export { getBackendMeta } from "./health";
export { getRaceById, getRaces, createRace, updateRace, deleteRace } from "./races";
export {
forgotPassword,
changePassword,
getCurrentUser,
getSessions,
login,
logout,
register,
resendVerification,
resetPassword,
revokeOtherSessions,
revokeSession,
verifyEmail,
} from "./auth";

View File

@@ -19,6 +19,7 @@ function normalizeRace(input: unknown): Race {
const isValid =
isString(race?.id) &&
isString(race?.slug) &&
isString(race?.date) &&
isString(race?.title) &&
typeof race?.distanceKm === "number" &&
@@ -48,6 +49,7 @@ function normalizeRace(input: unknown): Race {
return {
id: race.id,
slug: race.slug,
date: race.date,
title: race.title,
distanceKm: race.distanceKm,

View File

@@ -2,6 +2,7 @@ export type RaceStatus = "planned" | "registered" | "completed";
export interface Race {
id: string;
slug: string;
date: string;
title: string;
distanceKm: number;
@@ -25,7 +26,7 @@ export interface RacesQuery {
}
export interface CreateRacePayload {
id: string;
slug: string;
date: string;
title: string;
distanceKm: number;
@@ -42,3 +43,17 @@ export interface CreateRacePayload {
}
export type UpdateRacePayload = Partial<Omit<CreateRacePayload, "id">>;
export interface AuthUser {
id: string;
email: string;
emailVerifiedAt: string | null;
}
export interface AuthSession {
id: string;
createdAt: string;
lastSeenAt: string;
expiresAt: string;
current: boolean;
}

View File

@@ -0,0 +1,68 @@
import { createContext, useCallback, useContext, useEffect, useMemo, useState } from "react";
import type { AuthUser } from "../../api";
import { ApiError, getCurrentUser, login as loginRequest, logout as logoutRequest } from "../../api";
interface AuthContextValue {
user: AuthUser | null;
isLoading: boolean;
login(email: string, password: string): Promise<void>;
logout(): Promise<void>;
refresh(): Promise<void>;
}
const AuthContext = createContext<AuthContextValue | null>(null);
export function AuthProvider(props: { children: React.ReactNode }): JSX.Element {
const [user, setUser] = useState<AuthUser | null>(null);
const [isLoading, setIsLoading] = useState(true);
const refresh = useCallback(async () => {
try {
const response = await getCurrentUser();
setUser(response.user);
} catch (error) {
if (error instanceof ApiError && error.status === 401) {
setUser(null);
return;
}
setUser(null);
}
}, []);
useEffect(() => {
let mounted = true;
void refresh().finally(() => {
if (mounted) {
setIsLoading(false);
}
});
return () => {
mounted = false;
};
}, [refresh]);
const login = useCallback(async (email: string, password: string) => {
const response = await loginRequest({ email, password });
setUser(response.user);
}, []);
const logout = useCallback(async () => {
await logoutRequest();
setUser(null);
}, []);
const value = useMemo(
() => ({ user, isLoading, login, logout, refresh }),
[user, isLoading, login, logout, refresh],
);
return <AuthContext.Provider value={value}>{props.children}</AuthContext.Provider>;
}
export function useAuth(): AuthContextValue {
const context = useContext(AuthContext);
if (!context) {
throw new Error("useAuth must be used inside AuthProvider");
}
return context;
}

View File

@@ -0,0 +1,26 @@
import { Navigate, Outlet, useLocation } from "react-router-dom";
import { useAuth } from "./AuthContext";
export function RequireAuth(): JSX.Element {
const { user, isLoading } = useAuth();
const location = useLocation();
if (isLoading) {
return (
<section className="page page--auth" aria-busy="true">
<h1 className="page__title">Загрузка</h1>
<p className="page__subtitle">Проверяем сессию...</p>
</section>
);
}
if (!user) {
return <Navigate to="/login" replace state={{ from: location }} />;
}
if (!user.emailVerifiedAt) {
return <Navigate to="/verify-email" replace />;
}
return <Outlet />;
}

View File

@@ -1,7 +1,10 @@
import { Link, NavLink, Outlet } from "react-router-dom";
import { useAuth } from "../auth/AuthContext";
import { AppShellFooter } from "./AppShellFooter";
export function AppLayout(): JSX.Element {
const { user, logout } = useAuth();
return (
<div className="app-shell">
<header className="app-shell__header">
@@ -34,6 +37,30 @@ export function AppLayout(): JSX.Element {
>
+ Добавить
</NavLink>
{user ? (
<>
<NavLink
to="/account"
className={({ isActive }) =>
isActive ? "app-shell__link app-shell__link--active" : "app-shell__link"
}
>
Аккаунт
</NavLink>
<button className="app-shell__link app-shell__link--button" type="button" onClick={() => void logout()}>
Выйти
</button>
</>
) : (
<NavLink
to="/login"
className={({ isActive }) =>
isActive ? "app-shell__link app-shell__link--active" : "app-shell__link"
}
>
Войти
</NavLink>
)}
</nav>
</header>
<main className="app-shell__main">

View File

@@ -5,18 +5,32 @@ import { RacesPage } from "../pages/RacesPage";
import { RaceDetailsPage } from "../pages/RaceDetailsPage";
import { RaceFormPage } from "../pages/RaceFormPage";
import { RaceDayPage } from "../pages/RaceDayPage";
import { AccountPage } from "../pages/AccountPage";
import { ForgotPasswordPage, LoginPage, RegisterPage, ResetPasswordPage, VerifyEmailPage } from "../pages/AuthPages";
import { RequireAuth } from "./auth/RequireAuth";
export const appRouter = createBrowserRouter([
{
path: "/",
element: <AppLayout />,
children: [
{ index: true, element: <DashboardPage /> },
{ path: "races", element: <RacesPage /> },
{ path: "races/new", element: <RaceFormPage /> },
{ path: "races/day/:ymd", element: <RaceDayPage /> },
{ path: "races/:raceId", element: <RaceDetailsPage /> },
{ path: "races/:raceId/edit", element: <RaceFormPage /> },
{ path: "login", element: <LoginPage /> },
{ path: "register", element: <RegisterPage /> },
{ path: "verify-email", element: <VerifyEmailPage /> },
{ path: "forgot-password", element: <ForgotPasswordPage /> },
{ path: "reset-password", element: <ResetPasswordPage /> },
{
element: <RequireAuth />,
children: [
{ index: true, element: <DashboardPage /> },
{ path: "account", element: <AccountPage /> },
{ path: "races", element: <RacesPage /> },
{ path: "races/new", element: <RaceFormPage /> },
{ path: "races/day/:ymd", element: <RaceDayPage /> },
{ path: "races/:raceId", element: <RaceDetailsPage /> },
{ path: "races/:raceId/edit", element: <RaceFormPage /> },
],
},
],
},
]);

View File

@@ -1,197 +0,0 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { buildMonthCells, toYmd, WEEKDAY_LABELS_SHORT_RU } from "../lib";
const MONTH_NAMES_RU = [
"Январь",
"Февраль",
"Март",
"Апрель",
"Май",
"Июнь",
"Июль",
"Август",
"Сентябрь",
"Октябрь",
"Ноябрь",
"Декабрь",
];
interface DatePickerFieldProps {
value: string;
name: string;
required?: boolean;
onChange: (value: string) => void;
}
function parseYmd(value: string): { year: number; monthIndex: number; day: number } | null {
if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) {
return null;
}
const year = Number(value.slice(0, 4));
const monthIndex = Number(value.slice(5, 7)) - 1;
const day = Number(value.slice(8, 10));
if (!Number.isInteger(year) || !Number.isInteger(monthIndex) || !Number.isInteger(day)) {
return null;
}
if (monthIndex < 0 || monthIndex > 11) {
return null;
}
return { year, monthIndex, day };
}
function getInitialVisibleMonth(value: string): { year: number; monthIndex: number } {
const parsed = parseYmd(value);
if (parsed) {
return { year: parsed.year, monthIndex: parsed.monthIndex };
}
const now = new Date();
return { year: now.getFullYear(), monthIndex: now.getMonth() };
}
export function DatePickerField(props: DatePickerFieldProps): JSX.Element {
const { value, name, required, onChange } = props;
const [isOpen, setIsOpen] = useState(false);
const [visibleMonth, setVisibleMonth] = useState(() => getInitialVisibleMonth(value));
const rootRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const parsed = parseYmd(value);
if (!parsed) {
return;
}
setVisibleMonth({ year: parsed.year, monthIndex: parsed.monthIndex });
}, [value]);
useEffect(() => {
if (!isOpen) {
return;
}
function handlePointerDown(event: MouseEvent): void {
if (rootRef.current?.contains(event.target as Node)) {
return;
}
setIsOpen(false);
}
function handleKeyDown(event: KeyboardEvent): void {
if (event.key === "Escape") {
setIsOpen(false);
}
}
document.addEventListener("mousedown", handlePointerDown);
document.addEventListener("keydown", handleKeyDown);
return () => {
document.removeEventListener("mousedown", handlePointerDown);
document.removeEventListener("keydown", handleKeyDown);
};
}, [isOpen]);
const selected = parseYmd(value);
const todayYmd = toYmd(new Date().getFullYear(), new Date().getMonth(), new Date().getDate());
const cells = useMemo(
() => buildMonthCells(visibleMonth.year, visibleMonth.monthIndex),
[visibleMonth],
);
const monthTitle = `${MONTH_NAMES_RU[visibleMonth.monthIndex]} ${visibleMonth.year}`;
function shiftMonth(delta: number): void {
setVisibleMonth((prev) => {
const next = new Date(Date.UTC(prev.year, prev.monthIndex + delta, 1));
return { year: next.getUTCFullYear(), monthIndex: next.getUTCMonth() };
});
}
return (
<div className="date-picker" ref={rootRef}>
<div className="date-picker__control">
<input
className="race-form__input date-picker__input"
type="text"
inputMode="numeric"
name={name}
value={value}
onChange={(event) => {
onChange(event.target.value);
}}
onFocus={() => setIsOpen(true)}
placeholder="2026-05-03"
autoComplete="off"
required={required}
/>
<button
className="date-picker__toggle"
type="button"
aria-label="Открыть календарь"
aria-expanded={isOpen}
onClick={() => setIsOpen((prev) => !prev)}
>
</button>
</div>
{isOpen ? (
<div className="date-picker__popover" role="dialog" aria-label="Выбор даты">
<div className="date-picker__header">
<button
className="date-picker__nav"
type="button"
aria-label="Предыдущий месяц"
onClick={() => shiftMonth(-1)}
>
</button>
<p className="date-picker__title">{monthTitle}</p>
<button
className="date-picker__nav"
type="button"
aria-label="Следующий месяц"
onClick={() => shiftMonth(1)}
>
</button>
</div>
<div className="date-picker__weekdays" aria-hidden>
{WEEKDAY_LABELS_SHORT_RU.map((weekday) => (
<span key={weekday} className="date-picker__weekday">
{weekday}
</span>
))}
</div>
<div className="date-picker__cells">
{cells.map((day, idx) => {
if (day === null) {
return <span key={`empty-${idx}`} className="date-picker__cell date-picker__cell--empty" />;
}
const ymd = toYmd(visibleMonth.year, visibleMonth.monthIndex, day);
const isSelected =
selected?.year === visibleMonth.year &&
selected.monthIndex === visibleMonth.monthIndex &&
selected.day === day;
return (
<button
key={ymd}
className={`date-picker__day${isSelected ? " date-picker__day--selected" : ""}${ymd === todayYmd ? " date-picker__day--today" : ""}`}
type="button"
onClick={() => {
onChange(ymd);
setIsOpen(false);
}}
>
{day}
</button>
);
})}
</div>
</div>
) : null}
</div>
);
}

View File

@@ -1,92 +1,107 @@
import { useState } from "react";
import { useNavigate } from "react-router-dom";
import type { Race } from "../api";
import { formatRaceDate, isCloseDistance, parseFinishTimeToSeconds, parseRaceDate } from "../lib";
import { formatRaceDate, getPaceLabel, isCloseDistance, parseFinishTimeToSeconds, parseRaceDate } from "../lib";
type PaceTrendChartProps = {
races: Race[];
distanceKm: number;
};
/** Линейный график: время финиша (минуты) по завершённым стартам выбранной дистанции. */
export function PaceTrendChart(props: PaceTrendChartProps): JSX.Element {
const { races, distanceKm } = props;
function formatAxisTime(minutes: number): string {
const totalSeconds = Math.round(minutes * 60);
const hours = Math.floor(totalSeconds / 3600);
const rest = totalSeconds % 3600;
return hours ? `${hours}:${String(Math.floor(rest / 60)).padStart(2, "0")}` : `${Math.floor(rest / 60)}:${String(rest % 60).padStart(2, "0")}`;
}
function formatShortDate(date: string): string {
return parseRaceDate(date).toLocaleDateString("ru-RU", { day: "2-digit", month: "2-digit" });
}
/** Линейный график: время финиша по завершённым стартам выбранной дистанции. */
export function PaceTrendChart({ races, distanceKm }: PaceTrendChartProps): JSX.Element {
const navigate = useNavigate();
const [activeId, setActiveId] = useState<string | null>(null);
const series = races
.filter(
(race) =>
race.status === "completed" &&
isCloseDistance(race.distanceKm, distanceKm) &&
parseFinishTimeToSeconds(race.finishTime) != null,
)
.sort(
(a, b) => parseRaceDate(a.date).getTime() - parseRaceDate(b.date).getTime(),
)
.map((race) => {
const seconds = parseFinishTimeToSeconds(race.finishTime)!;
return { race, minutes: seconds / 60 };
});
.filter((race) => race.status === "completed" && isCloseDistance(race.distanceKm, distanceKm) && parseFinishTimeToSeconds(race.finishTime) != null)
.sort((a, b) => parseRaceDate(a.date).getTime() - parseRaceDate(b.date).getTime())
.map((race) => ({ race, minutes: parseFinishTimeToSeconds(race.finishTime)! / 60 }));
if (series.length < 2) {
return (
<p className="pace-chart__empty">
Нужно минимум два завершённых старта с временем на выбранной дистанции.
</p>
);
return <p className="pace-chart__empty">Нужно минимум два завершённых старта с временем на выбранной дистанции.</p>;
}
const minutes = series.map((s) => s.minutes);
const minM = Math.min(...minutes);
const maxM = Math.max(...minutes);
const range = maxM - minM || 1;
const n = series.length;
const pad = 4;
const values = series.map((item) => item.minutes);
const min = Math.min(...values);
const max = Math.max(...values);
const range = max - min || 1;
const w = 100;
const h = 36;
const innerW = w - pad * 2;
const innerH = h - pad * 2;
const points = series
.map((s, i) => {
const x = pad + (n === 1 ? innerW / 2 : (i / (n - 1)) * innerW);
const norm = (maxM - s.minutes) / range;
const y = pad + (1 - norm) * innerH;
return `${x},${y}`;
})
.join(" ");
const last = series[series.length - 1]!;
const best = series.reduce((currentBest, item) => (item.minutes < currentBest.minutes ? item : currentBest), series[0]!);
const dotPoints = series.map((s, i) => {
const x = pad + (n === 1 ? innerW / 2 : (i / (n - 1)) * innerW);
const norm = (maxM - s.minutes) / range;
const y = pad + (1 - norm) * innerH;
return { x, y, id: s.race.id };
const h = 58;
const margin = { top: 6, right: 4, bottom: 14, left: 17 };
const innerW = w - margin.left - margin.right;
const innerH = h - margin.top - margin.bottom;
const pointAt = (item: (typeof series)[number], index: number) => ({
x: margin.left + (index / (series.length - 1)) * innerW,
y: margin.top + ((max - item.minutes) / range) * innerH,
});
const points = series.map(pointAt);
const line = points.map(({ x, y }) => `${x},${y}`).join(" ");
const yTicks = [max, (max + min) / 2, min];
const labelStep = Math.max(1, Math.ceil(series.length / 6));
const active = series.find((item) => item.race.id === activeId) ?? null;
return (
<div className="pace-chart">
<svg className="pace-chart__svg" viewBox={`0 0 ${w} ${h}`} role="img" aria-label="Динамика времени на дистанции">
<line className="pace-chart__grid-line" x1={pad} y1={pad} x2={w - pad} y2={pad} />
<line className="pace-chart__grid-line" x1={pad} y1={h - pad} x2={w - pad} y2={h - pad} />
<polyline className="pace-chart__line" fill="none" points={points} />
{dotPoints.map((point, index) => (
<circle
key={point.id}
className={index === dotPoints.length - 1 ? "pace-chart__dot pace-chart__dot--last" : "pace-chart__dot"}
cx={point.x}
cy={point.y}
r="1.6"
/>
))}
<svg className="pace-chart__svg" viewBox={`0 0 ${w} ${h}`} role="group" aria-label="Динамика времени на дистанции. Нажмите точку, чтобы открыть старт.">
<text className="pace-chart__axis-title" x="2" y={h / 2} transform={`rotate(-90 2 ${h / 2})`}>Время</text>
{yTicks.map((tick) => {
const y = margin.top + ((max - tick) / range) * innerH;
return (
<g key={tick}>
<line className="pace-chart__grid-line" x1={margin.left} y1={y} x2={w - margin.right} y2={y} />
<text className="pace-chart__axis-label" x={margin.left - 2} y={y + 1} textAnchor="end">{formatAxisTime(tick)}</text>
</g>
);
})}
<line className="pace-chart__axis" x1={margin.left} y1={margin.top} x2={margin.left} y2={h - margin.bottom} />
<line className="pace-chart__axis" x1={margin.left} y1={h - margin.bottom} x2={w - margin.right} y2={h - margin.bottom} />
<polyline className="pace-chart__line" fill="none" points={line} />
{series.map((item, index) => {
const point = points[index]!;
const isLabeled = index % labelStep === 0 || index === series.length - 1;
return (
<g
key={item.race.id}
className="pace-chart__point"
tabIndex={0}
role="button"
aria-label={`Открыть ${item.race.title}: ${item.race.finishTime}, ${getPaceLabel(item.race.finishTime, item.race.distanceKm)}`}
onMouseEnter={() => setActiveId(item.race.id)}
onMouseLeave={() => setActiveId(null)}
onFocus={() => setActiveId(item.race.id)}
onBlur={() => setActiveId(null)}
onClick={() => navigate(`/races/${item.race.id}`)}
onKeyDown={(event) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
navigate(`/races/${item.race.id}`);
}
}}
>
<circle className={index === series.length - 1 ? "pace-chart__dot pace-chart__dot--last" : "pace-chart__dot"} cx={point.x} cy={point.y} r="1.05" />
{isLabeled ? <text className="pace-chart__point-label" x={point.x} y={h - margin.bottom + 5} textAnchor="middle">{formatShortDate(item.race.date)}</text> : null}
</g>
);
})}
<text className="pace-chart__axis-title" x={w / 2} y={h - 1} textAnchor="middle">Дата старта</text>
</svg>
<div className="pace-chart__stats">
<p className="pace-chart__caption">
Последний: {formatRaceDate(last.race.date)} · {last.race.finishTime} · {last.minutes.toFixed(1)} мин
</p>
<p className="pace-chart__caption pace-chart__caption--best">
Лучший: {formatRaceDate(best.race.date)} · {best.race.finishTime} · {best.minutes.toFixed(1)} мин
</p>
</div>
{active ? (
<div className="pace-chart__tooltip" role="status">
<strong>{active.race.title}</strong>
<span>{formatRaceDate(active.race.date)} · {active.race.finishTime} · {getPaceLabel(active.race.finishTime, active.race.distanceKm)}</span>
</div>
) : <p className="pace-chart__hint">Наведите на точку или выберите её клавиатурой.</p>}
</div>
);
}

View File

@@ -1,157 +0,0 @@
import { useCallback, useMemo } from "react";
function pad2(n: number): string {
return String(n).padStart(2, "0");
}
function parseToParts(value: string): { h: number | null; m: number | null; s: number | null } {
const t = value.trim();
if (!t) {
return { h: null, m: null, s: null };
}
const parts = t.split(":").map((p) => p.trim());
if (parts.length === 2) {
const h = Number(parts[0]);
const m = Number(parts[1]);
if (Number.isInteger(h) && Number.isInteger(m) && h >= 0 && h <= 23 && m >= 0 && m <= 59) {
return { h, m, s: 0 };
}
}
if (parts.length >= 3) {
const h = Number(parts[0]);
const m = Number(parts[1]);
const s = Number(parts[2]);
if (
Number.isInteger(h) &&
Number.isInteger(m) &&
Number.isInteger(s) &&
h >= 0 &&
h <= 23 &&
m >= 0 &&
m <= 59 &&
s >= 0 &&
s <= 59
) {
return { h, m, s };
}
}
return { h: null, m: null, s: null };
}
function partsToString(h: number | null, m: number | null, s: number | null): string {
if (h === null || m === null || s === null) {
return "";
}
return `${pad2(h)}:${pad2(m)}:${pad2(s)}`;
}
const HOURS = Array.from({ length: 24 }, (_, i) => i);
const MIN_SEC = Array.from({ length: 60 }, (_, i) => i);
interface StartTimeSelectsProps {
value: string;
onChange: (next: string) => void;
disabled?: boolean;
}
export function StartTimeSelects(props: StartTimeSelectsProps): JSX.Element {
const { value, onChange, disabled } = props;
const { h, m, s } = useMemo(() => parseToParts(value), [value]);
const emit = useCallback(
(nextH: number | null, nextM: number | null, nextS: number | null) => {
onChange(partsToString(nextH, nextM, nextS));
},
[onChange],
);
const hourVal = h === null ? "" : String(h);
const minVal = m === null ? "" : String(m);
const secVal = s === null ? "" : String(s);
return (
<div className="race-form__time-picker">
<label className="race-form__time-picker__unit">
<span className="race-form__time-picker__label">Часы</span>
<select
className="race-form__input race-form__time-picker__select"
aria-label="Часы"
disabled={disabled}
value={hourVal}
onChange={(e) => {
const v = e.target.value;
if (v === "") {
emit(null, null, null);
return;
}
const nh = Number(v);
emit(nh, m ?? 0, s ?? 0);
}}
>
<option value=""></option>
{HOURS.map((n) => (
<option key={n} value={String(n)}>
{pad2(n)}
</option>
))}
</select>
</label>
<span className="race-form__time-picker__sep" aria-hidden>
:
</span>
<label className="race-form__time-picker__unit">
<span className="race-form__time-picker__label">Минуты</span>
<select
className="race-form__input race-form__time-picker__select"
aria-label="Минуты"
disabled={disabled}
value={minVal}
onChange={(e) => {
const v = e.target.value;
if (v === "") {
emit(null, null, null);
return;
}
const nm = Number(v);
emit(h ?? 0, nm, s ?? 0);
}}
>
<option value=""></option>
{MIN_SEC.map((n) => (
<option key={n} value={String(n)}>
{pad2(n)}
</option>
))}
</select>
</label>
<span className="race-form__time-picker__sep" aria-hidden>
:
</span>
<label className="race-form__time-picker__unit">
<span className="race-form__time-picker__label">Секунды</span>
<select
className="race-form__input race-form__time-picker__select"
aria-label="Секунды"
disabled={disabled}
value={secVal}
onChange={(e) => {
const v = e.target.value;
if (v === "") {
emit(null, null, null);
return;
}
const ns = Number(v);
emit(h ?? 0, m ?? 0, ns);
}}
>
<option value=""></option>
{MIN_SEC.map((n) => (
<option key={n} value={String(n)}>
{pad2(n)}
</option>
))}
</select>
</label>
</div>
);
}

View File

@@ -1,4 +1,2 @@
export { DatePickerField } from "./DatePickerField";
export { PaceTrendChart } from "./PaceTrendChart";
export { RacesCalendar } from "./RacesCalendar";
export { StartTimeSelects } from "./StartTimeSelects";

View File

@@ -1,6 +1,7 @@
export {
formatDistance,
formatRaceDate,
findLatestPersonalRecord,
getPaceLabel,
getRaceCountdownLabel,
getRaceStatusClassName,

View File

@@ -24,18 +24,29 @@ export function parseFinishTimeToSeconds(value: string | null): number | null {
return null;
}
const parts = value.split(":").map((part) => Number(part));
if (parts.some((part) => Number.isNaN(part) || part < 0)) {
const parts = value.split(":");
if (parts.some((part) => !/^\d+$/.test(part))) {
return null;
}
const values = parts.map(Number);
if (parts.length === 2) {
const [minutes, seconds] = parts;
if (values.length === 1) {
return values[0]! <= 59 ? values[0]! : null;
}
if (values.length === 2) {
const [minutes, seconds] = values;
if (minutes! > 59 || seconds! > 59) {
return null;
}
return minutes * 60 + seconds;
}
if (parts.length === 3) {
const [hours, minutes, seconds] = parts;
if (values.length === 3) {
const [hours, minutes, seconds] = values;
if (minutes! > 59 || seconds! > 59) {
return null;
}
return hours * 3600 + minutes * 60 + seconds;
}
@@ -62,6 +73,37 @@ export function sortByDateDesc(races: Race[]): Race[] {
return [...races].sort((left, right) => parseRaceDate(right.date).getTime() - parseRaceDate(left.date).getTime());
}
/** Последний результат, который улучшил уже существовавший личный рекорд на сопоставимой дистанции. */
export function findLatestPersonalRecord(races: Race[]): Race | null {
const bestTimes: Array<{ distanceKm: number; seconds: number }> = [];
let latestRecord: Race | null = null;
for (const race of sortByDateAsc(races)) {
if (race.status !== "completed") {
continue;
}
const seconds = parseFinishTimeToSeconds(race.finishTime);
if (seconds === null) {
continue;
}
// ponytail: personal calendars are small; introduce a distance index only if profiling requires it.
const comparable = bestTimes.filter((best) => isCloseDistance(best.distanceKm, race.distanceKm));
if (comparable.length > 0 && seconds < Math.min(...comparable.map((best) => best.seconds))) {
latestRecord = race;
}
if (comparable.length === 0) {
bestTimes.push({ distanceKm: race.distanceKm, seconds });
} else {
comparable.forEach((best) => {
best.seconds = Math.min(best.seconds, seconds);
});
}
}
return latestRecord;
}
export function splitRacesByDate(races: Race[], now: Date = new Date()): { upcoming: Race[]; past: Race[] } {
const today = new Date(now);
today.setHours(0, 0, 0, 0);

View File

@@ -11,13 +11,6 @@ interface RaceVisual {
label: string;
}
interface OfficialRaceVisual {
keywords: string[];
imageSrc: string;
imageFit?: RaceVisualFit;
label: string;
}
const FALLBACK_VISUALS: Record<RaceVisualVariant, RaceVisual> = {
short: {
variant: "short",
@@ -56,109 +49,8 @@ const FALLBACK_VISUALS: Record<RaceVisualVariant, RaceVisual> = {
},
};
const OFFICIAL_VISUALS: OfficialRaceVisual[] = [
{
keywords: ["забег апрель"],
imageSrc: "https://aprilrun5km.runc.run/uploads/page_card_photos/AprilRun_photo_1.jpg",
label: "Забег Апрель",
},
{
keywords: ["быстрый пес"],
imageSrc: "https://fastdogxc.runc.run/uploads/page_card_photos/Dog_spring_2026-5.jpg",
label: "Кросс",
},
{
keywords: ["лисья гора"],
imageSrc: "https://foxhillxc.runc.run/uploads/page_card_photos/Fox_Spring_2026-0.jpg",
label: "Кросс",
},
{
keywords: ["казанский марафон"],
imageSrc: "https://static.tildacdn.com/tild3961-6436-4462-b738-356665613039/Frame_2131327895.png",
imageFit: "contain",
label: "Казанский марафон",
},
{
keywords: ["мышкинский полумарафон", "по шести холмам"],
imageSrc: "https://static.tildacdn.com/tild6133-6137-4865-b166-623532313531/photo.jpg",
label: "Золотое кольцо",
},
{
keywords: ["забег.рф", "забег рф"],
imageSrc: "https://xn--80acghh.xn--p1ai/zabeg.jpg",
label: "ЗаБег.РФ",
},
{
keywords: ["переславский марафон", "александровские версты"],
imageSrc: "https://static.tildacdn.com/tild6432-3338-4533-b262-633339353335/photo_1.jpg",
label: "Золотое кольцо",
},
{
keywords: ["красочный забег"],
imageSrc: "https://colorrun5km.runc.run/uploads/page_card_photos/ColorRun2026-1.jpg",
label: "Красочный забег",
},
{
keywords: ["здорово кострома", "здорово, кострома"],
imageSrc: "https://static.tildacdn.com/tild6139-3539-4661-b232-386230336431/kostroma.jpg",
label: "Золотое кольцо",
},
{
keywords: ["ночной забег москва"],
imageSrc: "https://nightrun10km.runc.run/uploads/page_card_photos/NightRun_2026-9.jpg",
label: "Ночной забег",
},
{
keywords: ["белые ночи"],
imageSrc: "https://wnmarathon.runc.run/uploads/page_card_photos/WN_photo_01.jpg",
label: "Белые ночи",
},
{
keywords: ["сергиевым путем", "сергиевым путём"],
imageSrc: "https://static.tildacdn.com/tild6236-3466-4239-b666-393061326338/serg.jpg",
label: "Золотое кольцо",
},
{
keywords: ["ночной забег нижний новгород"],
imageSrc: "https://rrweb.russiarunning.com/-x740/generalimages/0531a1b8-3876-4620-8961-2fa374e474e5.png",
imageFit: "contain",
label: "Ночной забег",
},
{
keywords: ["suvorov extreme"],
imageSrc: "https://goldenultra.ru/grut/files/photos/100.jpg",
label: "Трейл",
},
{
keywords: ["рыбинский полумарафон", "великий хлебный путь"],
imageSrc: "https://static.tildacdn.com/tild6130-3230-4332-b932-366166366633/photo.jpg",
label: "Золотое кольцо",
},
{
keywords: ["ярославский полумарафон", "золотое кольцо"],
imageSrc: "https://static.tildacdn.com/tild6331-6333-4635-b635-376262373361/photo.jpg",
label: "Золотое кольцо",
},
{
keywords: ["моя столица"],
imageSrc: "https://static.tildacdn.com/tild3263-3036-4639-b830-653365663832/-min.jpg",
imageFit: "contain",
label: "Моя столица",
},
];
function normalizeTitle(value: string): string {
return value
.toLowerCase()
.replaceAll("ё", "е")
.replace(/[«»|]/g, " ")
.replace(/[^\p{L}\p{N}.&]+/gu, " ")
.replace(/\s+/g, " ")
.trim();
}
function getFallbackRaceVisual(race: Race): RaceVisual {
const title = normalizeTitle(race.title);
const title = race.title.toLowerCase();
if (title.includes("ночной")) {
return FALLBACK_VISUALS.night;
@@ -196,20 +88,5 @@ export function getRaceVisual(race: Race): RaceVisual {
};
}
const title = normalizeTitle(race.title);
const official = OFFICIAL_VISUALS.find((visual) =>
visual.keywords.some((keyword) => title.includes(normalizeTitle(keyword))),
);
if (!official) {
return fallback;
}
return {
...fallback,
imageSrc: official.imageSrc,
fallbackSrc: fallback.imageSrc,
imageFit: official.imageFit ?? fallback.imageFit,
label: official.label,
};
return fallback;
}

View File

@@ -1,12 +1,15 @@
import React from "react";
import ReactDOM from "react-dom/client";
import { RouterProvider } from "react-router-dom";
import { AuthProvider } from "./app/auth/AuthContext";
import { appRouter } from "./app/router";
import "./styles/tokens.css";
import "./styles/global.css";
ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(
<React.StrictMode>
<RouterProvider router={appRouter} />
<AuthProvider>
<RouterProvider router={appRouter} />
</AuthProvider>
</React.StrictMode>
);

View File

@@ -0,0 +1,131 @@
import { useCallback, useEffect, useState } from "react";
import { ApiError, changePassword, getSessions, revokeOtherSessions, revokeSession } from "../api";
import type { AuthSession } from "../api";
import { useAuth } from "../app/auth/AuthContext";
function formatDate(value: string): string {
return new Intl.DateTimeFormat("ru-RU", { dateStyle: "medium", timeStyle: "short" }).format(new Date(value));
}
export function AccountPage(): JSX.Element {
const { user, logout } = useAuth();
const [sessions, setSessions] = useState<AuthSession[]>([]);
const [sessionsError, setSessionsError] = useState("");
const [sessionsLoading, setSessionsLoading] = useState(true);
const [currentPassword, setCurrentPassword] = useState("");
const [newPassword, setNewPassword] = useState("");
const [passwordConfirmation, setPasswordConfirmation] = useState("");
const [passwordError, setPasswordError] = useState("");
const [passwordSuccess, setPasswordSuccess] = useState("");
const [passwordSaving, setPasswordSaving] = useState(false);
const loadSessions = useCallback(async () => {
setSessionsLoading(true);
setSessionsError("");
try {
setSessions(await getSessions());
} catch (error) {
setSessionsError(error instanceof ApiError ? error.message : "Не удалось загрузить активные сессии.");
} finally {
setSessionsLoading(false);
}
}, []);
useEffect(() => {
void loadSessions();
}, [loadSessions]);
async function handlePasswordSubmit(event: React.FormEvent<HTMLFormElement>): Promise<void> {
event.preventDefault();
setPasswordError("");
setPasswordSuccess("");
if (newPassword !== passwordConfirmation) {
setPasswordError("Новый пароль и его повтор не совпадают.");
return;
}
setPasswordSaving(true);
try {
await changePassword({ currentPassword, newPassword });
setCurrentPassword("");
setNewPassword("");
setPasswordConfirmation("");
setPasswordSuccess("Пароль изменён. Остальные активные сессии завершены.");
await loadSessions();
} catch (error) {
setPasswordError(error instanceof ApiError ? error.message : "Не удалось изменить пароль.");
} finally {
setPasswordSaving(false);
}
}
async function handleSessionRevoke(session: AuthSession): Promise<void> {
try {
await revokeSession(session.id);
if (session.current) {
await logout();
return;
}
await loadSessions();
} catch (error) {
setSessionsError(error instanceof ApiError ? error.message : "Не удалось завершить сессию.");
}
}
async function handleOtherSessionsRevoke(): Promise<void> {
try {
await revokeOtherSessions();
await loadSessions();
} catch (error) {
setSessionsError(error instanceof ApiError ? error.message : "Не удалось завершить сессии.");
}
}
return (
<section className="page page--auth">
<h1 className="page__title">Аккаунт</h1>
<p className="page__subtitle">{user?.email} email подтверждён.</p>
<form className="auth-form" onSubmit={(event) => void handlePasswordSubmit(event)}>
<h2 className="auth-form__title">Сменить пароль</h2>
<label className="auth-form__field">
Текущий пароль
<input className="auth-form__input" required type="password" value={currentPassword} onChange={(event) => setCurrentPassword(event.target.value)} />
</label>
<label className="auth-form__field">
Новый пароль
<input className="auth-form__input" required minLength={15} type="password" value={newPassword} onChange={(event) => setNewPassword(event.target.value)} />
</label>
<label className="auth-form__field">
Повторите новый пароль
<input className="auth-form__input" required minLength={15} type="password" value={passwordConfirmation} onChange={(event) => setPasswordConfirmation(event.target.value)} />
</label>
{passwordError ? <p className="page__subtitle page__subtitle--error" role="alert">{passwordError}</p> : null}
{passwordSuccess ? <p className="account__success" role="status">{passwordSuccess}</p> : null}
<button className="btn" disabled={passwordSaving} type="submit">{passwordSaving ? "Сохраняем…" : "Сменить пароль"}</button>
</form>
<section className="auth-form" aria-labelledby="sessions-title">
<h2 className="auth-form__title" id="sessions-title">Активные сессии</h2>
<p className="page__subtitle">После смены пароля все остальные сессии завершаются автоматически.</p>
{sessionsError ? <p className="page__subtitle page__subtitle--error" role="alert">{sessionsError}</p> : null}
{sessionsLoading ? <p>Загружаем сессии</p> : null}
{!sessionsLoading ? (
<ul className="account__sessions">
{sessions.map((session) => (
<li className="account__session" key={session.id}>
<div>
<strong>{session.current ? "Текущая сессия" : "Активная сессия"}</strong>
<p>Последняя активность: {formatDate(session.lastSeenAt)}</p>
<p>Создана: {formatDate(session.createdAt)}</p>
</div>
{!session.current ? <button className="btn btn--secondary" type="button" onClick={() => void handleSessionRevoke(session)}>Завершить</button> : null}
</li>
))}
</ul>
) : null}
{sessions.some((session) => !session.current) ? <button className="btn btn--secondary" type="button" onClick={() => void handleOtherSessionsRevoke()}>Завершить все остальные</button> : null}
</section>
</section>
);
}

View File

@@ -0,0 +1,328 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { Link, Navigate, useLocation, useNavigate, useSearchParams } from "react-router-dom";
import {
ApiError,
forgotPassword,
register,
resendVerification,
resetPassword,
verifyEmail,
} from "../api";
import { useAuth } from "../app/auth/AuthContext";
function errorMessage(error: unknown, fallback: string): string {
if (error instanceof ApiError) {
return error.details.length > 0 ? error.details.join("; ") : error.message;
}
return fallback;
}
function TurnstileField(props: { onToken(token: string): void }): JSX.Element {
const ref = useRef<HTMLDivElement | null>(null);
const siteKey = import.meta.env.VITE_TURNSTILE_SITE_KEY;
const { onToken } = props;
useEffect(() => {
if (!siteKey || !ref.current) {
onToken("mock-turnstile-token");
return;
}
const scriptId = "turnstile-script";
if (!document.getElementById(scriptId)) {
const script = document.createElement("script");
script.id = scriptId;
script.src = "https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit";
script.async = true;
script.defer = true;
document.head.appendChild(script);
}
let widgetId: string | null = null;
const timer = window.setInterval(() => {
if (window.turnstile && ref.current && !widgetId) {
widgetId = window.turnstile.render(ref.current, {
sitekey: siteKey,
callback: onToken,
"expired-callback": () => onToken(""),
});
window.clearInterval(timer);
}
}, 100);
return () => {
window.clearInterval(timer);
if (widgetId && window.turnstile) {
window.turnstile.remove(widgetId);
}
};
}, [onToken, siteKey]);
return <div className="auth-form__captcha" ref={ref} />;
}
export function LoginPage(): JSX.Element {
const { user, login } = useAuth();
const navigate = useNavigate();
const location = useLocation();
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState<string | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false);
if (user?.emailVerifiedAt) {
return <Navigate to="/" replace />;
}
const handleSubmit = async (event: React.FormEvent) => {
event.preventDefault();
setIsSubmitting(true);
setError(null);
try {
await login(email, password);
const from = (location.state as { from?: Location } | null)?.from?.pathname ?? "/";
navigate(from, { replace: true });
} catch (err) {
setError(errorMessage(err, "Не удалось войти."));
} finally {
setIsSubmitting(false);
}
};
return (
<section className="page page--auth">
<h1 className="page__title">Вход</h1>
<form className="auth-form" onSubmit={handleSubmit}>
<label className="auth-form__field">
<span className="auth-form__label">Email</span>
<input className="auth-form__input" type="email" value={email} onChange={(e) => setEmail(e.target.value)} required />
</label>
<label className="auth-form__field">
<span className="auth-form__label">Пароль</span>
<input className="auth-form__input" type="password" value={password} onChange={(e) => setPassword(e.target.value)} required />
</label>
{error ? <p className="page__subtitle page__subtitle--error">{error}</p> : null}
<button className="btn btn--primary" type="submit" disabled={isSubmitting}>
Войти
</button>
</form>
<p className="auth-form__links">
<Link className="page-link" to="/register">Регистрация</Link>
<Link className="page-link" to="/forgot-password">Забыли пароль?</Link>
</p>
</section>
);
}
export function RegisterPage(): JSX.Element {
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [turnstileToken, setTurnstileToken] = useState("");
const [message, setMessage] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false);
const handleToken = useCallback((token: string) => setTurnstileToken(token), []);
const handleSubmit = async (event: React.FormEvent) => {
event.preventDefault();
setError(null);
setMessage(null);
setIsSubmitting(true);
try {
await register({ email, password, turnstileToken });
setMessage("Проверьте почту: мы отправили ссылку для подтверждения email.");
} catch (err) {
setError(errorMessage(err, "Не удалось зарегистрироваться."));
} finally {
setIsSubmitting(false);
}
};
return (
<section className="page page--auth">
<h1 className="page__title">Регистрация</h1>
<form className="auth-form" onSubmit={handleSubmit}>
<label className="auth-form__field">
<span className="auth-form__label">Email</span>
<input className="auth-form__input" type="email" value={email} onChange={(e) => setEmail(e.target.value)} required />
</label>
<label className="auth-form__field">
<span className="auth-form__label">Пароль</span>
<input className="auth-form__input" type="password" minLength={15} value={password} onChange={(e) => setPassword(e.target.value)} required />
</label>
<TurnstileField onToken={handleToken} />
{message ? <p className="page__subtitle">{message}</p> : null}
{error ? <p className="page__subtitle page__subtitle--error">{error}</p> : null}
<button className="btn btn--primary" type="submit" disabled={!turnstileToken || isSubmitting}>
{isSubmitting ? "Создаём аккаунт…" : "Создать аккаунт"}
</button>
</form>
<p className="auth-form__links">
<Link className="page-link" to="/login">Уже есть аккаунт</Link>
</p>
</section>
);
}
export function VerifyEmailPage(): JSX.Element {
const { user, refresh } = useAuth();
const [params] = useSearchParams();
const [email, setEmail] = useState(user?.email ?? "");
const [message, setMessage] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [isVerifying, setIsVerifying] = useState(false);
const [isVerified, setIsVerified] = useState(false);
const [isResending, setIsResending] = useState(false);
useEffect(() => {
const token = params.get("token");
if (!token) {
return;
}
let isMounted = true;
setIsVerifying(true);
setError(null);
void (async () => {
try {
await verifyEmail(token);
try {
await refresh();
} catch {
// Подтверждение уже выполнено; обновление сессии не должно скрывать успех.
}
if (isMounted) {
setIsVerified(true);
}
} catch (err) {
if (isMounted) {
setError(errorMessage(err, "Ссылка недействительна."));
}
} finally {
if (isMounted) {
setIsVerifying(false);
}
}
})();
return () => {
isMounted = false;
};
}, [params, refresh]);
const resend = async () => {
setError(null);
setMessage(null);
setIsResending(true);
try {
await resendVerification(email);
setMessage("Если email зарегистрирован и ещё не подтверждён, письмо отправлено.");
} catch (err) {
setError(errorMessage(err, "Не удалось отправить письмо повторно."));
} finally {
setIsResending(false);
}
};
if (isVerified) {
return (
<section className="page page--auth">
<h1 className="page__title">Email подтверждён</h1>
<p className="page__subtitle">Теперь можно пользоваться календарём.</p>
<p className="auth-form__links"><Link className="page-link" to="/">Перейти к календарю</Link></p>
</section>
);
}
return (
<section className="page page--auth">
<h1 className="page__title">Подтверждение email</h1>
<p className="page__subtitle">{isVerifying ? "Проверяем ссылку…" : "Для доступа к календарю подтвердите email по ссылке из письма."}</p>
<form className="auth-form" onSubmit={(e) => { e.preventDefault(); void resend(); }}>
<label className="auth-form__field">
<span className="auth-form__label">Email</span>
<input className="auth-form__input" type="email" value={email} onChange={(e) => setEmail(e.target.value)} required />
</label>
{message ? <p className="page__subtitle">{message}</p> : null}
{error ? <p className="page__subtitle page__subtitle--error">{error}</p> : null}
<button className="btn btn--primary" type="submit" disabled={isVerifying || isResending}>
{isResending ? "Отправляем…" : "Отправить письмо ещё раз"}
</button>
</form>
</section>
);
}
export function ForgotPasswordPage(): JSX.Element {
const [email, setEmail] = useState("");
const [message, setMessage] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false);
const handleSubmit = async (event: React.FormEvent) => {
event.preventDefault();
setError(null);
setMessage(null);
setIsSubmitting(true);
try {
await forgotPassword(email);
setMessage("Если email зарегистрирован, ссылка отправлена.");
} catch (err) {
setError(errorMessage(err, "Не удалось отправить ссылку."));
} finally {
setIsSubmitting(false);
}
};
return (
<section className="page page--auth">
<h1 className="page__title">Сброс пароля</h1>
<form className="auth-form" onSubmit={handleSubmit}>
<label className="auth-form__field">
<span className="auth-form__label">Email</span>
<input className="auth-form__input" type="email" value={email} onChange={(event) => setEmail(event.target.value)} required />
</label>
{message ? <p className="page__subtitle">{message}</p> : null}
{error ? <p className="page__subtitle page__subtitle--error" role="alert">{error}</p> : null}
<button className="btn btn--primary" type="submit" disabled={isSubmitting}>{isSubmitting ? "Отправляем…" : "Отправить ссылку"}</button>
</form>
</section>
);
}
export function ResetPasswordPage(): JSX.Element {
const [params] = useSearchParams();
const [password, setPassword] = useState("");
const [message, setMessage] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false);
const handleSubmit = async (event: React.FormEvent) => {
event.preventDefault();
setError(null);
setMessage(null);
setIsSubmitting(true);
try {
await resetPassword(params.get("token") ?? "", password);
setMessage("Пароль обновлён. Теперь войдите заново.");
} catch (err) {
setError(errorMessage(err, "Не удалось обновить пароль."));
} finally {
setIsSubmitting(false);
}
};
return (
<section className="page page--auth">
<h1 className="page__title">Новый пароль</h1>
<form className="auth-form" onSubmit={handleSubmit}>
<label className="auth-form__field">
<span className="auth-form__label">Пароль</span>
<input className="auth-form__input" type="password" minLength={15} value={password} onChange={(event) => setPassword(event.target.value)} required />
</label>
{message ? <p className="page__subtitle">{message}</p> : null}
{error ? <p className="page__subtitle page__subtitle--error" role="alert">{error}</p> : null}
<button className="btn btn--primary" type="submit" disabled={isSubmitting}>{isSubmitting ? "Сохраняем…" : "Сохранить пароль"}</button>
</form>
</section>
);
}

View File

@@ -1,12 +1,13 @@
import type { CSSProperties } from "react";
import { useEffect, useMemo, useState } from "react";
import { Link } from "react-router-dom";
import { Link, useNavigate } from "react-router-dom";
import type { Race } from "../api";
import { ApiError, getRaces } from "../api";
import { PaceTrendChart } from "../components";
import {
formatDistance,
formatRaceDate,
findLatestPersonalRecord,
getRaceCountdownLabel,
getRaceVisual,
getPaceLabel,
@@ -34,10 +35,14 @@ function toCssUrl(value: string): string {
}
export function DashboardPage(): JSX.Element {
const navigate = useNavigate();
const [races, setRaces] = useState<Race[]>([]);
const [isLoading, setIsLoading] = useState<boolean>(true);
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const [chartDistanceKm, setChartDistanceKm] = useState<number>(10);
const [latestDistance, setLatestDistance] = useState<string>("");
const [latestYear, setLatestYear] = useState<string>("");
const [latestPage, setLatestPage] = useState(0);
useEffect(() => {
const ac = new AbortController();
@@ -76,10 +81,7 @@ export function DashboardPage(): JSX.Element {
const nextRace = upcoming[0] ?? null;
const lastResult = past.find((race) => race.status === "completed") ?? null;
const lastPersonalRecord =
past.find(
(race) => race.status === "completed" && parseFinishTimeToSeconds(race.finishTime) !== null,
) ?? null;
const lastPersonalRecord = findLatestPersonalRecord(races);
const currentYear = new Date().getFullYear();
const seasonRaces = races.filter((race) => parseRaceDate(race.date).getFullYear() === currentYear);
@@ -132,6 +134,7 @@ export function DashboardPage(): JSX.Element {
.filter((race) => race.status === "completed")
.map((race) => ({
id: race.id,
date: race.date,
year: parseRaceDate(race.date).getFullYear(),
title: race.title,
distance: formatDistance(race.distanceKm),
@@ -139,9 +142,20 @@ export function DashboardPage(): JSX.Element {
pace: getPaceLabel(race.finishTime, race.distanceKm) ?? "не удалось вычислить",
place: race.finishPlace?.trim() ? race.finishPlace : "нет данных",
}))
.sort((left, right) => right.year - left.year || left.title.localeCompare(right.title, "ru-RU"));
.sort((left, right) => parseRaceDate(right.date).getTime() - parseRaceDate(left.date).getTime());
}, [races]);
const latestDistances = useMemo(
() => [...new Set(comparisonRows.map((row) => row.distance))].sort((left, right) => Number(left.split(" ")[0]) - Number(right.split(" ")[0])),
[comparisonRows],
);
const latestYears = useMemo(() => [...new Set(comparisonRows.map((row) => row.year))].sort((a, b) => b - a), [comparisonRows]);
const filteredLatestRows = comparisonRows.filter(
(row) => (!latestDistance || row.distance === latestDistance) && (!latestYear || row.year === Number(latestYear)),
);
const latestPageCount = Math.ceil(filteredLatestRows.length / 5);
const latestRows = filteredLatestRows.slice(latestPage * 5, latestPage * 5 + 5);
const seasonProgress =
dashboardMetrics.seasonTotal > 0
? Math.round((dashboardMetrics.seasonCompletedCount / dashboardMetrics.seasonTotal) * 100)
@@ -259,10 +273,8 @@ export function DashboardPage(): JSX.Element {
)}
</article>
<article
className={`dashboard-card dashboard-card--accent-lime${dashboardMetrics.lastPersonalRecord ? " dashboard-card--linked" : ""}`}
>
{dashboardMetrics.lastPersonalRecord ? (
{dashboardMetrics.lastPersonalRecord ? (
<article className="dashboard-card dashboard-card--accent-lime dashboard-card--linked">
<Link
className="dashboard-card__link-surface"
to={`/races/${dashboardMetrics.lastPersonalRecord.id}`}
@@ -278,13 +290,8 @@ export function DashboardPage(): JSX.Element {
</p>
<p className="dashboard-card__hint">{formatRaceDate(dashboardMetrics.lastPersonalRecord.date)}</p>
</Link>
) : (
<>
<h2 className="dashboard-card__title">Последний личный рекорд</h2>
<p className="dashboard-card__empty">Нет завершённых стартов с финишным временем.</p>
</>
)}
</article>
</article>
) : null}
<article className="dashboard-card dashboard-card--season dashboard-card--accent-violet">
<h2 className="dashboard-card__title">Сезон</h2>
@@ -325,26 +332,50 @@ export function DashboardPage(): JSX.Element {
<h2 className="dashboard-section__title">Рекорды по дистанциям</h2>
<div className="dashboard-grid dashboard-grid--pr">
{personalRecordsByDistance.map((item) => (
<article key={item.distanceKm} className="dashboard-card">
<h3 className="dashboard-card__title">{formatDistance(item.distanceKm)}</h3>
<article key={item.distanceKm} className={`dashboard-card${item.bestRace ? " dashboard-card--linked" : ""}`}>
{item.bestRace ? (
<>
<Link
className="dashboard-card__link-surface"
to={`/races/${item.bestRace.id}`}
aria-label={`Рекорд на ${formatDistance(item.distanceKm)}: ${item.bestRace.title}`}
>
<h3 className="dashboard-card__title">{formatDistance(item.distanceKm)}</h3>
<p className="dashboard-card__value">{item.bestRace.finishTime ?? "время не указано"}</p>
<p className="dashboard-card__meta">{item.bestRace.title}</p>
<p className="dashboard-card__hint">{formatRaceDate(item.bestRace.date)}</p>
</>
</Link>
) : (
<p className="dashboard-card__empty">Нет завершённых стартов для этой дистанции.</p>
<>
<h3 className="dashboard-card__title">{formatDistance(item.distanceKm)}</h3>
<p className="dashboard-card__empty">Нет завершённых стартов для этой дистанции.</p>
</>
)}
</article>
))}
</div>
</section>
<section className="dashboard-section" aria-label="Сравнение завершённых стартов">
<h2 className="dashboard-section__title">Сравнение стартов</h2>
<section className="dashboard-section" aria-label="Последние завершённые старты">
<h2 className="dashboard-section__title">Последние старты</h2>
{comparisonRows.length > 0 ? (
<div className="comparison-table-wrapper">
<>
<div className="dashboard-latest__filters" aria-label="Фильтры последних стартов">
<label className="races-filter__field">
<span className="races-filter__label">Дистанция</span>
<select className="races-filter__select" value={latestDistance} onChange={(event) => { setLatestDistance(event.target.value); setLatestPage(0); }}>
<option value="">Все дистанции</option>
{latestDistances.map((distance) => <option key={distance} value={distance}>{distance}</option>)}
</select>
</label>
<label className="races-filter__field">
<span className="races-filter__label">Год участия</span>
<select className="races-filter__select" value={latestYear} onChange={(event) => { setLatestYear(event.target.value); setLatestPage(0); }}>
<option value="">Все годы</option>
{latestYears.map((year) => <option key={year} value={year}>{year}</option>)}
</select>
</label>
</div>
<div className="comparison-table-wrapper">
<table className="comparison-table">
<thead>
<tr>
@@ -357,8 +388,20 @@ export function DashboardPage(): JSX.Element {
</tr>
</thead>
<tbody>
{comparisonRows.map((row) => (
<tr key={row.id}>
{latestRows.map((row) => (
<tr
key={row.id}
className="comparison-table__row"
tabIndex={0}
onClick={() => navigate(`/races/${row.id}`)}
onKeyDown={(event) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
navigate(`/races/${row.id}`);
}
}}
aria-label={`Открыть старт: ${row.title}`}
>
<td>{row.year}</td>
<td>{row.title}</td>
<td>{row.distance}</td>
@@ -369,7 +412,16 @@ export function DashboardPage(): JSX.Element {
))}
</tbody>
</table>
</div>
</div>
{filteredLatestRows.length === 0 ? <p className="dashboard-card__empty">По выбранным фильтрам стартов нет.</p> : null}
{latestPageCount > 1 ? (
<nav className="dashboard-latest__pagination" aria-label="Страницы последних стартов">
<button className="btn btn--secondary" type="button" disabled={latestPage === 0} onClick={() => setLatestPage((page) => page - 1)}>Назад</button>
<span aria-live="polite">Страница {latestPage + 1} из {latestPageCount}</span>
<button className="btn btn--secondary" type="button" disabled={latestPage === latestPageCount - 1} onClick={() => setLatestPage((page) => page + 1)}>Вперёд</button>
</nav>
) : null}
</>
) : (
<p className="dashboard-card__empty">Нет завершённых стартов для сравнения.</p>
)}

View File

@@ -1,9 +1,8 @@
import { useCallback, useEffect, useState } from "react";
import { Link, useNavigate, useParams, useSearchParams } from "react-router-dom";
import { ApiError, createRace, getRaceById, updateRace } from "../api";
import type { CreateRacePayload, Race, RaceStatus, UpdateRacePayload } from "../api";
import { DatePickerField, StartTimeSelects } from "../components";
import { isRaceDateInPast, parseFinishTimeToSeconds } from "../lib";
import type { CreateRacePayload, Race, RaceStatus } from "../api";
import { isRaceDateInPast } from "../lib";
function slugify(text: string): string {
return text
@@ -36,8 +35,13 @@ interface FormData {
clusterSchedule: string;
bibPickup: string;
bibNumber: string;
finishTime: string;
finishPlace: string;
finishPlaceTotal: string;
originalFinishPlace: string;
isFinishPlaceEdited: boolean;
finishHours: string;
finishMinutes: string;
finishSeconds: string;
notes: string;
}
@@ -52,8 +56,13 @@ const EMPTY_FORM: FormData = {
clusterSchedule: "",
bibPickup: "",
bibNumber: "",
finishTime: "",
finishPlace: "",
finishPlaceTotal: "",
originalFinishPlace: "",
isFinishPlaceEdited: false,
finishHours: "",
finishMinutes: "",
finishSeconds: "",
notes: "",
};
@@ -70,17 +79,88 @@ function raceToFormData(race: Race): FormData {
clusterSchedule: race.clusterSchedule ?? "",
bibPickup: race.bibPickup ?? "",
bibNumber: race.bibNumber ?? "",
finishTime: race.finishTime ?? "",
finishPlace: race.finishPlace ?? "",
...finishTimeToFormData(race.finishTime),
...finishPlaceToFormData(race.finishPlace),
notes: race.notes ?? "",
};
}
function finishTimeToFormData(
value: string | null,
): Pick<FormData, "finishHours" | "finishMinutes" | "finishSeconds"> {
const parts = value?.trim().split(":") ?? [];
if (parts.length === 3) {
return { finishHours: parts[0], finishMinutes: parts[1], finishSeconds: parts[2] };
}
if (parts.length === 2) {
return { finishHours: "", finishMinutes: parts[0], finishSeconds: parts[1] };
}
return { finishHours: "", finishMinutes: "", finishSeconds: parts[0] ?? "" };
}
function finishPlaceToFormData(
value: string | null,
): Pick<FormData, "finishPlace" | "finishPlaceTotal" | "originalFinishPlace" | "isFinishPlaceEdited"> {
const originalFinishPlace = value ?? "";
const [finishPlace = "", finishPlaceTotal = ""] = originalFinishPlace.split("/", 2);
return { finishPlace, finishPlaceTotal, originalFinishPlace, isFinishPlaceEdited: false };
}
function emptyToNull(value: string): string | null {
const trimmed = value.trim();
return trimmed === "" ? null : trimmed;
}
function parseNonNegativeInteger(value: string): number | null {
const trimmed = value.trim();
if (!/^\d+$/.test(trimmed)) {
return null;
}
return Number(trimmed);
}
function getFinishTime(form: FormData): { value: string | null; error: string | null } {
const hasValue = [form.finishHours, form.finishMinutes, form.finishSeconds].some((value) => value.trim() !== "");
if (!hasValue) {
return { value: null, error: null };
}
const hours = form.finishHours.trim() === "" ? null : parseNonNegativeInteger(form.finishHours);
const minutes = form.finishMinutes.trim() === "" ? 0 : parseNonNegativeInteger(form.finishMinutes);
const seconds = form.finishSeconds.trim() === "" ? 0 : parseNonNegativeInteger(form.finishSeconds);
if (hours === null && form.finishHours.trim() !== "") {
return { value: null, error: "Часы должны быть целым числом не меньше 0." };
}
if (minutes === null || seconds === null || minutes > 59 || seconds > 59) {
return { value: null, error: "Минуты и секунды должны быть целыми числами от 0 до 59." };
}
const pad = (value: number) => String(value).padStart(2, "0");
if (hours !== null) {
return { value: `${hours}:${pad(minutes)}:${pad(seconds)}`, error: null };
}
if (form.finishMinutes.trim() !== "") {
return { value: `${pad(minutes)}:${pad(seconds)}`, error: null };
}
return { value: pad(seconds), error: null };
}
function getFinishPlace(form: FormData): { value: string | null; error: string | null } {
if (!form.isFinishPlaceEdited) {
return { value: emptyToNull(form.originalFinishPlace), error: null };
}
if (form.finishPlace.trim() === "" && form.finishPlaceTotal.trim() === "") {
return { value: null, error: null };
}
const place = parseNonNegativeInteger(form.finishPlace);
const total = form.finishPlaceTotal.trim() === "" ? null : parseNonNegativeInteger(form.finishPlaceTotal);
if (place === null || place < 1 || (total !== null && (total < 1 || place > total))) {
return { value: null, error: "Место должно быть не меньше 1 и не больше числа участников." };
}
return { value: total === null ? String(place) : `${place}/${total}`, error: null };
}
function validateForm(form: FormData): string[] {
const errors: string[] = [];
if (!form.date.trim()) {
@@ -93,6 +173,14 @@ function validateForm(form: FormData): string[] {
if (Number.isNaN(km) || km <= 0) {
errors.push("Дистанция должна быть положительным числом.");
}
const finishTime = getFinishTime(form);
const finishPlace = getFinishPlace(form);
if (finishTime.error) {
errors.push(finishTime.error);
}
if (finishPlace.error) {
errors.push(finishPlace.error);
}
return errors;
}
@@ -167,12 +255,6 @@ export function RaceFormPage(): JSX.Element {
const { name, value } = event.target;
setForm((prev) => {
const next = { ...prev, [name]: value };
if (name === "finishTime") {
const trimmed = value.trim();
if (trimmed !== "" && parseFinishTimeToSeconds(trimmed) !== null) {
return { ...next, status: "completed" };
}
}
return next;
});
},
@@ -193,56 +275,38 @@ export function RaceFormPage(): JSX.Element {
setIsSaving(true);
try {
const finishTrimmed = form.finishTime.trim();
const hasParsedFinish =
finishTrimmed !== "" && parseFinishTimeToSeconds(finishTrimmed) !== null;
const finishTime = getFinishTime(form);
const finishPlace = getFinishPlace(form);
let statusValue: RaceStatus | null =
form.status === "planned" || form.status === "registered" || form.status === "completed"
? form.status
: null;
if (hasParsedFinish) {
if (finishTime.value) {
statusValue = "completed";
}
if (isEditMode && raceId) {
const payload: UpdateRacePayload = {
date: form.date.trim(),
title: form.title.trim(),
distanceKm: parseFloat(form.distanceKm),
status: statusValue,
officialUrl: emptyToNull(form.officialUrl),
coverImageUrl: emptyToNull(form.coverImageUrl),
startTime: emptyToNull(form.startTime),
clusterSchedule: emptyToNull(form.clusterSchedule),
bibPickup: emptyToNull(form.bibPickup),
bibNumber: emptyToNull(form.bibNumber),
finishTime: emptyToNull(form.finishTime),
finishPlace: emptyToNull(form.finishPlace),
notes: emptyToNull(form.notes),
};
const payload: Omit<CreateRacePayload, "slug"> = {
date: form.date.trim(),
title: form.title.trim(),
distanceKm: parseFloat(form.distanceKm),
status: statusValue,
officialUrl: emptyToNull(form.officialUrl),
coverImageUrl: emptyToNull(form.coverImageUrl),
startTime: emptyToNull(form.startTime),
clusterSchedule: emptyToNull(form.clusterSchedule),
bibPickup: emptyToNull(form.bibPickup),
bibNumber: emptyToNull(form.bibNumber),
finishTime: finishTime.value,
finishPlace: finishPlace.value,
notes: emptyToNull(form.notes),
};
if (isEditMode && raceId) {
await updateRace(raceId, payload);
navigate(`/races/${raceId}`);
} else {
const id = generateId(form.date.trim(), form.title.trim());
const payload: CreateRacePayload = {
id,
date: form.date.trim(),
title: form.title.trim(),
distanceKm: parseFloat(form.distanceKm),
status: statusValue,
officialUrl: emptyToNull(form.officialUrl),
coverImageUrl: emptyToNull(form.coverImageUrl),
startTime: emptyToNull(form.startTime),
clusterSchedule: emptyToNull(form.clusterSchedule),
bibPickup: emptyToNull(form.bibPickup),
bibNumber: emptyToNull(form.bibNumber),
finishTime: emptyToNull(form.finishTime),
finishPlace: emptyToNull(form.finishPlace),
notes: emptyToNull(form.notes),
};
const created = await createRace(payload);
const slug = generateId(form.date.trim(), form.title.trim());
const created = await createRace({ ...payload, slug });
navigate(`/races/${created.id}`);
}
} catch (error) {
@@ -291,17 +355,17 @@ export function RaceFormPage(): JSX.Element {
<fieldset className="race-form__group">
<legend className="race-form__legend">Основная информация</legend>
<div className="race-form__field">
<label className="race-form__field">
<span className="race-form__label">Дата *</span>
<DatePickerField
<input
className="race-form__input"
type="date"
name="date"
value={form.date}
onChange={(next) => {
setForm((prev) => ({ ...prev, date: next }));
}}
onChange={handleChange}
required
/>
</div>
</label>
<label className="race-form__field">
<span className="race-form__label">Название *</span>
@@ -317,7 +381,6 @@ export function RaceFormPage(): JSX.Element {
</label>
<label className="race-form__field">
<span className="race-form__label">Дистанция, км *</span>
<input
className="race-form__input"
type="number"
@@ -376,15 +439,17 @@ export function RaceFormPage(): JSX.Element {
</label>
{hideOrgScheduleFields ? null : (
<div className="race-form__field">
<label className="race-form__field">
<span className="race-form__label">Время старта</span>
<StartTimeSelects
<input
className="race-form__input"
type="time"
step="1"
name="startTime"
value={form.startTime}
onChange={(next) => {
setForm((prev) => ({ ...prev, startTime: next }));
}}
onChange={handleChange}
/>
</div>
</label>
)}
{hideOrgScheduleFields ? null : (
@@ -430,29 +495,37 @@ export function RaceFormPage(): JSX.Element {
<fieldset className="race-form__group">
<legend className="race-form__legend">Результаты</legend>
<label className="race-form__field">
<div className="race-form__field">
<span className="race-form__label">Финишное время</span>
<input
className="race-form__input"
type="text"
name="finishTime"
value={form.finishTime}
onChange={handleChange}
placeholder="1:45:30"
/>
</label>
<div className="race-form__result-fields">
<label className="race-form__result-field">
<span className="race-form__result-field-label">Часы</span>
<input className="race-form__input" type="number" name="finishHours" value={form.finishHours} onChange={handleChange} min="0" step="1" inputMode="numeric" />
</label>
<label className="race-form__result-field">
<span className="race-form__result-field-label">Минуты</span>
<input className="race-form__input" type="number" name="finishMinutes" value={form.finishMinutes} onChange={handleChange} min="0" max="59" step="1" inputMode="numeric" />
</label>
<label className="race-form__result-field">
<span className="race-form__result-field-label">Секунды</span>
<input className="race-form__input" type="number" name="finishSeconds" value={form.finishSeconds} onChange={handleChange} min="0" max="59" step="1" inputMode="numeric" />
</label>
</div>
</div>
<label className="race-form__field">
<div className="race-form__field">
<span className="race-form__label">Место на финише</span>
<input
className="race-form__input"
type="text"
name="finishPlace"
value={form.finishPlace}
onChange={handleChange}
placeholder="12/340"
/>
</label>
<div className="race-form__result-fields">
<label className="race-form__result-field">
<span className="race-form__result-field-label">Место</span>
<input className="race-form__input" type="number" name="finishPlace" value={form.finishPlace} onChange={(event) => setForm((prev) => ({ ...prev, finishPlace: event.target.value, isFinishPlaceEdited: true }))} min="1" step="1" inputMode="numeric" />
</label>
<label className="race-form__result-field">
<span className="race-form__result-field-label">Всего участников</span>
<input className="race-form__input" type="number" name="finishPlaceTotal" value={form.finishPlaceTotal} onChange={(event) => setForm((prev) => ({ ...prev, finishPlaceTotal: event.target.value, isFinishPlaceEdited: true }))} min="1" step="1" inputMode="numeric" />
</label>
</div>
</div>
</fieldset>
) : null}

View File

@@ -33,6 +33,7 @@ const MONTH_OPTIONS: { value: string; label: string }[] = [
const VIEW_STORAGE_KEY = "races-view-mode";
type ViewMode = "list" | "calendar";
type RaceListTab = "upcoming" | "completed";
function yearSelectOptions(): number[] {
const current = new Date().getFullYear();
@@ -61,11 +62,11 @@ function readInitialViewMode(): ViewMode {
}
}
function RaceList(props: { title: string; races: Race[] }): JSX.Element {
const { title, races } = props;
function RaceList(props: { title: string; races: Race[]; variant: RaceListTab }): JSX.Element {
const { title, races, variant } = props;
return (
<section className="race-list" aria-label={title}>
<section className={`race-list race-list--${variant}`} aria-label={title}>
<h2 className="race-list__title">{title}</h2>
{races.length > 0 ? (
<ul className="race-list__items">
@@ -138,6 +139,7 @@ export function RacesPage(): JSX.Element {
const [yearFilter, setYearFilter] = useState<string>("");
const [monthFilter, setMonthFilter] = useState<string>("");
const [viewMode, setViewMode] = useState<ViewMode>(() => readInitialViewMode());
const [activeListTab, setActiveListTab] = useState<RaceListTab>("upcoming");
const setViewModePersist = useCallback((mode: ViewMode) => {
setViewMode(mode);
@@ -332,10 +334,34 @@ export function RacesPage(): JSX.Element {
</div>
{viewMode === "list" ? (
<div className="race-lists">
<RaceList title="Будущие" races={upcoming} />
<RaceList title="Завершенные" races={completed} />
</div>
<>
<div className="race-list-tabs" role="tablist" aria-label="Раздел стартов">
<button
type="button"
role="tab"
aria-selected={activeListTab === "upcoming"}
className={`race-list-tabs__btn${activeListTab === "upcoming" ? " race-list-tabs__btn--active" : ""}`}
onClick={() => setActiveListTab("upcoming")}
>
Будущие
<span className="race-list-tabs__count">{upcoming.length}</span>
</button>
<button
type="button"
role="tab"
aria-selected={activeListTab === "completed"}
className={`race-list-tabs__btn${activeListTab === "completed" ? " race-list-tabs__btn--active" : ""}`}
onClick={() => setActiveListTab("completed")}
>
Прошедшие
<span className="race-list-tabs__count">{completed.length}</span>
</button>
</div>
<div className={`race-lists race-lists--mobile-${activeListTab}`}>
<RaceList title="Будущие" races={upcoming} variant="upcoming" />
<RaceList title="Завершенные" races={completed} variant="completed" />
</div>
</>
) : (
<div className="races-cal-wrap">
<RacesCalendar

View File

@@ -63,6 +63,13 @@ a {
color: var(--color-text-muted);
}
.app-shell__link--button {
border: 0;
background: transparent;
font: inherit;
cursor: pointer;
}
.app-shell__link:hover,
.app-shell__link:focus-visible {
color: var(--color-text);
@@ -122,6 +129,83 @@ a {
color: var(--color-error);
}
.page--auth {
max-width: 32rem;
margin: 0 auto;
}
.auth-form {
margin-top: var(--space-5);
display: grid;
gap: var(--space-4);
}
.auth-form__field {
display: grid;
gap: var(--space-2);
}
.auth-form__label {
color: var(--color-text-muted);
font-size: var(--font-size-caption);
font-weight: 600;
}
.auth-form__input {
width: 100%;
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
padding: var(--space-3);
font: inherit;
color: var(--color-text);
background: var(--color-surface);
}
.auth-form__input:focus {
border-color: var(--color-accent);
outline: 2px solid color-mix(in srgb, var(--color-accent) 22%, transparent);
}
.auth-form__captcha {
min-height: 4rem;
}
.auth-form__links {
margin: var(--space-5) 0 0;
display: flex;
flex-wrap: wrap;
gap: var(--space-4);
}
.account__success {
margin: 0;
color: var(--color-success);
}
.account__sessions {
display: grid;
gap: var(--space-3);
margin: 0;
padding: 0;
list-style: none;
}
.account__session {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-3);
padding: var(--space-3);
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
}
.account__session p {
margin: var(--space-1) 0 0;
color: var(--color-text-muted);
font-size: var(--font-size-caption);
}
.dashboard-grid {
margin-top: var(--space-6);
display: grid;
@@ -248,6 +332,27 @@ a {
gap: var(--space-4);
}
.race-list-tabs {
display: none;
}
.race-list-tabs__btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: var(--space-2);
}
.race-list-tabs__count {
min-width: 1.75rem;
border-radius: 999px;
padding: 0.1rem var(--space-2);
background: var(--color-surface-soft);
color: var(--color-text-muted);
font-size: var(--font-size-caption);
font-weight: 700;
}
.race-list {
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
@@ -396,6 +501,32 @@ a {
min-width: 10rem;
}
.comparison-table__row {
cursor: pointer;
transition: background-color 0.15s ease, transform 0.15s ease;
}
.comparison-table__row:hover,
.comparison-table__row:focus-visible {
outline: none;
background: color-mix(in srgb, var(--color-accent) 9%, transparent);
transform: translateX(0.2rem);
}
.dashboard-latest__filters,
.dashboard-latest__pagination {
display: flex;
flex-wrap: wrap;
align-items: end;
gap: var(--space-3);
margin: 0 0 var(--space-4);
}
.dashboard-latest__pagination {
align-items: center;
margin-top: var(--space-4);
}
.races-filter__label {
font-size: var(--font-size-caption);
font-weight: 600;
@@ -432,6 +563,57 @@ a {
vector-effect: non-scaling-stroke;
}
.pace-chart__axis {
stroke: rgba(14, 31, 45, 0.42);
stroke-width: 0.75;
vector-effect: non-scaling-stroke;
}
.pace-chart__axis-label,
.pace-chart__axis-title,
.pace-chart__point-label {
fill: var(--color-text-muted);
font-size: 2.5px;
}
.pace-chart__axis-title {
font-size: 2.8px;
font-weight: 700;
}
.pace-chart__point-label {
font-size: 2.25px;
}
.pace-chart__point {
cursor: pointer;
outline: none;
}
.pace-chart__point:focus-visible .pace-chart__dot,
.pace-chart__point:hover .pace-chart__dot {
stroke-width: 2;
}
.pace-chart__tooltip,
.pace-chart__hint {
margin: var(--space-3) 0 0;
font-size: var(--font-size-caption);
}
.pace-chart__tooltip {
display: grid;
gap: var(--space-1);
padding: var(--space-3);
border-radius: var(--radius-sm);
background: rgba(255, 255, 255, 0.78);
color: var(--color-text);
}
.pace-chart__hint {
color: var(--color-text-muted);
}
.pace-chart__caption {
margin: var(--space-3) 0 0;
font-size: var(--font-size-caption);
@@ -749,167 +931,24 @@ a {
min-height: 5rem;
}
.race-form__time-picker {
display: flex;
flex-wrap: wrap;
align-items: flex-end;
.race-form__result-fields {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(7rem, 1fr));
gap: var(--space-2);
}
.race-form__time-picker__unit {
.race-form__result-field {
display: flex;
flex-direction: column;
gap: var(--space-1);
}
.race-form__time-picker__label {
.race-form__result-field-label {
font-size: var(--font-size-caption);
font-weight: 600;
color: var(--color-text-muted);
}
.race-form__time-picker__select {
min-width: 4.25rem;
}
.race-form__time-picker__sep {
color: var(--color-text-muted);
font-weight: 600;
padding-bottom: var(--space-2);
}
.date-picker {
position: relative;
width: min(100%, 22rem);
}
.date-picker__control {
display: flex;
align-items: stretch;
}
.date-picker__input {
min-width: 0;
border-top-right-radius: 0;
border-bottom-right-radius: 0;
}
.date-picker__toggle {
width: 3rem;
border: 1px solid var(--color-border);
border-left: none;
border-radius: 0 var(--radius-sm) var(--radius-sm) 0;
background: var(--color-bg-deep);
color: #ffffff;
font: inherit;
font-weight: 900;
cursor: pointer;
}
.date-picker__toggle:hover,
.date-picker__toggle:focus-visible {
background: var(--color-accent-strong);
outline: none;
}
.date-picker__popover {
position: absolute;
top: calc(100% + var(--space-2));
left: 0;
z-index: 12;
width: min(20rem, calc(100vw - var(--space-8)));
padding: var(--space-4);
border: 1px solid rgba(17, 104, 216, 0.2);
border-radius: var(--radius-md);
background: rgba(255, 255, 255, 0.98);
box-shadow: var(--shadow-card-lift);
}
.date-picker__header {
display: grid;
grid-template-columns: 2.25rem minmax(0, 1fr) 2.25rem;
align-items: center;
gap: var(--space-2);
margin-bottom: var(--space-3);
}
.date-picker__title {
margin: 0;
color: var(--color-bg-deep);
font-weight: 900;
text-align: center;
}
.date-picker__nav {
width: 2.25rem;
height: 2.25rem;
border: none;
border-radius: var(--radius-sm);
background: rgba(17, 104, 216, 0.08);
color: var(--color-accent);
font: inherit;
font-size: 1.25rem;
font-weight: 900;
cursor: pointer;
}
.date-picker__nav:hover,
.date-picker__nav:focus-visible {
background: var(--color-lime);
color: #071927;
outline: none;
}
.date-picker__weekdays,
.date-picker__cells {
display: grid;
grid-template-columns: repeat(7, 1fr);
gap: 2px;
}
.date-picker__weekdays {
margin-bottom: var(--space-1);
}
.date-picker__weekday {
color: var(--color-text-muted);
font-size: 0.72rem;
font-weight: 800;
text-align: center;
}
.date-picker__cell,
.date-picker__day {
min-height: 2.25rem;
}
.date-picker__day {
border: 1px solid transparent;
border-radius: var(--radius-sm);
background: transparent;
color: var(--color-text);
font: inherit;
font-size: var(--font-size-caption);
font-weight: 700;
cursor: pointer;
}
.date-picker__day:hover,
.date-picker__day:focus-visible {
background: #e8f1ff;
border-color: rgba(17, 104, 216, 0.2);
outline: none;
}
.date-picker__day--today {
border-color: var(--color-accent);
}
.date-picker__day--selected {
background: var(--color-lime);
color: #071927;
}
.race-form__actions {
display: flex;
gap: var(--space-3);
@@ -1991,6 +2030,53 @@ body {
grid-template-columns: 1fr;
}
.race-list-tabs {
margin-top: var(--space-6);
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: var(--space-1);
padding: var(--space-1);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
background: var(--color-surface);
}
.race-list-tabs__btn {
min-height: 2.75rem;
border: 0;
border-radius: var(--radius-sm);
background: transparent;
color: var(--color-text-muted);
font: inherit;
font-weight: 700;
cursor: pointer;
}
.race-list-tabs__btn:hover,
.race-list-tabs__btn:focus-visible {
color: var(--color-text);
outline: none;
}
.race-list-tabs__btn--active {
background: var(--color-accent);
color: var(--color-surface);
}
.race-list-tabs__btn--active .race-list-tabs__count {
background: var(--color-surface);
color: var(--color-accent);
}
.race-lists {
margin-top: var(--space-4);
}
.race-lists--mobile-upcoming .race-list--completed,
.race-lists--mobile-completed .race-list--upcoming {
display: none;
}
.races-cal__year {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
@@ -2059,3 +2145,14 @@ body {
grid-template-columns: 1fr;
}
}
@media (prefers-reduced-motion: reduce) {
.comparison-table__row {
transition: none;
}
.comparison-table__row:hover,
.comparison-table__row:focus-visible {
transform: none;
}
}

View File

@@ -1 +1,12 @@
/// <reference types="vite/client" />
interface ImportMetaEnv {
readonly VITE_TURNSTILE_SITE_KEY?: string;
}
interface Window {
turnstile?: {
render(container: HTMLElement, options: { sitekey: string; callback(token: string): void; "expired-callback"(): void }): string;
remove(widgetId: string): void;
};
}

View File

@@ -1 +0,0 @@
{"root":["./src/main.tsx","./src/api/index.ts","./src/app/router.tsx","./src/app/layouts/applayout.tsx","./src/components/index.ts","./src/features/index.ts","./src/lib/index.ts","./src/pages/dashboardpage.tsx","./src/pages/racespage.tsx"],"version":"5.9.3"}

View File

@@ -7,7 +7,9 @@
"moduleResolution": "Bundler",
"allowSyntheticDefaultImports": true,
"types": ["node"],
"strict": true
"strict": true,
"noEmit": true,
"tsBuildInfoFile": "./node_modules/.cache/tsconfig.node.tsbuildinfo"
},
"include": ["vite.config.ts"]
}

File diff suppressed because one or more lines are too long

View File

@@ -1,2 +0,0 @@
declare const _default: import("vite").UserConfig;
export default _default;

View File

@@ -1,5 +0,0 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
export default defineConfig({
plugins: [react()]
});