From 103e3ca20914c51be1d444aedc4a24927d85c6f8 Mon Sep 17 00:00:00 2001 From: Vakanaut Date: Sun, 12 Jul 2026 16:21:53 +0300 Subject: [PATCH 1/2] feat: add public user profiles --- backend/migrations/005_public_profiles.sql | 1 + backend/package-lock.json | 4 +- backend/package.json | 2 +- backend/src/app.ts | 2 + backend/src/authMiddleware.ts | 1 + backend/src/authService.ts | 22 ++++++-- backend/src/routes/auth.ts | 16 ++++++ backend/src/routes/users.ts | 53 +++++++++++++++++ backend/test/app.test.ts | 42 ++++++++++++++ frontend/package-lock.json | 4 +- frontend/package.json | 2 +- frontend/src/api/auth.ts | 7 +++ frontend/src/api/index.ts | 4 +- frontend/src/api/types.ts | 9 +++ frontend/src/api/users.ts | 22 ++++++++ frontend/src/app/router.tsx | 2 + frontend/src/pages/AccountPage.tsx | 40 ++++++++++++- frontend/src/pages/PublicProfilePage.tsx | 66 ++++++++++++++++++++++ 18 files changed, 286 insertions(+), 13 deletions(-) create mode 100644 backend/migrations/005_public_profiles.sql create mode 100644 backend/src/routes/users.ts create mode 100644 frontend/src/api/users.ts create mode 100644 frontend/src/pages/PublicProfilePage.tsx diff --git a/backend/migrations/005_public_profiles.sql b/backend/migrations/005_public_profiles.sql new file mode 100644 index 0000000..1b528e5 --- /dev/null +++ b/backend/migrations/005_public_profiles.sql @@ -0,0 +1 @@ +ALTER TABLE users ADD COLUMN IF NOT EXISTS is_profile_public BOOLEAN NOT NULL DEFAULT FALSE; diff --git a/backend/package-lock.json b/backend/package-lock.json index 2a113e6..9af7107 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -1,12 +1,12 @@ { "name": "calendar-run-backend", - "version": "1.5.0", + "version": "1.5.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "calendar-run-backend", - "version": "1.5.0", + "version": "1.5.1", "dependencies": { "argon2": "^0.44.0", "cookie-parser": "^1.4.7", diff --git a/backend/package.json b/backend/package.json index c597bb8..bc0c656 100644 --- a/backend/package.json +++ b/backend/package.json @@ -1,6 +1,6 @@ { "name": "calendar-run-backend", - "version": "1.5.0", + "version": "1.5.1", "private": true, "scripts": { "build": "tsc", diff --git a/backend/src/app.ts b/backend/src/app.ts index 316b398..b08ce71 100644 --- a/backend/src/app.ts +++ b/backend/src/app.ts @@ -7,6 +7,7 @@ import { loadAuth, requireCsrf } from "./authMiddleware"; import authRouter from "./routes/auth"; import healthRouter from "./routes/health"; import racesRouter from "./routes/races"; +import usersRouter from "./routes/users"; const TURNSTILE_ORIGIN = "https://challenges.cloudflare.com"; @@ -54,6 +55,7 @@ export function createApp(): express.Express { app.use("/api", healthRouter); app.use("/api", authRouter); + app.use("/api", usersRouter); app.use("/api", racesRouter); app.use((err: unknown, _req: Request, res: Response, _next: NextFunction) => { diff --git a/backend/src/authMiddleware.ts b/backend/src/authMiddleware.ts index f1a73c2..6c2c206 100644 --- a/backend/src/authMiddleware.ts +++ b/backend/src/authMiddleware.ts @@ -10,6 +10,7 @@ declare global { id: string; email: string; emailVerifiedAt: string | null; + isProfilePublic: boolean; }; csrfTokenHash: string; sessionToken: string; diff --git a/backend/src/authService.ts b/backend/src/authService.ts index dbdc246..b7368ee 100644 --- a/backend/src/authService.ts +++ b/backend/src/authService.ts @@ -18,6 +18,7 @@ export interface AuthUser { id: string; email: string; emailVerifiedAt: string | null; + isProfilePublic: boolean; } interface UserRow { @@ -25,6 +26,7 @@ interface UserRow { email: string; password_hash: string; email_verified_at: Date | string | null; + is_profile_public: boolean; } interface SessionRow { @@ -35,6 +37,7 @@ interface SessionRow { expires_at: Date | string; email: string; email_verified_at: Date | string | null; + is_profile_public: boolean; } interface SessionListRow { @@ -65,6 +68,7 @@ function userFromRow(row: UserRow): AuthUser { id: row.id, email: row.email, emailVerifiedAt: toIso(row.email_verified_at), + isProfilePublic: row.is_profile_public, }; } @@ -81,7 +85,7 @@ function isUniqueViolation(error: unknown): boolean { export async function findUserByEmail(email: string): Promise { const normalized = normalizeEmail(email); const { rows } = await pool.query( - "SELECT id, email, password_hash, email_verified_at FROM users WHERE LOWER(BTRIM(email)) = $1", + "SELECT id, email, password_hash, email_verified_at, is_profile_public FROM users WHERE LOWER(BTRIM(email)) = $1", [normalized], ); return rows[0] ?? null; @@ -142,7 +146,7 @@ export async function registerUser(email: string, password: string): Promise( `INSERT INTO users (email, password_hash) VALUES ($1, $2) - RETURNING id, email, password_hash, email_verified_at`, + RETURNING id, email, password_hash, email_verified_at, is_profile_public`, [normalized, passwordHash], )); } catch (error) { @@ -180,7 +184,8 @@ export async function createSession(userId: string): Promise<{ sessionToken: str 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`, + (SELECT email_verified_at FROM users WHERE id = $1) AS email_verified_at, + (SELECT is_profile_public FROM users WHERE id = $1) AS is_profile_public`, [userId, sha256Hex(sessionToken), sha256Hex(csrfToken), expiresAt], ); const row = rows[0]; @@ -192,6 +197,7 @@ export async function createSession(userId: string): Promise<{ sessionToken: str id: row.user_id, email: row.email, emailVerifiedAt: toIso(row.email_verified_at), + isProfilePublic: row.is_profile_public, }, }; } @@ -306,7 +312,7 @@ export async function rotateCsrf(sessionToken: string): Promise { export async function getSession(sessionToken: string): Promise<{ user: AuthUser; csrfTokenHash: string } | null> { const tokenHash = sha256Hex(sessionToken); const { rows } = await pool.query( - `SELECT s.id, s.user_id, s.token_hash, s.csrf_token_hash, s.expires_at, u.email, u.email_verified_at + `SELECT s.id, s.user_id, s.token_hash, s.csrf_token_hash, s.expires_at, u.email, u.email_verified_at, u.is_profile_public 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()`, @@ -325,11 +331,19 @@ export async function getSession(sessionToken: string): Promise<{ user: AuthUser id: row.user_id, email: row.email, emailVerifiedAt: toIso(row.email_verified_at), + isProfilePublic: row.is_profile_public, }, csrfTokenHash: row.csrf_token_hash, }; } +export async function setProfileVisibility(userId: string, isProfilePublic: boolean): Promise { + await pool.query( + "UPDATE users SET is_profile_public = $2, updated_at = NOW() WHERE id = $1", + [userId, isProfilePublic], + ); +} + export function csrfMatches(hash: string, token: string): boolean { return timingSafeEqualHex(hash, sha256Hex(token)); } diff --git a/backend/src/routes/auth.ts b/backend/src/routes/auth.ts index 773fdb8..409e532 100644 --- a/backend/src/routes/auth.ts +++ b/backend/src/routes/auth.ts @@ -13,6 +13,7 @@ import { revokeSessionById, rotateCsrf, revokeSession, + setProfileVisibility, verifyEmailToken, } from "../authService"; import { clearSessionCookie, requireAuth, setSessionCookie } from "../authMiddleware"; @@ -79,6 +80,7 @@ const passwordChangeSchema = z.object({ }).strict(); const sessionIdSchema = z.string().uuid(); +const profileSchema = z.object({ isProfilePublic: z.boolean() }).strict(); function validationError(res: Response): void { res.status(400).json({ error: "validation_error", details: ["Invalid request body"] }); @@ -147,6 +149,20 @@ router.get("/auth/me", async (req: Request, res: Response, next) => { } }); +router.patch("/auth/profile", requireAuth, async (req: Request, res: Response, next) => { + const parsed = profileSchema.safeParse(req.body); + if (!parsed.success) { + validationError(res); + return; + } + try { + await setProfileVisibility(req.auth!.user.id, parsed.data.isProfilePublic); + res.json({ isProfilePublic: parsed.data.isProfilePublic }); + } catch (error) { + next(error); + } +}); + router.get("/auth/sessions", requireAuth, async (req: Request, res: Response, next) => { try { res.json({ sessions: await listActiveSessions(req.auth!.user.id, req.auth!.sessionToken) }); diff --git a/backend/src/routes/users.ts b/backend/src/routes/users.ts new file mode 100644 index 0000000..86aeb69 --- /dev/null +++ b/backend/src/routes/users.ts @@ -0,0 +1,53 @@ +import { Request, Response, Router } from "express"; +import { z } from "zod"; +import { pool } from "../db"; + +const router: Router = Router(); + +interface PublicRaceRow { + race_date: string | Date; + title: string; + distance_km: string; + status: "planned" | "registered" | "completed" | null; + cover_image_url: string | null; +} + +function raceDate(value: string | Date): string { + return typeof value === "string" ? value.slice(0, 10) : value.toISOString().slice(0, 10); +} + +router.get("/users/:id/races", async (req: Request, res: Response) => { + const parsed = z.string().uuid().safeParse(req.params.id); + if (!parsed.success) { + res.status(400).json({ error: "validation_error", details: ["id: Must be a UUID"] }); + return; + } + + try { + const profile = await pool.query( + "SELECT id FROM users WHERE id = $1 AND is_profile_public = TRUE", + [parsed.data], + ); + if (profile.rowCount === 0) { + res.status(404).json({ error: "not_found", details: ["Profile not found"] }); + return; + } + const { rows } = await pool.query( + `SELECT race_date, title, distance_km, status, cover_image_url + FROM races WHERE owner_user_id = $1 ORDER BY race_date ASC`, + [parsed.data], + ); + res.json(rows.map((row) => ({ + date: raceDate(row.race_date), + title: row.title, + distanceKm: Number(row.distance_km), + status: row.status, + coverImageUrl: row.cover_image_url, + }))); + } catch (error) { + console.error("[GET /users/:id/races]", error); + res.status(503).json({ error: "database_unavailable" }); + } +}); + +export default router; diff --git a/backend/test/app.test.ts b/backend/test/app.test.ts index 3b37a4e..c80531e 100644 --- a/backend/test/app.test.ts +++ b/backend/test/app.test.ts @@ -173,6 +173,48 @@ test("GET /api/races requires authentication", async () => { assert.equal(res.body.error, "unauthorized"); }); +test("public profile exposes only its owner's calendar after they enable it", async () => { + const { agent } = await authAgent(); + const user = await agent.get("/api/auth/me").expect(200); + const userId = user.body.user.id as string; + await agent + .post("/api/races") + .set("X-CSRF-Token", user.body.csrfToken as string) + .send({ + slug: "2026-08-01-public-race", + date: "2026-08-01", + title: "Public Race", + distanceKm: 10, + notes: "Private note", + bibNumber: "123", + finishTime: "00:40:00", + }) + .expect(201); + + await request(app).get(`/api/users/${userId}/races`).expect(404); + await agent + .patch("/api/auth/profile") + .set("X-CSRF-Token", user.body.csrfToken as string) + .send({ isProfilePublic: true }) + .expect(200); + + const publicRaces = await request(app).get(`/api/users/${userId}/races`).expect(200); + assert.deepEqual(publicRaces.body, [{ + date: "2026-08-01", + title: "Public Race", + distanceKm: 10, + status: null, + coverImageUrl: null, + }]); + + await agent + .patch("/api/auth/profile") + .set("X-CSRF-Token", user.body.csrfToken as string) + .send({ isProfilePublic: false }) + .expect(200); + await request(app).get(`/api/users/${userId}/races`).expect(404); +}); + test("login uses generic response for missing user and wrong password", async () => { const password = "correct horse battery staple"; await createVerifiedUser("generic@example.com", password); diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 9724119..f0898e6 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "calendar-run-frontend", - "version": "0.8.0", + "version": "0.8.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "calendar-run-frontend", - "version": "0.8.0", + "version": "0.8.1", "dependencies": { "react": "^18.3.1", "react-dom": "^18.3.1", diff --git a/frontend/package.json b/frontend/package.json index c5d1e0b..f38703e 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "calendar-run-frontend", "private": true, - "version": "0.8.0", + "version": "0.8.1", "type": "module", "scripts": { "dev": "vite", diff --git a/frontend/src/api/auth.ts b/frontend/src/api/auth.ts index a15fadb..4ed1a1e 100644 --- a/frontend/src/api/auth.ts +++ b/frontend/src/api/auth.ts @@ -87,3 +87,10 @@ export async function revokeSession(sessionId: string): Promise { export async function revokeOtherSessions(): Promise { await requestJson("/auth/sessions/revoke-others", { method: "POST" }); } + +export async function updateProfileVisibility(isProfilePublic: boolean): Promise { + await requestJson("/auth/profile", { + method: "PATCH", + body: JSON.stringify({ isProfilePublic }), + }); +} diff --git a/frontend/src/api/index.ts b/frontend/src/api/index.ts index 3120081..7cd0674 100644 --- a/frontend/src/api/index.ts +++ b/frontend/src/api/index.ts @@ -1,8 +1,9 @@ -export type { AuthSession, AuthUser, CreateRacePayload, Race, RacesQuery, RaceStatus, UpdateRacePayload } from "./types"; +export type { AuthSession, AuthUser, CreateRacePayload, PublicRace, Race, RacesQuery, RaceStatus, UpdateRacePayload } from "./types"; export { ApiError, getApiErrorMessage } from "./errors"; export type { BackendMetaResponse } from "./health"; export { getBackendMeta } from "./health"; export { getRaceById, getRaces, createRace, updateRace, deleteRace } from "./races"; +export { getPublicRaces } from "./users"; export { forgotPassword, changePassword, @@ -15,5 +16,6 @@ export { resetPassword, revokeOtherSessions, revokeSession, + updateProfileVisibility, verifyEmail, } from "./auth"; diff --git a/frontend/src/api/types.ts b/frontend/src/api/types.ts index 906ada5..03c34bb 100644 --- a/frontend/src/api/types.ts +++ b/frontend/src/api/types.ts @@ -48,6 +48,15 @@ export interface AuthUser { id: string; email: string; emailVerifiedAt: string | null; + isProfilePublic: boolean; +} + +export interface PublicRace { + date: string; + title: string; + distanceKm: number; + status: RaceStatus | null; + coverImageUrl: string | null; } export interface AuthSession { diff --git a/frontend/src/api/users.ts b/frontend/src/api/users.ts new file mode 100644 index 0000000..c4a042e --- /dev/null +++ b/frontend/src/api/users.ts @@ -0,0 +1,22 @@ +import { ApiError } from "./errors"; +import { requestJson } from "./http"; +import type { PublicRace } from "./types"; + +function isPublicRace(value: unknown): value is PublicRace { + const race = value as Partial; + return ( + typeof race?.date === "string" && + typeof race?.title === "string" && + typeof race?.distanceKm === "number" && + (race?.status === null || race?.status === "planned" || race?.status === "registered" || race?.status === "completed") && + (race?.coverImageUrl === null || typeof race?.coverImageUrl === "string") + ); +} + +export async function getPublicRaces(userId: string, init?: RequestInit): Promise { + const response = await requestJson(`/users/${userId}/races`, init); + if (!Array.isArray(response) || !response.every(isPublicRace)) { + throw new ApiError({ code: "unknown_error", message: "Некорректный формат данных от API." }); + } + return response; +} diff --git a/frontend/src/app/router.tsx b/frontend/src/app/router.tsx index e2140ff..d625de6 100644 --- a/frontend/src/app/router.tsx +++ b/frontend/src/app/router.tsx @@ -6,6 +6,7 @@ import { RaceDetailsPage } from "../pages/RaceDetailsPage"; import { RaceFormPage } from "../pages/RaceFormPage"; import { RaceDayPage } from "../pages/RaceDayPage"; import { AccountPage } from "../pages/AccountPage"; +import { PublicProfilePage } from "../pages/PublicProfilePage"; import { ForgotPasswordPage, LoginPage, RegisterPage, ResetPasswordPage, VerifyEmailPage } from "../pages/AuthPages"; import { RequireAuth } from "./auth/RequireAuth"; @@ -19,6 +20,7 @@ export const appRouter = createBrowserRouter([ { path: "verify-email", element: }, { path: "forgot-password", element: }, { path: "reset-password", element: }, + { path: "users/:userId", element: }, { element: , children: [ diff --git a/frontend/src/pages/AccountPage.tsx b/frontend/src/pages/AccountPage.tsx index 0b99fc4..7bb3156 100644 --- a/frontend/src/pages/AccountPage.tsx +++ b/frontend/src/pages/AccountPage.tsx @@ -1,5 +1,5 @@ import { useCallback, useEffect, useState } from "react"; -import { ApiError, changePassword, getSessions, revokeOtherSessions, revokeSession } from "../api"; +import { ApiError, changePassword, getSessions, revokeOtherSessions, revokeSession, updateProfileVisibility } from "../api"; import type { AuthSession } from "../api"; import { useAuth } from "../app/auth/AuthContext"; @@ -8,7 +8,7 @@ function formatDate(value: string): string { } export function AccountPage(): JSX.Element { - const { user, logout } = useAuth(); + const { user, logout, refresh } = useAuth(); const [sessions, setSessions] = useState([]); const [sessionsError, setSessionsError] = useState(""); const [sessionsLoading, setSessionsLoading] = useState(true); @@ -18,6 +18,8 @@ export function AccountPage(): JSX.Element { const [passwordError, setPasswordError] = useState(""); const [passwordSuccess, setPasswordSuccess] = useState(""); const [passwordSaving, setPasswordSaving] = useState(false); + const [profileError, setProfileError] = useState(""); + const [profileSaving, setProfileSaving] = useState(false); const loadSessions = useCallback(async () => { setSessionsLoading(true); @@ -81,11 +83,45 @@ export function AccountPage(): JSX.Element { } } + async function handleProfileVisibilityChange(isProfilePublic: boolean): Promise { + setProfileSaving(true); + setProfileError(""); + try { + await updateProfileVisibility(isProfilePublic); + await refresh(); + } catch (error) { + setProfileError(error instanceof ApiError ? error.message : "Не удалось обновить видимость страницы."); + } finally { + setProfileSaving(false); + } + } + return (

Аккаунт

{user?.email} — email подтверждён.

+
+

Публичная страница

+ + + {profileError ?

{profileError}

: null} +
+
void handlePasswordSubmit(event)}>

Сменить пароль