Skip to content

About

Resources

Stars

0 stars

Watchers

0 watching

Forks

Repository files navigation

portfolio-engine

A portfolio that is also a working product. Live at theppratan.dev — not a static résumé page, but a running full-stack application with real authentication, a real database, a protected file vault, and a game you can play against a live global leaderboard.

Built by Theppratan Junpanya — Computer Engineering & Business Administration graduate. Bangkok / Nonthaburi, remote-ready.

🇹🇭 สรุปสั้น ๆ: เว็บพอร์ตโฟลิโอที่ไม่ใช่หน้าเว็บนิ่ง ๆ แต่เป็นระบบจริงที่รันอยู่บน production มีระบบสมาชิก ฐานข้อมูลจริง พื้นที่ไฟล์ที่ป้องกันด้วยการยืนยันตัวตน และมินิเกมที่บันทึกคะแนนลงกระดานอันดับจริง เขียนขึ้นเองทั้งหมด ไม่ใช้เทมเพลต — เอกสารภายในฉบับละเอียดอยู่ที่ PROGRESS.md


Why this exists

Most junior portfolios are a list of claims. This one is built so the claims can be verified in the browser, in under two minutes:

The claim Where you can check it yourself
"I can build secure authentication" Sign up on the site. The session cookie is HttpOnly — open DevTools and confirm JavaScript cannot read it.
"I can protect resources server-side" GET /api/files/resume returns 401 without a session. The file lives outside public/, so there is no URL to guess.
"I understand rate limiting" Enter a wrong password 5 times. The 5th attempt returns 429, not another 401.
"I write real-time interactive code" Play the mini-game — a requestAnimationFrame loop with fixed-timestep physics, written from scratch on <canvas>.
"I care about data integrity" Try POST /api/leaderboard with score: 999999999. The server rejects it.
"I ship to real users" The Chan Ni-Yom project in the vault is a live restaurant platform: channiyom.com

The stack

Layer Choice Why this one
Framework Next.js 16 (App Router, Turbopack) One codebase for UI and API. Route Handlers keep secrets on the server.
Language TypeScript 5 (strict: true) Type errors fail the build — the production build is type-checked, not skipped.
UI React 19 + Tailwind CSS v3 Utility classes bound to CSS custom properties, so theming is one attribute swap.
Database PostgreSQL (Supabase) Relational data with real foreign keys and cascade rules.
ORM Prisma 5 Typed queries; the schema is the single source of truth.
Auth Session tokens in DB + HttpOnly cookies Revocable server-side — see the design note below.
OAuth Supabase Auth (Google / GitHub) Federated login without storing third-party credentials.
Hosting Vercel (sin1, Singapore) Edge caching close to the audience.

No UI kit, no template, no create-*-app starter beyond the initial scaffold. Every component in src/components/ was written for this project.


Architecture

flowchart TD
    subgraph Browser
        UI["React client components<br/>Theme + Language context"]
        EV{{"window CustomEvent bus<br/>authStateChanged · triggerVaultLogout"}}
        UI <--> EV
    end

    subgraph Vercel["Next.js 16 on Vercel"]
        RH["Route Handlers<br/>/api/*"]
        SESS["lib/session.ts<br/>single auth checkpoint"]
        SEC["lib/security.ts<br/>rate limit · validation"]
        RH --> SESS
        RH --> SEC
    end

    subgraph Data
        PG[("PostgreSQL / Supabase<br/>users · sessions<br/>audit_logs · leaderboards")]
        FILE["private/resume.pdf<br/>outside the public folder"]
    end

    SB(["Supabase Auth<br/>Google · GitHub"])

    UI -- "fetch + HttpOnly cookie" --> RH
    UI -- "OAuth redirect" --> SB
    SB -- "access token verified server-side" --> RH
    SESS --> PG
    SEC --> PG
    RH -- "only with a valid session" --> FILE
Loading

One rule holds the whole thing together: the client never decides who you are. isUnlocked in React only controls what is drawn. Every protected resource re-validates the cookie against the sessions table on the server, on every request.


Authentication design

Two front doors, one lock:

Email + password ──► bcrypt verify ──┐
                                     ├──► session row in DB ──► HttpOnly cookie
Google / GitHub ──► Supabase ────────┘         (2h absolute)
                    (token verified server-side)

Session tokens instead of JWT — and why

JWTs are self-contained, which means you cannot revoke one before it expires. For a site with a "log out everywhere" requirement and an idle-timeout demo, that is the wrong trade. Sessions live in Postgres, so:

  • logging out deletes the row — the token dies instantly, not in 15 minutes
  • changing your password revokes every other device in the same transaction
  • deleting your account cascades sessions away via a database constraint, not application code

The cost is one indexed lookup per request. For this workload that is the right price to pay.

Four layers of session lifetime

Layer Duration Mechanism
Absolute timeout 2 hours Cookie expiry and sessions.expires_at — both must be valid
Sliding renewal +2 hours Refreshed whenever GET /api/user/settings is called
Idle timeout configurable Client-side warning + countdown, then a forced master logout
Tab close immediate sessionStorage flag disappears; next load forces a full logout

The idle timer is deliberately careful about one thing: it checks whether a <video> is playing before it fires. Kicking someone out mid-demo-video is a bug, not a security feature.


Security posture

Concrete measures, not a checklist:

