Compare commits

..

7 Commits

Author SHA1 Message Date
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
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
Vaka.pro
fdb0ba3d2d fix: use next race image as dashboard hero background
Some checks failed
CI / build-and-test (pull_request) Has been cancelled
2026-04-27 23:30:36 +03:00
367868cf1b Merge pull request 'fix: tolerate missing race cover image field' (#33) from fix/race-cover-api-backcompat into main
Some checks failed
CI / build-and-test (push) Has been cancelled
Reviewed-on: #33
2026-04-27 20:08:11 +00:00
39 changed files with 2507 additions and 82 deletions

View File

@@ -30,6 +30,34 @@ API_PORT=3001
# Прод: https://ваш-домен — несколько origin через запятую: https://a.ru,https://www.a.ru # Прод: https://ваш-домен — несколько origin через запятую: https://a.ru,https://www.a.ru
CORS_ORIGIN=http://localhost:5173 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 (опционально) ───────────────────────────────── # ─── Версия API (опционально) ─────────────────────────────────
# Если в образе не удаётся прочитать package.json, подставьте вручную (видно в GET /health). # Если в образе не удаётся прочитать package.json, подставьте вручную (видно в GET /health).
# APP_VERSION=1.0.0 # APP_VERSION=1.0.0

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,23 +1,31 @@
{ {
"name": "calendar-run-backend", "name": "calendar-run-backend",
"version": "1.3.0", "version": "1.4.1",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "calendar-run-backend", "name": "calendar-run-backend",
"version": "1.3.0", "version": "1.4.1",
"dependencies": { "dependencies": {
"argon2": "^0.44.0",
"cookie-parser": "^1.4.7",
"cors": "^2.8.5", "cors": "^2.8.5",
"csv-parse": "^5.6.0", "csv-parse": "^5.6.0",
"dotenv": "^16.4.7", "dotenv": "^16.4.7",
"express": "^4.21.2", "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": { "devDependencies": {
"@types/cookie-parser": "^1.4.10",
"@types/cors": "^2.8.17", "@types/cors": "^2.8.17",
"@types/express": "^5.0.0", "@types/express": "^5.0.0",
"@types/node": "^22.12.0", "@types/node": "^22.12.0",
"@types/nodemailer": "^8.0.0",
"@types/pg": "^8.11.10", "@types/pg": "^8.11.10",
"@types/supertest": "^6.0.2", "@types/supertest": "^6.0.2",
"cross-env": "^10.1.0", "cross-env": "^10.1.0",
@@ -44,7 +52,6 @@
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/@epic-web/invariant/-/invariant-1.0.0.tgz", "resolved": "https://registry.npmjs.org/@epic-web/invariant/-/invariant-1.0.0.tgz",
"integrity": "sha512-lrTPqgvfFQtR/eY/qkIzp98OGdNJu0m5ji3q/nJI8v3SXkRKEnWiOxMmbvcSoAIzv/cGiuvRy57k4suKQSAdwA==", "integrity": "sha512-lrTPqgvfFQtR/eY/qkIzp98OGdNJu0m5ji3q/nJI8v3SXkRKEnWiOxMmbvcSoAIzv/cGiuvRy57k4suKQSAdwA==",
"dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/@esbuild/aix-ppc64": { "node_modules/@esbuild/aix-ppc64": {
@@ -540,6 +547,15 @@
"@noble/hashes": "^1.1.5" "@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": { "node_modules/@tsconfig/node10": {
"version": "1.0.12", "version": "1.0.12",
"resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.12.tgz", "resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.12.tgz",
@@ -589,6 +605,16 @@
"@types/node": "*" "@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": { "node_modules/@types/cookiejar": {
"version": "2.1.5", "version": "2.1.5",
"resolved": "https://registry.npmjs.org/@types/cookiejar/-/cookiejar-2.1.5.tgz", "resolved": "https://registry.npmjs.org/@types/cookiejar/-/cookiejar-2.1.5.tgz",
@@ -651,11 +677,20 @@
"integrity": "sha512-F0R/h2+dsy5wJAUe3tAU6oqa2qbWY5TpNfL/RGmo1y38hiyO1w3x2jPtt76wmuaJI4DQnOBu21cNXQ2STIUUWg==", "integrity": "sha512-F0R/h2+dsy5wJAUe3tAU6oqa2qbWY5TpNfL/RGmo1y38hiyO1w3x2jPtt76wmuaJI4DQnOBu21cNXQ2STIUUWg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"undici-types": "~6.21.0" "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": { "node_modules/@types/pg": {
"version": "8.20.0", "version": "8.20.0",
"resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.20.0.tgz", "resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.20.0.tgz",
@@ -773,6 +808,22 @@
"dev": true, "dev": true,
"license": "MIT" "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": { "node_modules/array-flatten": {
"version": "1.1.1", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz",
@@ -908,6 +959,25 @@
"node": ">= 0.6" "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": { "node_modules/cookie-signature": {
"version": "1.0.7", "version": "1.0.7",
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz", "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz",
@@ -949,7 +1019,6 @@
"version": "10.1.0", "version": "10.1.0",
"resolved": "https://registry.npmjs.org/cross-env/-/cross-env-10.1.0.tgz", "resolved": "https://registry.npmjs.org/cross-env/-/cross-env-10.1.0.tgz",
"integrity": "sha512-GsYosgnACZTADcmEyJctkJIoqAhHjttw7RsFrVoJNXbsWWqaq6Ym+7kZjq6mS45O0jij6vtiReppKQEtqWy6Dw==", "integrity": "sha512-GsYosgnACZTADcmEyJctkJIoqAhHjttw7RsFrVoJNXbsWWqaq6Ym+7kZjq6mS45O0jij6vtiReppKQEtqWy6Dw==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@epic-web/invariant": "^1.0.0", "@epic-web/invariant": "^1.0.0",
@@ -967,7 +1036,6 @@
"version": "7.0.6", "version": "7.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
"integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"path-key": "^3.1.0", "path-key": "^3.1.0",
@@ -1233,6 +1301,24 @@
"url": "https://opencollective.com/express" "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": { "node_modules/fast-safe-stringify": {
"version": "2.1.1", "version": "2.1.1",
"resolved": "https://registry.npmjs.org/fast-safe-stringify/-/fast-safe-stringify-2.1.1.tgz", "resolved": "https://registry.npmjs.org/fast-safe-stringify/-/fast-safe-stringify-2.1.1.tgz",
@@ -1437,6 +1523,15 @@
"node": ">= 0.4" "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": { "node_modules/http-errors": {
"version": "2.0.1", "version": "2.0.1",
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
@@ -1475,6 +1570,15 @@
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
"license": "ISC" "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": { "node_modules/ipaddr.js": {
"version": "1.9.1", "version": "1.9.1",
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
@@ -1488,7 +1592,6 @@
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
"integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
"dev": true,
"license": "ISC" "license": "ISC"
}, },
"node_modules/make-error": { "node_modules/make-error": {
@@ -1582,6 +1685,35 @@
"node": ">= 0.6" "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": { "node_modules/object-assign": {
"version": "4.1.1", "version": "4.1.1",
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
@@ -1638,7 +1770,6 @@
"version": "3.1.1", "version": "3.1.1",
"resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
"integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==",
"dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=8" "node": ">=8"
@@ -1655,7 +1786,6 @@
"resolved": "https://registry.npmjs.org/pg/-/pg-8.20.0.tgz", "resolved": "https://registry.npmjs.org/pg/-/pg-8.20.0.tgz",
"integrity": "sha512-ldhMxz2r8fl/6QkXnBD3CR9/xg694oT6DZQ2s6c/RI28OjtSOpxnPrUCGOBJ46RCUxcWdx3p6kw/xnDHjKvaRA==", "integrity": "sha512-ldhMxz2r8fl/6QkXnBD3CR9/xg694oT6DZQ2s6c/RI28OjtSOpxnPrUCGOBJ46RCUxcWdx3p6kw/xnDHjKvaRA==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"pg-connection-string": "^2.12.0", "pg-connection-string": "^2.12.0",
"pg-pool": "^3.13.0", "pg-pool": "^3.13.0",
@@ -1922,7 +2052,6 @@
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
"integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"shebang-regex": "^3.0.0" "shebang-regex": "^3.0.0"
@@ -1935,7 +2064,6 @@
"version": "3.0.0", "version": "3.0.0",
"resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz",
"integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==",
"dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=8" "node": ">=8"
@@ -2207,7 +2335,6 @@
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true, "dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"peer": true,
"bin": { "bin": {
"tsc": "bin/tsc", "tsc": "bin/tsc",
"tsserver": "bin/tsserver" "tsserver": "bin/tsserver"
@@ -2261,7 +2388,6 @@
"version": "2.0.2", "version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
"integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==",
"dev": true,
"license": "ISC", "license": "ISC",
"dependencies": { "dependencies": {
"isexe": "^2.0.0" "isexe": "^2.0.0"
@@ -2298,6 +2424,15 @@
"engines": { "engines": {
"node": ">=6" "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", "name": "calendar-run-backend",
"version": "1.3.0", "version": "1.4.1",
"private": true, "private": true,
"scripts": { "scripts": {
"build": "tsc", "build": "tsc",
@@ -11,16 +11,24 @@
"test": "cross-env CALENDAR_RUN_MOCK_DB=1 tsx --test test/app.test.ts" "test": "cross-env CALENDAR_RUN_MOCK_DB=1 tsx --test test/app.test.ts"
}, },
"dependencies": { "dependencies": {
"argon2": "^0.44.0",
"cookie-parser": "^1.4.7",
"cors": "^2.8.5", "cors": "^2.8.5",
"csv-parse": "^5.6.0", "csv-parse": "^5.6.0",
"dotenv": "^16.4.7", "dotenv": "^16.4.7",
"express": "^4.21.2", "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": { "devDependencies": {
"@types/cookie-parser": "^1.4.10",
"@types/cors": "^2.8.17", "@types/cors": "^2.8.17",
"@types/express": "^5.0.0", "@types/express": "^5.0.0",
"@types/node": "^22.12.0", "@types/node": "^22.12.0",
"@types/nodemailer": "^8.0.0",
"@types/pg": "^8.11.10", "@types/pg": "^8.11.10",
"@types/supertest": "^6.0.2", "@types/supertest": "^6.0.2",
"cross-env": "^10.1.0", "cross-env": "^10.1.0",

View File

@@ -1,18 +1,59 @@
import express, { Request, Response, NextFunction } from "express"; import express, { Request, Response, NextFunction } from "express";
import cors from "cors"; import cors from "cors";
import cookieParser from "cookie-parser";
import helmet from "helmet";
import { config } from "./config"; import { config } from "./config";
import { loadAuth, requireCsrf } from "./authMiddleware";
import authRouter from "./routes/auth";
import healthRouter from "./routes/health"; import healthRouter from "./routes/health";
import racesRouter from "./routes/races"; 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 { export function createApp(): express.Express {
const app = express(); const app = express();
app.use(helmet(buildHelmetOptions(config.securityProfile)));
app.use( 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(express.json());
app.use(cookieParser(config.session.secret));
app.use(loadAuth);
app.use(requireCsrf);
app.use("/api", healthRouter); app.use("/api", healthRouter);
app.use("/api", authRouter);
app.use("/api", racesRouter); app.use("/api", racesRouter);
app.use((err: unknown, _req: Request, res: Response, _next: NextFunction) => { 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();
}

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

@@ -0,0 +1,396 @@
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;
}
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 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,10 +11,46 @@ function requireEnv(name: string): string {
return value; return value;
} }
function optionalEnv(name: string): string | null {
const value = process.env[name]?.trim();
return value ? value : null;
}
function requireEnvUnlessMock(name: string, fallback: string): string {
if (useMockDb) {
return process.env[name]?.trim() || fallback;
}
return requireEnv(name);
}
function parseBoolean(value: string | undefined, fallback: boolean): boolean {
if (value == null || value.trim() === "") {
return fallback;
}
return value === "1" || value.toLowerCase() === "true";
}
const useMockDb = const useMockDb =
process.env.CALENDAR_RUN_MOCK_DB === "1" || process.env.CALENDAR_RUN_MOCK_DB === "1" ||
process.env.CALENDAR_RUN_MOCK_DB?.toLowerCase() === "true"; process.env.CALENDAR_RUN_MOCK_DB?.toLowerCase() === "true";
const securityProfile = process.env.SECURITY_PROFILE?.trim() || process.env.NODE_ENV || "development";
export function resolveTurnstileBypassToken(params: {
rawBypassToken?: string;
securityProfile: string;
useMockDb: boolean;
}): string {
const raw = params.rawBypassToken?.trim() ?? "";
if (raw && params.securityProfile === "production" && !params.useMockDb) {
throw new Error("TURNSTILE_BYPASS_TOKEN is not allowed in production");
}
if (raw) {
return raw;
}
return params.useMockDb ? "mock-turnstile-token" : "";
}
export const config = { export const config = {
useMockDb, useMockDb,
db: useMockDb db: useMockDb
@@ -35,6 +71,33 @@ export const config = {
apiPort: parseInt(process.env.PORT || process.env.API_PORT || "3001", 10), apiPort: parseInt(process.env.PORT || process.env.API_PORT || "3001", 10),
/** Одно значение или несколько через запятую (прод: https://домен) */ /** Одно значение или несколько через запятую (прод: https://домен) */
corsOrigin: parseCorsOrigins(), corsOrigin: parseCorsOrigins(),
appBaseUrl: process.env.APP_BASE_URL?.trim() || "http://localhost:5173",
session: {
secret: requireEnvUnlessMock("SESSION_SECRET", "mock-session-secret-change-me"),
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() || (useMockDb ? "mock-turnstile-secret" : ""),
bypassToken: resolveTurnstileBypassToken({
rawBypassToken: process.env.TURNSTILE_BYPASS_TOKEN,
securityProfile,
useMockDb,
}),
},
authCleanupIntervalHours: parseInt(process.env.AUTH_CLEANUP_INTERVAL_HOURS || "24", 10),
securityProfile,
}; };
function parseCorsOrigins(): string | string[] { function parseCorsOrigins(): string | string[] {

View File

@@ -1,4 +1,5 @@
import { Pool, PoolConfig, QueryResult, QueryResultRow } from "pg"; import { Pool, PoolConfig, QueryResult, QueryResultRow } from "pg";
import crypto from "crypto";
import { config } from "./config"; import { config } from "./config";
import type { RaceRow } from "./mappers/race"; import type { RaceRow } from "./mappers/race";
@@ -19,6 +20,8 @@ function mockRowFromInsert(sql: string, params: unknown[]): RaceRow {
if (!match) { if (!match) {
return { return {
id: String(params[0] ?? ""), id: String(params[0] ?? ""),
slug: String(params[0] ?? ""),
owner_user_id: null,
race_date: "", race_date: "",
title: "", title: "",
distance_km: "0", distance_km: "0",
@@ -42,7 +45,9 @@ function mockRowFromInsert(sql: string, params: unknown[]): RaceRow {
row[col] = params[i]; row[col] = params[i];
}); });
return { return {
id: String(row.id ?? ""), id: String(row.id ?? crypto.randomUUID()),
slug: String(row.slug ?? row.id ?? ""),
owner_user_id: row.owner_user_id != null ? String(row.owner_user_id) : null,
race_date: String(row.race_date ?? ""), race_date: String(row.race_date ?? ""),
title: String(row.title ?? ""), title: String(row.title ?? ""),
distance_km: String(row.distance_km ?? "0"), distance_km: String(row.distance_km ?? "0"),
@@ -72,6 +77,14 @@ function createMockPool(): Pool {
}) as QueryResult<T>; }) as QueryResult<T>;
const store = new Map<string, RaceRow>(); const store = new Map<string, RaceRow>();
const users = new Map<string, any>();
const sessions = new Map<string, any>();
const verificationTokens = new Map<string, any>();
const resetTokens = new Map<string, any>();
const appSettings = new Map<string, string>();
const result = <T extends QueryResultRow>(rows: T[], command = "SELECT"): QueryResult<T> =>
({ rows, rowCount: rows.length, command, oid: 0, fields: [] }) as QueryResult<T>;
const mockQuery = async <T extends QueryResultRow>( const mockQuery = async <T extends QueryResultRow>(
text: string, text: string,
@@ -80,8 +93,322 @@ function createMockPool(): Pool {
const sql = text.replace(/\s+/g, " ").trim(); const sql = text.replace(/\s+/g, " ").trim();
const p = params ?? []; const p = params ?? [];
if (sql === "BEGIN" || sql === "COMMIT" || sql === "ROLLBACK" || sql.includes("pg_advisory_xact_lock")) {
return result<T>([]);
}
if (sql.includes("SELECT COUNT(*)::text AS count FROM users")) {
return result([{ count: String(users.size) } as unknown as T]);
}
if (sql.includes("SELECT COUNT(*)::text AS count FROM sessions")) {
const tokenHash = p[0] != null ? String(p[0]) : null;
const count = Array.from(sessions.values()).filter((row) => !tokenHash || row.token_hash === tokenHash).length;
return result([{ count: String(count) } as unknown as T]);
}
if (sql.includes("SELECT COUNT(*)::text AS count FROM email_verification_tokens")) {
const tokenHash = p[0] != null ? String(p[0]) : null;
const count = Array.from(verificationTokens.values()).filter((row) => !tokenHash || row.token_hash === tokenHash).length;
return result([{ count: String(count) } as unknown as T]);
}
if (sql.includes("SELECT COUNT(*)::text AS count FROM password_reset_tokens")) {
const tokenHash = p[0] != null ? String(p[0]) : null;
const count = Array.from(resetTokens.values()).filter((row) => !tokenHash || row.token_hash === tokenHash).length;
return result([{ count: String(count) } as unknown as T]);
}
if (sql.includes("SELECT id FROM users WHERE LOWER(BTRIM(email))")) {
const email = String(p[0] ?? "");
const user = Array.from(users.values()).find((item) => item.email.trim().toLowerCase() === email);
return user ? result([user as T]) : emptyResult();
}
if (sql.includes("SELECT id, email, password_hash, email_verified_at FROM users WHERE LOWER(BTRIM(email))")) {
const email = String(p[0] ?? "");
const user = Array.from(users.values()).find((item) => item.email.trim().toLowerCase() === email);
return user ? result([user as T]) : emptyResult();
}
if (sql.includes("INSERT INTO users")) {
const email = String(p[0] ?? "").trim().toLowerCase();
const existing = Array.from(users.values()).find((item) => item.email.trim().toLowerCase() === email);
if (existing) {
const err = new Error("duplicate key") as Error & { code?: string };
err.code = "23505";
throw err;
}
const id = crypto.randomUUID();
const row = {
id,
email: String(p[0] ?? ""),
password_hash: String(p[1] ?? ""),
email_verified_at: null,
created_at: new Date(),
updated_at: null,
};
users.set(id, row);
return result([row as unknown as T], "INSERT");
}
if (sql.includes("INSERT INTO email_verification_tokens")) {
const id = crypto.randomUUID();
const row = {
id,
user_id: String(p[0] ?? ""),
token_hash: String(p[1] ?? ""),
expires_at: p[2] ?? new Date(),
used_at: null,
created_at: new Date(),
};
verificationTokens.set(id, row);
return result([row as unknown as T], "INSERT");
}
if (sql.includes("UPDATE email_verification_tokens SET used_at = NOW() WHERE id")) {
const id = String(p[0] ?? "");
const row = verificationTokens.get(id);
if (!row || row.used_at != null) {
return result<T>([], "UPDATE");
}
row.used_at = new Date();
return result([{ id: row.id } as unknown as T], "UPDATE");
}
if (sql.includes("UPDATE email_verification_tokens SET used_at = NOW() WHERE user_id")) {
const userId = String(p[0] ?? "");
const exceptId = p[1] != null ? String(p[1]) : null;
for (const row of verificationTokens.values()) {
if (row.user_id === userId && row.id !== exceptId && row.used_at == null) {
row.used_at = new Date();
}
}
return result<T>([], "UPDATE");
}
if (sql.includes("FROM email_verification_tokens") && sql.includes("token_hash =")) {
const tokenHash = String(p[0] ?? "");
const now = Date.now();
return result(
Array.from(verificationTokens.values()).filter(
(row) => row.token_hash === tokenHash && !row.used_at && new Date(row.expires_at).getTime() > now,
) as T[],
);
}
if (sql.includes("FROM email_verification_tokens") && sql.includes("WHERE used_at IS NULL")) {
const now = Date.now();
return result(
Array.from(verificationTokens.values()).filter((row) => !row.used_at && new Date(row.expires_at).getTime() > now) as T[],
);
}
if (sql.includes("INSERT INTO password_reset_tokens")) {
const id = crypto.randomUUID();
const row = {
id,
user_id: String(p[0] ?? ""),
token_hash: String(p[1] ?? ""),
expires_at: p[2] ?? new Date(),
used_at: null,
created_at: new Date(),
};
resetTokens.set(id, row);
return result([row as unknown as T], "INSERT");
}
if (sql.includes("UPDATE password_reset_tokens SET used_at = NOW() WHERE id")) {
const id = String(p[0] ?? "");
const row = resetTokens.get(id);
if (!row || row.used_at != null) {
return result<T>([], "UPDATE");
}
row.used_at = new Date();
return result([{ id: row.id } as unknown as T], "UPDATE");
}
if (sql.includes("UPDATE password_reset_tokens SET used_at = NOW() WHERE user_id")) {
const userId = String(p[0] ?? "");
const exceptId = p[1] != null ? String(p[1]) : null;
for (const row of resetTokens.values()) {
if (row.user_id === userId && row.id !== exceptId && row.used_at == null) {
row.used_at = new Date();
}
}
return result<T>([], "UPDATE");
}
if (sql.includes("FROM password_reset_tokens") && sql.includes("token_hash =")) {
const tokenHash = String(p[0] ?? "");
const now = Date.now();
return result(
Array.from(resetTokens.values()).filter(
(row) => row.token_hash === tokenHash && !row.used_at && new Date(row.expires_at).getTime() > now,
) as T[],
);
}
if (sql.includes("FROM password_reset_tokens") && sql.includes("WHERE used_at IS NULL")) {
const now = Date.now();
return result(
Array.from(resetTokens.values()).filter((row) => !row.used_at && new Date(row.expires_at).getTime() > now) as T[],
);
}
if (sql.includes("UPDATE users SET email_verified_at")) {
const user = users.get(String(p[0] ?? ""));
if (user) {
user.email_verified_at = user.email_verified_at ?? new Date();
user.updated_at = new Date();
}
return result<T>([], "UPDATE");
}
if (sql.includes("UPDATE users SET password_hash")) {
const user = users.get(String(p[0] ?? ""));
if (user) {
user.password_hash = String(p[1] ?? "");
user.updated_at = new Date();
}
return result<T>([], "UPDATE");
}
if (sql.includes("INSERT INTO sessions")) {
const id = crypto.randomUUID();
const user = users.get(String(p[0] ?? ""));
const row = {
id,
user_id: String(p[0] ?? ""),
token_hash: String(p[1] ?? ""),
csrf_token_hash: String(p[2] ?? ""),
expires_at: p[3] ?? new Date(),
email: user?.email ?? "",
email_verified_at: user?.email_verified_at ?? null,
revoked_at: null,
created_at: new Date(),
last_seen_at: new Date(),
};
sessions.set(id, row);
return result([row as unknown as T], "INSERT");
}
if (sql.includes("FROM sessions s JOIN users u")) {
const tokenHash = String(p[0] ?? "");
const now = Date.now();
const row = Array.from(sessions.values()).find(
(item) => item.token_hash === tokenHash && !item.revoked_at && new Date(item.expires_at).getTime() > now,
);
if (!row) {
return emptyResult();
}
const user = users.get(row.user_id);
return result([{ ...row, email: user?.email ?? "", email_verified_at: user?.email_verified_at ?? null } as unknown as T]);
}
if (sql.includes("UPDATE sessions SET csrf_token_hash")) {
const tokenHash = String(p[0] ?? "");
const row = Array.from(sessions.values()).find((item) => item.token_hash === tokenHash && !item.revoked_at);
if (row) {
row.csrf_token_hash = String(p[1] ?? "");
row.last_seen_at = new Date();
}
return result<T>(row ? ([{} as unknown as T]) : [], "UPDATE");
}
if (sql.includes("UPDATE sessions SET last_seen_at")) {
return result<T>([], "UPDATE");
}
if (sql.includes("UPDATE sessions SET revoked_at = NOW() WHERE token_hash")) {
const tokenHash = String(p[0] ?? "");
for (const row of sessions.values()) {
if (row.token_hash === tokenHash) {
row.revoked_at = new Date();
}
}
return result<T>([], "UPDATE");
}
if (sql.includes("UPDATE sessions SET revoked_at = NOW() WHERE user_id")) {
const userId = String(p[0] ?? "");
for (const row of sessions.values()) {
if (row.user_id === userId && !row.revoked_at) {
row.revoked_at = new Date();
}
}
return result<T>([], "UPDATE");
}
if (sql.includes("DELETE FROM sessions WHERE expires_at <= NOW()")) {
const now = Date.now();
let deleted = 0;
for (const [id, row] of sessions.entries()) {
const revokedAt = row.revoked_at ? new Date(row.revoked_at).getTime() : null;
const staleRevoked = revokedAt != null && revokedAt < now - 30 * 24 * 60 * 60 * 1000;
if (new Date(row.expires_at).getTime() <= now || staleRevoked) {
sessions.delete(id);
deleted += 1;
}
}
return result<T>(Array.from({ length: deleted }, () => ({} as unknown as T)), "DELETE");
}
if (sql.includes("DELETE FROM email_verification_tokens WHERE expires_at <= NOW()")) {
const now = Date.now();
let deleted = 0;
for (const [id, row] of verificationTokens.entries()) {
if (new Date(row.expires_at).getTime() <= now || row.used_at != null) {
verificationTokens.delete(id);
deleted += 1;
}
}
return result<T>(Array.from({ length: deleted }, () => ({} as unknown as T)), "DELETE");
}
if (sql.includes("DELETE FROM password_reset_tokens WHERE expires_at <= NOW()")) {
const now = Date.now();
let deleted = 0;
for (const [id, row] of resetTokens.entries()) {
if (new Date(row.expires_at).getTime() <= now || row.used_at != null) {
resetTokens.delete(id);
deleted += 1;
}
}
return result<T>(Array.from({ length: deleted }, () => ({} as unknown as T)), "DELETE");
}
if (sql.includes("SELECT value FROM app_settings")) {
const value = appSettings.get("orphan_races_claimed_by_user_id");
return value ? result([{ value } as unknown as T]) : emptyResult();
}
if (sql.includes("INSERT INTO app_settings")) {
appSettings.set("orphan_races_claimed_by_user_id", String(p[0] ?? ""));
return result<T>([], "INSERT");
}
if (sql.includes("UPDATE races SET owner_user_id")) {
const userId = String(p[0] ?? "");
for (const race of store.values()) {
if (!race.owner_user_id) {
race.owner_user_id = userId;
race.updated_at = new Date();
}
}
return result<T>([], "UPDATE");
}
if (sql.includes("INSERT INTO races") && sql.includes("RETURNING")) { if (sql.includes("INSERT INTO races") && sql.includes("RETURNING")) {
const row = mockRowFromInsert(text, p); const row = mockRowFromInsert(text, p);
const conflict = Array.from(store.values()).find(
(item) => item.owner_user_id && item.owner_user_id === row.owner_user_id && item.slug === row.slug,
);
if (conflict) {
const err = new Error("duplicate key") as Error & { code?: string };
err.code = "23505";
throw err;
}
store.set(row.id, row); store.set(row.id, row);
return { return {
rows: [row as unknown as T], rows: [row as unknown as T],
@@ -94,7 +421,12 @@ function createMockPool(): Pool {
if (sql.includes("DELETE FROM races")) { if (sql.includes("DELETE FROM races")) {
const id = String(p[0] ?? ""); const id = String(p[0] ?? "");
const existed = store.delete(id); const ownerId = p[1] != null ? String(p[1]) : null;
const existing = store.get(id);
const existed = Boolean(existing && (!ownerId || existing.owner_user_id === ownerId));
if (existed) {
store.delete(id);
}
return { return {
rows: [], rows: [],
rowCount: existed ? 1 : 0, rowCount: existed ? 1 : 0,
@@ -105,9 +437,10 @@ function createMockPool(): Pool {
} }
if (sql.includes("UPDATE races") && sql.includes("RETURNING")) { if (sql.includes("UPDATE races") && sql.includes("RETURNING")) {
const id = String(p[p.length - 1] ?? ""); const id = String(p[p.length - 2] ?? p[p.length - 1] ?? "");
const ownerId = p[p.length - 1] != null ? String(p[p.length - 1]) : null;
const existing = store.get(id); const existing = store.get(id);
if (!existing) { if (!existing || (ownerId && existing.owner_user_id !== ownerId)) {
return emptyResult(); return emptyResult();
} }
const setMatch = sql.match(/UPDATE races SET (.+) WHERE id =/); const setMatch = sql.match(/UPDATE races SET (.+) WHERE id =/);
@@ -135,14 +468,18 @@ function createMockPool(): Pool {
if (sql.includes("SELECT * FROM races WHERE id =")) { if (sql.includes("SELECT * FROM races WHERE id =")) {
const id = String(p[0] ?? ""); const id = String(p[0] ?? "");
const ownerId = p[1] != null ? String(p[1]) : null;
const row = store.get(id); const row = store.get(id);
return row return row && (!ownerId || row.owner_user_id === ownerId)
? { rows: [row as unknown as T], rowCount: 1, command: "SELECT", oid: 0, fields: [] } as QueryResult<T> ? { rows: [row as unknown as T], rowCount: 1, command: "SELECT", oid: 0, fields: [] } as QueryResult<T>
: emptyResult(); : emptyResult();
} }
if (sql.includes("SELECT * FROM races")) { if (sql.includes("SELECT * FROM races")) {
const rows = Array.from(store.values()); const ownerParam = p.find((value) => typeof value === "string" && /^[0-9a-f-]{36}$/i.test(value));
const rows = ownerParam
? Array.from(store.values()).filter((row) => row.owner_user_id === ownerParam)
: Array.from(store.values());
return { rows: rows as unknown as T[], rowCount: rows.length, command: "SELECT", oid: 0, fields: [] } as QueryResult<T>; return { rows: rows as unknown as T[], rowCount: rows.length, command: "SELECT", oid: 0, fields: [] } as QueryResult<T>;
} }
@@ -152,9 +489,10 @@ function createMockPool(): Pool {
const mockPool = { const mockPool = {
query: mockQuery, query: mockQuery,
connect: async () => { connect: async () => {
throw new Error( return {
"CALENDAR_RUN_MOCK_DB is enabled: migrate/seed require a real database; unset CALENDAR_RUN_MOCK_DB and configure DB_*.", query: mockQuery,
); release() {},
};
}, },
end: async () => {}, end: async () => {},
on() { on() {

View File

@@ -1,7 +1,9 @@
import { config } from "./config"; import { config } from "./config";
import { createApp } from "./app"; import { createApp } from "./app";
import { startAuthCleanupSchedule } from "./authService";
const app = createApp(); const app = createApp();
startAuthCleanupSchedule();
app.listen(config.apiPort, () => { app.listen(config.apiPort, () => {
console.log(`[api] Listening on http://localhost:${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 { export interface RaceRow {
id: string; id: string;
slug: string;
owner_user_id: string | null;
race_date: string | Date; race_date: string | Date;
title: string; title: string;
distance_km: string; distance_km: string;
@@ -24,6 +26,7 @@ export interface RaceRow {
/** API shape (camelCase). */ /** API shape (camelCase). */
export interface RaceDto { export interface RaceDto {
id: string; id: string;
slug: string;
date: string; date: string;
title: string; title: string;
distanceKm: number; distanceKm: number;
@@ -61,6 +64,7 @@ function raceDateToApiValue(value: string | Date): string {
export function rowToDto(row: RaceRow): RaceDto { export function rowToDto(row: RaceRow): RaceDto {
return { return {
id: row.id, id: row.id,
slug: row.slug,
date: raceDateToApiValue(row.race_date), date: raceDateToApiValue(row.race_date),
title: row.title, title: row.title,
distanceKm: parseFloat(row.distance_km), 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. */ /** Map incoming camelCase body fields to snake_case column names. */
const FIELD_MAP: Record<string, string> = { const FIELD_MAP: Record<string, string> = {
slug: "slug",
date: "race_date", date: "race_date",
title: "title", title: "title",
distanceKm: "distance_km", distanceKm: "distance_km",

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

@@ -0,0 +1,197 @@
import { Router, Request, Response } from "express";
import rateLimit from "express-rate-limit";
import { z } from "zod";
import {
loginUser,
registerUser,
requestPasswordReset,
resendVerification,
resetPassword,
rotateCsrf,
revokeSession,
verifyEmailToken,
} from "../authService";
import { clearSessionCookie, 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 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"),
});
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.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

@@ -2,8 +2,10 @@ import { Router, Request, Response } from "express";
import { pool } from "../db"; import { pool } from "../db";
import { rowToDto, bodyToColumns, RaceRow } from "../mappers/race"; import { rowToDto, bodyToColumns, RaceRow } from "../mappers/race";
import { extractRaceCoverImage } from "../raceCoverImage"; import { extractRaceCoverImage } from "../raceCoverImage";
import { requireAuth } from "../authMiddleware";
const router = Router(); const router = Router();
router.use(requireAuth);
type ValidationErrorBody = { type ValidationErrorBody = {
error: "validation_error"; error: "validation_error";
@@ -69,6 +71,9 @@ router.get("/races", async (req: Request, res: Response) => {
const params: unknown[] = []; const params: unknown[] = [];
let idx = 1; let idx = 1;
conditions.push(`owner_user_id = $${idx++}`);
params.push(req.auth!.user.id);
if (yearResult.value != null) { if (yearResult.value != null) {
conditions.push(`EXTRACT(YEAR FROM race_date) = $${idx++}`); conditions.push(`EXTRACT(YEAR FROM race_date) = $${idx++}`);
params.push(yearResult.value); params.push(yearResult.value);
@@ -94,8 +99,8 @@ router.get("/races", async (req: Request, res: Response) => {
router.get("/races/:id", async (req: Request, res: Response) => { router.get("/races/:id", async (req: Request, res: Response) => {
try { try {
const { rows } = await pool.query<RaceRow>( const { rows } = await pool.query<RaceRow>(
"SELECT * FROM races WHERE id = $1", "SELECT * FROM races WHERE id = $1 AND owner_user_id = $2",
[req.params.id], [req.params.id, req.auth!.user.id],
); );
if (rows.length === 0) { if (rows.length === 0) {
res.status(404).json({ error: "not_found", details: ["Race not found"] }); res.status(404).json({ error: "not_found", details: ["Race not found"] });
@@ -113,12 +118,14 @@ router.get("/races/:id", async (req: Request, res: Response) => {
router.post("/races", async (req: Request, res: Response) => { router.post("/races", async (req: Request, res: Response) => {
const body = req.body; const body = req.body;
if (!body.id || !body.date || !body.title || body.distanceKm == null) { const slug = typeof body.slug === "string" && body.slug.trim() ? body.slug.trim() : body.id;
validationError(res, ["Fields id, date, title, distanceKm are required"]);
if (!slug || !body.date || !body.title || body.distanceKm == null) {
validationError(res, ["Fields slug, date, title, distanceKm are required"]);
return; return;
} }
const payload = { ...body }; const payload = { ...body, slug };
const hasManualCover = typeof payload.coverImageUrl === "string" && payload.coverImageUrl.trim() !== ""; const hasManualCover = typeof payload.coverImageUrl === "string" && payload.coverImageUrl.trim() !== "";
const hasOfficialUrl = typeof payload.officialUrl === "string" && payload.officialUrl.trim() !== ""; const hasOfficialUrl = typeof payload.officialUrl === "string" && payload.officialUrl.trim() !== "";
@@ -127,8 +134,12 @@ router.post("/races", async (req: Request, res: Response) => {
} }
const { columns, values } = bodyToColumns(payload); const { columns, values } = bodyToColumns(payload);
columns.unshift("id"); columns.unshift("owner_user_id");
values.unshift(body.id); values.unshift(req.auth!.user.id);
if (!columns.includes("slug")) {
columns.push("slug");
values.push(slug);
}
const placeholders = values.map((_, i) => `$${i + 1}`).join(", "); const placeholders = values.map((_, i) => `$${i + 1}`).join(", ");
const sql = `INSERT INTO races (${columns.join(", ")}) VALUES (${placeholders}) RETURNING *`; const sql = `INSERT INTO races (${columns.join(", ")}) VALUES (${placeholders}) RETURNING *`;
@@ -140,7 +151,7 @@ router.post("/races", async (req: Request, res: Response) => {
if (err.code === "23505") { if (err.code === "23505") {
res.status(409).json({ res.status(409).json({
error: "conflict", error: "conflict",
details: ["Race with this id already exists"], details: ["Race with this slug already exists"],
}); });
return; return;
} }
@@ -162,7 +173,8 @@ router.patch("/races/:id", async (req: Request, res: Response) => {
const sets = columns.map((col, i) => `${col} = $${i + 1}`); const sets = columns.map((col, i) => `${col} = $${i + 1}`);
sets.push(`updated_at = NOW()`); sets.push(`updated_at = NOW()`);
values.push(req.params.id); values.push(req.params.id);
const sql = `UPDATE races SET ${sets.join(", ")} WHERE id = $${values.length} RETURNING *`; 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 { try {
const { rows } = await pool.query<RaceRow>(sql, values); const { rows } = await pool.query<RaceRow>(sql, values);
@@ -182,8 +194,8 @@ router.patch("/races/:id", async (req: Request, res: Response) => {
router.delete("/races/:id", async (req: Request, res: Response) => { router.delete("/races/:id", async (req: Request, res: Response) => {
try { try {
const { rowCount } = await pool.query( const { rowCount } = await pool.query(
"DELETE FROM races WHERE id = $1", "DELETE FROM races WHERE id = $1 AND owner_user_id = $2",
[req.params.id], [req.params.id, req.auth!.user.id],
); );
if (rowCount === 0) { if (rowCount === 0) {
res.status(404).json({ error: "not_found", details: ["Race not found"] }); 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)}`; 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"; const CSV_NAME = "races_2026_calendar.csv";
function resolveCsvPath(): string | null { function resolveCsvPath(): string | null {
@@ -56,23 +81,28 @@ async function seed() {
const client = await pool.connect(); const client = await pool.connect();
try { try {
const ownerUserId = await resolveSeedOwnerUserId(client);
for (const row of records) { 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); const distanceKm = parseFloat(row.distance_km);
if (ownerUserId) {
await client.query( await client.query(
`INSERT INTO races (id, race_date, title, distance_km, status) `INSERT INTO races (slug, owner_user_id, race_date, title, distance_km, status, source)
VALUES ($1, $2, $3, $4, $5) VALUES ($1, $2, $3, $4, $5, $6, 'seed')
ON CONFLICT (id) DO UPDATE SET ON CONFLICT DO NOTHING`,
race_date = EXCLUDED.race_date, [slug, ownerUserId, row.date, row.event, distanceKm, "planned"],
title = EXCLUDED.title,
distance_km = EXCLUDED.distance_km,
status = EXCLUDED.status,
updated_at = NOW()`,
[id, 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."); 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,72 @@
import assert from "node:assert/strict"; import assert from "node:assert/strict";
import { test } from "node:test"; import { test } from "node:test";
import request from "supertest"; import request from "supertest";
import { createApp } from "../src/app"; 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 { extractRaceCoverImageFromHtml } from "../src/raceCoverImage"; import { extractRaceCoverImageFromHtml } from "../src/raceCoverImage";
import { hashPassword, normalizeEmail } from "../src/security";
const app = createApp(); const app = createApp();
let userCounter = 0;
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 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 () => { test("GET /api/health returns ok", async () => {
const res = await request(app).get("/api/health").expect(200); const res = await request(app).get("/api/health").expect(200);
@@ -25,28 +87,194 @@ test("GET /api/ready succeeds with mock database", async () => {
assert.equal(res.body.db, "connected"); assert.equal(res.body.db, "connected");
}); });
test("production config rejects Turnstile bypass token", () => {
assert.throws(
() =>
resolveTurnstileBypassToken({
rawBypassToken: "unsafe-bypass",
securityProfile: "production",
useMockDb: false,
}),
/TURNSTILE_BYPASS_TOKEN/,
);
assert.equal(
resolveTurnstileBypassToken({
rawBypassToken: "dev-bypass",
securityProfile: "development",
useMockDb: false,
}),
"dev-bypass",
);
assert.equal(
resolveTurnstileBypassToken({
rawBypassToken: "mock-bypass",
securityProfile: "production",
useMockDb: true,
}),
"mock-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 () => { 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.equal(res.body.error, "validation_error");
assert.ok(Array.isArray(res.body.details)); assert.ok(Array.isArray(res.body.details));
}); });
test("GET /api/races rejects month out of range", async () => { 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"); assert.equal(res.body.error, "validation_error");
}); });
test("GET /api/races accepts year and month", async () => { 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)); assert.ok(Array.isArray(res.body));
}); });
test("GET /api/races/:id returns not_found", async () => { test("GET /api/races/:id returns not_found", async () => {
const res = await request(app).get("/api/races/does-not-exist").expect(404); const { agent } = await authAgent();
const res = await agent.get("/api/races/does-not-exist").expect(404);
assert.equal(res.body.error, "not_found"); assert.equal(res.body.error, "not_found");
assert.ok(Array.isArray(res.body.details)); 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("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", () => { test("extractRaceCoverImageFromHtml prefers runc.run intro image", () => {
const html = ` const html = `
<meta property="og:image" content="https://example.com/og.jpg"> <meta property="og:image" content="https://example.com/og.jpg">
@@ -81,11 +309,13 @@ test("extractRaceCoverImageFromHtml reads Open Graph and Twitter images", () =>
}); });
test("POST /api/races stores manual coverImageUrl", async () => { test("POST /api/races stores manual coverImageUrl", async () => {
const { agent, csrfToken } = await authAgent();
const coverImageUrl = "https://example.com/manual.jpg"; const coverImageUrl = "https://example.com/manual.jpg";
const res = await request(app) const res = await agent
.post("/api/races") .post("/api/races")
.set("X-CSRF-Token", csrfToken)
.send({ .send({
id: "2026-06-01-manual-cover", slug: "2026-06-01-manual-cover",
date: "2026-06-01", date: "2026-06-01",
title: "Manual Cover", title: "Manual Cover",
distanceKm: 10, distanceKm: 10,
@@ -106,10 +336,12 @@ test("POST /api/races auto extracts coverImageUrl from officialUrl", async () =>
}); });
try { try {
const res = await request(app) const { agent, csrfToken } = await authAgent();
const res = await agent
.post("/api/races") .post("/api/races")
.set("X-CSRF-Token", csrfToken)
.send({ .send({
id: "2026-06-02-auto-cover", slug: "2026-06-02-auto-cover",
date: "2026-06-02", date: "2026-06-02",
title: "Auto Cover", title: "Auto Cover",
distanceKm: 21.1, distanceKm: 21.1,
@@ -130,10 +362,12 @@ test("POST /api/races succeeds when cover extraction fails", async () => {
}; };
try { try {
const res = await request(app) const { agent, csrfToken } = await authAgent();
const res = await agent
.post("/api/races") .post("/api/races")
.set("X-CSRF-Token", csrfToken)
.send({ .send({
id: "2026-06-03-cover-fail", slug: "2026-06-03-cover-fail",
date: "2026-06-03", date: "2026-06-03",
title: "Cover Fail", title: "Cover Fail",
distanceKm: 5, distanceKm: 5,
@@ -148,11 +382,12 @@ test("POST /api/races succeeds when cover extraction fails", async () => {
}); });
test("PATCH /api/races/:id updates coverImageUrl explicitly", async () => { test("PATCH /api/races/:id updates coverImageUrl explicitly", async () => {
const id = "2026-06-04-patch-cover"; const { agent, csrfToken } = await authAgent();
await request(app) const created = await agent
.post("/api/races") .post("/api/races")
.set("X-CSRF-Token", csrfToken)
.send({ .send({
id, slug: "2026-06-04-patch-cover",
date: "2026-06-04", date: "2026-06-04",
title: "Patch Cover", title: "Patch Cover",
distanceKm: 10, distanceKm: 10,
@@ -160,8 +395,9 @@ test("PATCH /api/races/:id updates coverImageUrl explicitly", async () => {
.expect(201); .expect(201);
const coverImageUrl = "https://example.com/patched.jpg"; const coverImageUrl = "https://example.com/patched.jpg";
const res = await request(app) const res = await agent
.patch(`/api/races/${id}`) .patch(`/api/races/${created.body.id}`)
.set("X-CSRF-Token", csrfToken)
.send({ coverImageUrl }) .send({ coverImageUrl })
.expect(200); .expect(200);

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

View File

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

View File

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

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

@@ -0,0 +1,69 @@
import { requestJson, setCsrfToken } from "./http";
import type { 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 }),
});
}

View File

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

View File

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

View File

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

View File

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

View File

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

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 { Link, NavLink, Outlet } from "react-router-dom";
import { useAuth } from "../auth/AuthContext";
import { AppShellFooter } from "./AppShellFooter"; import { AppShellFooter } from "./AppShellFooter";
export function AppLayout(): JSX.Element { export function AppLayout(): JSX.Element {
const { user, logout } = useAuth();
return ( return (
<div className="app-shell"> <div className="app-shell">
<header className="app-shell__header"> <header className="app-shell__header">
@@ -34,6 +37,20 @@ export function AppLayout(): JSX.Element {
> >
+ Добавить + Добавить
</NavLink> </NavLink>
{user ? (
<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> </nav>
</header> </header>
<main className="app-shell__main"> <main className="app-shell__main">

View File

@@ -5,11 +5,21 @@ import { RacesPage } from "../pages/RacesPage";
import { RaceDetailsPage } from "../pages/RaceDetailsPage"; import { RaceDetailsPage } from "../pages/RaceDetailsPage";
import { RaceFormPage } from "../pages/RaceFormPage"; import { RaceFormPage } from "../pages/RaceFormPage";
import { RaceDayPage } from "../pages/RaceDayPage"; import { RaceDayPage } from "../pages/RaceDayPage";
import { ForgotPasswordPage, LoginPage, RegisterPage, ResetPasswordPage, VerifyEmailPage } from "../pages/AuthPages";
import { RequireAuth } from "./auth/RequireAuth";
export const appRouter = createBrowserRouter([ export const appRouter = createBrowserRouter([
{ {
path: "/", path: "/",
element: <AppLayout />, element: <AppLayout />,
children: [
{ 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: [ children: [
{ index: true, element: <DashboardPage /> }, { index: true, element: <DashboardPage /> },
{ path: "races", element: <RacesPage /> }, { path: "races", element: <RacesPage /> },
@@ -19,4 +29,6 @@ export const appRouter = createBrowserRouter([
{ path: "races/:raceId/edit", element: <RaceFormPage /> }, { path: "races/:raceId/edit", element: <RaceFormPage /> },
], ],
}, },
],
},
]); ]);

View File

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

View File

@@ -0,0 +1,253 @@
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 handleToken = useCallback((token: string) => setTurnstileToken(token), []);
const handleSubmit = async (event: React.FormEvent) => {
event.preventDefault();
setError(null);
setMessage(null);
try {
await register({ email, password, turnstileToken });
setMessage("Проверьте почту: мы отправили ссылку для подтверждения email.");
} catch (err) {
setError(errorMessage(err, "Не удалось зарегистрироваться."));
}
};
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}>Создать аккаунт</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);
useEffect(() => {
const token = params.get("token");
if (!token) {
return;
}
void verifyEmail(token)
.then(async () => {
await refresh();
setMessage("Email подтверждён. Теперь можно пользоваться календарём.");
})
.catch((err) => setError(errorMessage(err, "Ссылка недействительна.")));
}, [params, refresh]);
const resend = async () => {
setError(null);
await resendVerification(email);
setMessage("Если email зарегистрирован и ещё не подтверждён, письмо отправлено.");
};
return (
<section className="page page--auth">
<h1 className="page__title">Подтверждение email</h1>
<p className="page__subtitle">Для доступа к календарю подтвердите 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">Отправить письмо ещё раз</button>
</form>
</section>
);
}
export function ForgotPasswordPage(): JSX.Element {
const [email, setEmail] = useState("");
const [message, setMessage] = useState<string | null>(null);
return (
<section className="page page--auth">
<h1 className="page__title">Сброс пароля</h1>
<form className="auth-form" onSubmit={async (e) => { e.preventDefault(); await forgotPassword(email); setMessage("Если email зарегистрирован, ссылка отправлена."); }}>
<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}
<button className="btn btn--primary" type="submit">Отправить ссылку</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);
return (
<section className="page page--auth">
<h1 className="page__title">Новый пароль</h1>
<form className="auth-form" onSubmit={async (e) => {
e.preventDefault();
const token = params.get("token") ?? "";
try {
await resetPassword(token, password);
setMessage("Пароль обновлён. Теперь войдите заново.");
} catch (err) {
setError(errorMessage(err, "Не удалось обновить пароль."));
}
}}>
<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">{error}</p> : null}
<button className="btn btn--primary" type="submit">Сохранить пароль</button>
</form>
</section>
);
}

View File

@@ -1,3 +1,4 @@
import type { CSSProperties } from "react";
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import type { Race } from "../api"; import type { Race } from "../api";
@@ -7,6 +8,7 @@ import {
formatDistance, formatDistance,
formatRaceDate, formatRaceDate,
getRaceCountdownLabel, getRaceCountdownLabel,
getRaceVisual,
getPaceLabel, getPaceLabel,
isCloseDistance, isCloseDistance,
parseFinishTimeToSeconds, parseFinishTimeToSeconds,
@@ -16,6 +18,10 @@ import {
const PR_DISTANCES = [5, 10, 21.1, 42.2] as const; const PR_DISTANCES = [5, 10, 21.1, 42.2] as const;
type DashboardHeroStyle = CSSProperties & {
"--dashboard-hero-image"?: string;
};
function getErrorMessage(error: unknown): string { function getErrorMessage(error: unknown): string {
if (error instanceof ApiError) { if (error instanceof ApiError) {
return error.message; return error.message;
@@ -23,6 +29,10 @@ function getErrorMessage(error: unknown): string {
return "Не удалось загрузить данные обзора."; return "Не удалось загрузить данные обзора.";
} }
function toCssUrl(value: string): string {
return `url(${JSON.stringify(value)})`;
}
export function DashboardPage(): JSX.Element { export function DashboardPage(): JSX.Element {
const [races, setRaces] = useState<Race[]>([]); const [races, setRaces] = useState<Race[]>([]);
const [isLoading, setIsLoading] = useState<boolean>(true); const [isLoading, setIsLoading] = useState<boolean>(true);
@@ -136,6 +146,10 @@ export function DashboardPage(): JSX.Element {
dashboardMetrics.seasonTotal > 0 dashboardMetrics.seasonTotal > 0
? Math.round((dashboardMetrics.seasonCompletedCount / dashboardMetrics.seasonTotal) * 100) ? Math.round((dashboardMetrics.seasonCompletedCount / dashboardMetrics.seasonTotal) * 100)
: 0; : 0;
const dashboardHeroVisual = dashboardMetrics.nextRace ? getRaceVisual(dashboardMetrics.nextRace) : null;
const dashboardHeroStyle: DashboardHeroStyle | undefined = dashboardHeroVisual
? { "--dashboard-hero-image": toCssUrl(dashboardHeroVisual.imageSrc) }
: undefined;
if (isLoading) { if (isLoading) {
return ( return (
@@ -157,7 +171,11 @@ export function DashboardPage(): JSX.Element {
return ( return (
<section className="page page--dashboard"> <section className="page page--dashboard">
<section className="dashboard-hero" aria-label="Обзор сезона"> <section
className={`dashboard-hero${dashboardHeroVisual ? " dashboard-hero--with-image" : ""}`}
style={dashboardHeroStyle}
aria-label="Обзор сезона"
>
<div className="dashboard-hero__content"> <div className="dashboard-hero__content">
<p className="dashboard-hero__eyebrow">Календарь сезона</p> <p className="dashboard-hero__eyebrow">Календарь сезона</p>
<h1 className="dashboard-hero__title">Беговой штаб</h1> <h1 className="dashboard-hero__title">Беговой штаб</h1>

View File

@@ -224,9 +224,9 @@ export function RaceFormPage(): JSX.Element {
await updateRace(raceId, payload); await updateRace(raceId, payload);
navigate(`/races/${raceId}`); navigate(`/races/${raceId}`);
} else { } else {
const id = generateId(form.date.trim(), form.title.trim()); const slug = generateId(form.date.trim(), form.title.trim());
const payload: CreateRacePayload = { const payload: CreateRacePayload = {
id, slug,
date: form.date.trim(), date: form.date.trim(),
title: form.title.trim(), title: form.title.trim(),
distanceKm: parseFloat(form.distanceKm), distanceKm: parseFloat(form.distanceKm),

View File

@@ -63,6 +63,13 @@ a {
color: var(--color-text-muted); color: var(--color-text-muted);
} }
.app-shell__link--button {
border: 0;
background: transparent;
font: inherit;
cursor: pointer;
}
.app-shell__link:hover, .app-shell__link:hover,
.app-shell__link:focus-visible { .app-shell__link:focus-visible {
color: var(--color-text); color: var(--color-text);
@@ -122,6 +129,54 @@ a {
color: var(--color-error); 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);
}
.dashboard-grid { .dashboard-grid {
margin-top: var(--space-6); margin-top: var(--space-6);
display: grid; display: grid;
@@ -1355,6 +1410,13 @@ body {
url("/images/runner-hero.jpg") center / cover; url("/images/runner-hero.jpg") center / cover;
} }
.dashboard-hero--with-image {
background:
linear-gradient(90deg, rgba(7, 25, 39, 0.94) 0%, rgba(7, 25, 39, 0.68) 48%, rgba(7, 25, 39, 0.2) 100%),
var(--dashboard-hero-image) center / cover,
url("/images/runner-hero.jpg") center / cover;
}
.dashboard-hero__content, .dashboard-hero__content,
.dashboard-hero__panel { .dashboard-hero__panel {
position: relative; position: relative;

View File

@@ -1 +1,12 @@
/// <reference types="vite/client" /> /// <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;
};
}