Go + Echo backend for CodeMetrics, a competitive-programming progress tracker. Replaces the previous Node/Express + MongoDB service with a Postgres-backed API.
- Echo v4 HTTP framework
- PostgreSQL via
pgx - JWT auth (
golang-jwt/jwt) with bcrypt password hashing, Google OAuth ("Sign In With Google" ID tokens, verified viago-oidc), and email-based password reset - Transactional email (welcome on signup, a login alert with IP/device on every login, password-changed confirmation) sent through the same SMTP mailer as password reset — a no-op logger until
SMTP_HOSTis configured - Per-IP rate limiting (stricter on
/api/auth/*) - Structured JSON logging (
log/slog) with per-request logs - Cursor-free page/limit pagination on list endpoints
- Server-side Codeforces API client with a shared rate limiter (respects CF's ~1req/2s guidance) and a Postgres-backed cache, so per-user stats aren't recomputed or re-fetched from Codeforces on every request
cp .env.example .env
docker compose up -d # starts Postgres on localhost:5433
go run ./cmd/serverThe server applies its schema automatically on startup (see internal/db/schema.sql).
All responses are JSON with a success boolean.
| Method | Path | Auth | Body / Query |
|---|---|---|---|
| POST | /api/auth/register |
none | { "email", "password" } |
| POST | /api/auth/login |
none | { "email", "password" } → returns { token, userId } |
| POST | /api/auth/google |
none | { "credential" } (Google ID token) → returns { token, userId }. Returns 501 until GOOGLE_CLIENT_ID is set. |
| POST | /api/auth/forgot-password |
none | { "email" } → always returns a generic success message |
| POST | /api/auth/reset-password |
none | { "token", "password" } |
| GET | /api/auth/userid |
JWT | ?email= |
/api/auth/login, /register, /google, /forgot-password, and /reset-password share a strict rate-limit bucket per IP (default: 5/min) to slow down credential stuffing / brute force.
Password-reset emails go through SMTP (Gmail app password works well for low volume — see .env.example). If SMTP_HOST isn't set, the reset link is logged instead of emailed, so the flow still works end-to-end in development.
A user can own any number of named leaderboards, each with its own tracked-username list. All endpoints require Authorization: Bearer <token> and act on the authenticated user's own leaderboards (ownership is checked server-side against the JWT's user id — a leaderboard id belonging to someone else 404s rather than exposing another user's data).
| Method | Path | Body / Query |
|---|---|---|
| POST | /api/leaderboards |
{ "name" } |
| GET | /api/leaderboards |
→ { data: [{ id, name, createdAt }] } |
| PUT | /api/leaderboards/:id |
{ "name" } |
| DELETE | /api/leaderboards/:id |
— (cascades, removing every tracked username on that leaderboard) |
Scoped to a single leaderboard, which must belong to the authenticated user.
| Method | Path | Body / Query |
|---|---|---|
| POST | /api/user/add |
{ "username", "leaderboardId" } |
| POST | /api/user/remove |
{ "username", "leaderboardId" } |
| GET | /api/user/fetchusernames |
?leaderboardId=&page=1&limit=20 → { data, pagination } |
| Method | Path | Query | Response |
|---|---|---|---|
| GET | /api/stats |
?usernames=a,b,c (max 10) |
{ data: [Stats, ...] } — see below |
| GET | /api/cf/validate |
?username=x |
{ valid: bool } |
Each Stats object covers rating (rating/maxRating/minRating, contestCount, bestRank/worstRank, maxRatingUp/maxRatingDown, full ratingHistory with per-contest rank), problem-solving (solvedCount/triedCount/unsolvedCount, averageSubmissionsPerSolved, maxSubmissionsOnProblem, solvedWithOneSubmissionPct, maxAcceptedInADay), and breakdowns by difficulty/tag (levels, problemRatings, tags, plus solvedProblemKeys for computing what two handles have solved in common). This backs the frontend's head-to-head compare page — everything pairwise (common solved, common contests) is derived client-side from two of these payloads rather than a dedicated compare endpoint.
Stats are cached in Postgres (cf_stats_cache) for 10 minutes per handle; outbound calls to Codeforces are serialized behind a shared rate limiter (~1 req/2s) regardless of how many concurrent requests hit these endpoints.
See .env.example for all supported environment variables (DB connection, JWT secret/expiry, CORS origin, rate-limit tuning).