Area Implementation
Password storage bcrypt, cost 10 (cost 12 for admin credentials)
Session cookie HttpOnly + Secure + SameSite=Strict
Brute force Failed attempts counted from audit_logs over a 15-minute window → 429. Counting from the database rather than memory is deliberate: serverless instances do not share memory, so an in-process counter would not actually limit anything.
User enumeration Unknown account and wrong password return the same message and status
Privilege escalation Registration hard-codes role: GUEST; the admin role can only be granted by a CLI script run by the operator
OAuth spoofing The access token is verified against Supabase server-side; the email in the request body is ignored in favour of the verified one
Input validation Email format, password length, and score plausibility are all enforced server-side, never trusted from the client
Protected files Stored in private/, streamed only after session validation, Cache-Control: no-store
Audit trail 15+ event types recorded with actor, detail and IP address
Response headers nosniff, SAMEORIGIN, Referrer-Policy, Permissions-Policy; X-Powered-By removed
Error handling Stack traces are logged server-side and never returned to the client

Data model

User ─┬─1:N─ Session      (sessionToken unique, expiresAt)      [cascade delete]
      ├─1:N─ AuditLog     (action, details, ipAddress)          [set null on delete]
      └─1:1─ Leaderboard  (playerName, score)                   [cascade delete]

Cascade behaviour is defined in the schema, not in application code — deleting a user cannot leave orphaned sessions behind, even if a future code path forgets to clean up.

Indexes exist for the queries that actually run: audit_logs(action, created_at) powers the rate limiter, sessions(user_id) powers bulk revocation.


API surface

Endpoint Method Auth Purpose
/api/auth/register POST — Create account (validated, rate limited)
/api/auth/login POST — Verify password, issue session
/api/auth/sync POST OAuth token Exchange a verified Supabase session for ours
/api/auth/logout POST cookie Delete session row, force-clear cookie
/api/user/settings GET cookie Profile + sliding session renewal
/api/user/settings PATCH cookie Update display name
/api/user/settings PUT cookie Change password, revoke other sessions
/api/user/settings DELETE cookie Delete account (password-confirmed)
/api/leaderboard GET optional Top 10, flags the caller's own row
/api/leaderboard POST cookie Submit score (validated, keeps personal best)
/api/leaderboard DELETE cookie Remove own record
/api/files/resume GET cookie Stream protected PDF + audit log
/api/system-status POST token Health check with a real database ping

What's in the vault

Signing in unlocks four case studies:

Project Stack Status
v01 AI content & YouTube channels Generative AI · video production Live
v02 Witch 2.5D RPG Unity · C# · WebGL In development
v03 VR motorcycle assembly Unity · Meta Quest · web dashboard Delivered
v04 Chan Ni-Yom restaurant platform .NET 9 Blazor Server · PostgreSQL Live in production

v04 is the one to look at. channiyom.com is a working ordering platform for a real Thai restaurant on Chan Road, Rama 3 — bilingual storefront, member accounts, cart, and a separate staff console on admin.channiyom.com where the kitchen manages menu items and prices without a code deploy. It ships with cookie consent and a written privacy policy because it handles real customer data.

It is the web tier of a four-repository system: the shipped web application, a core services backend, a mobile client, and an AR feature — the latter three still to be built, in that order.


Running it locally

npm install

Create .env in the project root:

DATABASE_URL="postgresql://..."            # Supabase pooled connection
DIRECT_URL="postgresql://..."              # direct connection, for migrations
NEXT_PUBLIC_SUPABASE_URL="https://....supabase.co"
NEXT_PUBLIC_SUPABASE_ANON_KEY="..."
SYSTEM_STATUS_TOKEN="..."                  # optional, overrides the demo token
npx prisma generate
npx prisma db push       # first-time schema sync
npm run dev              # http://localhost:3000
Script Does
npm run dev Development server (Turbopack)
npm run build prisma generate + type-checked production build
npm run lint ESLint (flat config)
npm run typecheck tsc --noEmit

Creating an admin account — never hard-coded, always explicit:

[email protected] ADMIN_PASSWORD='a-long-random-passphrase' node --env-file=.env scripts/manage-admin.mjs

Project layout

src/
├── app/
│   ├── layout.tsx              Theme + language providers, SEO metadata
│   ├── page.tsx                Single-page composition of every section
│   ├── opengraph-image.tsx     Social share card, generated at build time
│   ├── robots.ts · sitemap.ts  Generated, not hand-maintained
│   └── api/                    Route Handlers (auth, leaderboard, files, health)
├── components/
│   ├── Play.tsx                Canvas game loop + live leaderboard
│   ├── Vault.tsx               Auth flows, protected project viewer, idle timeout
│   ├── Console.tsx             Interactive terminal that calls the real health API
│   └── vault-projects/         One component per case study, code-split on open
├── context/                    Language (TH/EN) and theme (light/dark) providers
└── lib/
    ├── prisma.ts               Client singleton
    ├── session.ts              The single authentication checkpoint
    └── security.ts             Rate limiting, IP extraction, input validation

Engineering decisions worth defending

Global event bus instead of a state library. Six components need to react to "the user logged out." Redux or Zustand would work, but this app has exactly one piece of cross-cutting state. window.dispatchEvent(new Event("triggerVaultLogout")) handles it with zero dependencies, and logout logic exists in exactly one function — no duplicated cleanup drifting out of sync.

Design tokens as CSS custom properties. Theme switching sets one data-theme attribute on <html>. No re-render, no flash, no theme provider re-computing class names — and the <canvas> game reads the same variables, so the game recolours with the site.

Everything the client sends is treated as hostile. Score is validated for plausibility. The OAuth email is discarded in favour of the token-verified one. role is never read from a request body. The client is a rendering surface, not an authority.

Bilingual from the first commit. Every string ships as { en, th }. Retrofitting i18n is expensive; doing it from the start costs almost nothing.


Contact

Theppratan Junpanya ✉ [email protected] · 💼 LinkedIn · 💻 GitHub

Résumé is available on the site after signing in — that gate is itself part of the demo.

About

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages