Skip to content

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

1 Commit
 
 
 
 
 
 

Repository files navigation

LinkForge — URL shortener with click analytics

A deliberately small 3-tier demo app, built to be a testbed for real infra concepts — not just a CRUD project. The UI is intentionally polished (vibe-coded, no shame in that); the backend is the part meant to be read, modified, and rebuilt on by hand as you go through the roadmap.

Structure (maps directly to your 3 tiers)

linkforge/
├── frontend/         # Tier 1 — React (Vite), deploy build output on its own droplet behind Nginx
├── backend/          # Tier 2 — Spring Boot REST API, deploy on its own droplet(s)
└── (DB not included) # Tier 3 — Postgres, its own droplet — see Day 10+ below

Running locally first (before any droplet)

The backend defaults to an in-memory H2 database, so it runs with zero setup — good for sanity-checking the code works before you touch a server.

cd backend
mvn spring-boot:run

Frontend (React + Vite):

cd frontend
npm install
npm run dev

Opens on http://localhost:5173 by default and talks to http://localhost:8080. To point it at a real backend later, set the env var before building/running:

VITE_API_BASE=http://YOUR_BACKEND_IP:8080 npm run build

The build output goes to frontend/dist/ — that's what you copy onto the frontend Droplet and serve with Nginx (just static files, no Node needed to run it in prod, only to build it).

API surface

Method Path Auth required? Purpose
POST /api/auth/register No Create an account
POST /api/auth/login No Step 1 of login (password check)
POST /api/auth/2fa/verify No* Step 2 of login (TOTP code) *needs pendingToken
POST /api/auth/2fa/enable Yes Start 2FA enrollment, get secret + QR URL
POST /api/auth/2fa/confirm Yes Finish enrollment by proving a code works
POST /api/links Yes Create a short link — { "longUrl": "..." }
GET /r/{shortCode} No Redirect (the hot path — cache & LB this)
GET /api/links/{code}/stats No Fetch click count for a code

Auth + 2FA flow (read this before touching the code)

Login without 2FA enabled:

POST /api/auth/login {username, password}
→ { requires2fa: false, token: "<JWT>" }

Use that token as Authorization: Bearer <JWT> on protected endpoints.

Login with 2FA enabled:

POST /api/auth/login {username, password}
→ { requires2fa: true, pendingToken: "<short-lived JWT>" }

POST /api/auth/2fa/verify {pendingToken, code}
→ { requires2fa: false, token: "<JWT>" }

The pendingToken is deliberately useless for anything except /2fa/verify — it has a purpose: pending_2fa claim, and JwtAuthFilter only accepts tokens with purpose: access. This is the actual security property: a leaked password alone never produces a working session token.

Enrolling in 2FA (once logged in):

POST /api/auth/2fa/enable        (needs Authorization header)
→ { secret: "BASE32SECRET", otpauthUrl: "otpauth://totp/..." }

Feed otpauthUrl into any QR generator (e.g. qrencode or an online tool) and scan it with Google Authenticator / Authy, or type secret in manually. Then:

POST /api/auth/2fa/confirm {code}   (needs Authorization header)

Only after this call succeeds does totpEnabled flip to true — enrolling doesn't silently turn on 2FA, you have to prove the code actually works first.

Testing the whole flow with curl, no frontend needed:

curl -X POST localhost:8080/api/auth/register -H 'Content-Type: application/json' \
  -d '{"username":"you","email":"[email protected]","password":"password123"}'

curl -X POST localhost:8080/api/auth/login -H 'Content-Type: application/json' \
  -d '{"username":"you","password":"password123"}'
# copy the token, then:
curl -X POST localhost:8080/api/links -H 'Content-Type: application/json' \
  -H 'Authorization: Bearer <token>' -d '{"longUrl":"https://example.com"}'

Enforcement toggle: app.security.enabled in application.properties controls whether /api/links actually requires login. It defaults to false right now, so your frontend and the Day 1-9 deployment/proxy/LB stages work exactly as before — no login screen needed yet. The full auth + 2FA code is still there and testable via curl regardless of the flag (see below). Flip it to true once you're ready to wire a login UI into the frontend, or just want to practice with real enforcement on.

Where each roadmap stage plugs in

  • Day 1-3, manual deploy: backend → one Droplet, frontend → a second Droplet (or same box, different port, behind Nginx path routing — your call).
  • Day 4-5, reverse/forward proxy + rate limiting: put Nginx in front of the backend. Rate-limit POST /api/links (creation) — do not rate-limit GET /r/{code} (redirects should stay fast and open).
  • Day 6-9, load balancer + algorithms + load testing: run 2+ backend instances, point wrk/ab/hey at GET /r/{code} since that's the real hot path.
  • Day 10-14, DB, pooling, caching: switch application.properties from H2 to Postgres (the commented block is ready to go), add PgBouncer/HikariCP tuning, then put Redis in front of the shortCode → longUrl lookup in ShortenerService.resolve().
  • Day 15-17, Kafka: replace the synchronous clickEventRepository.save(...) call in ShortenerService.resolve() with a Kafka producer call, and move that save into a consumer. That single line is the whole "decouple the write path" lesson.

What's deliberately left for you to build

  • Kafka producer/consumer wiring
  • Redis cache-aside logic in resolve()
  • Rate limiting config (Nginx-level, not app-level, per the roadmap)
  • Postgres schema migration (JPA ddl-auto=update is fine for now, but a real project would use Flyway/Liquibase — worth trying once the basics are solid)
  • Sharding logic if you get to it — shortCode prefix is a reasonable shard key to try

About

Secure full-stack URL shortener with Spring Boot REST API, Spring Security (JWT), BCrypt, and TOTP-based 2FA — collision-resistant short-code generation with 100M+ URL keyspace.

Topics

Resources

Stars

Watchers

Forks

Releases

Packages

Used by

Contributors

Languages