Files
goodgo-platform/apps/web/middleware.ts
Ho Ngoc Hai 1ae36f7f98
Some checks failed
CI / Lint → Typecheck → Test → Build (22) (push) Failing after 6s
CI / E2E Tests (push) Has been skipped
CI / AI Services (Python) — Smoke (push) Failing after 6s
CodeQL Analysis / CodeQL (javascript-typescript) (push) Failing after 1m10s
Deploy / Build API Image (push) Failing after 13s
Deploy / Build Web Image (push) Failing after 6s
Deploy / Build AI Services Image (push) Failing after 8s
E2E Tests / Playwright E2E (push) Failing after 11s
Deploy / Deploy to Staging (push) Has been skipped
Deploy / Smoke Test Staging (push) Has been skipped
Deploy / Deploy to Production (push) Has been skipped
Deploy / Smoke Test Production (push) Has been skipped
Deploy / Rollback Staging (push) Has been skipped
Deploy / Rollback Production (push) Has been skipped
Security Scanning / Trivy Scan — API Image (push) Failing after 1m52s
Security Scanning / Trivy Scan — Web Image (push) Failing after 56s
Security Scanning / Trivy Scan — AI Services Image (push) Failing after 49s
Security Scanning / Trivy Filesystem Scan (push) Failing after 1m2s
Security Scanning / Dependency Audit (pnpm) (push) Failing after 11m25s
Security Scanning / Security Gate (push) Has been cancelled
fix(auth+web): unblock test accounts + public catalog routes
Two unrelated production blockers came up while exercising the live
deploy:

1. Auth rate limit too aggressive (5 req/h)
   The throttler hit `429 Too Many Requests` after just five login
   attempts — testers (and the post-login refresh churn the SPA does
   on cold start) were locking themselves out almost immediately.

   - `auth.controller.ts`: `AUTH_RATE_LIMIT` and the per-IP login burst
     limit are now read from env vars (`AUTH_RATE_LIMIT`,
     `AUTH_PER_IP_LIMIT`), default 5 in production but easy to raise
     for staging without redeploying. Cluster ConfigMap now sets
     200 / 100 respectively.

   - `throttler-behind-proxy.guard.ts`: added `shouldSkip()` that
     bypasses throttling entirely when the request body or JWT
     identifies a seed / demo account (admin + 10 seeded buyer /
     seller / agent / developer / park-operator phones). Also reads
     `THROTTLER_BYPASS_PHONES` and `_EMAILS` env vars so the ops team
     can temporarily allow-list a tester's number without code change.

2. `/khu-cong-nghiep` (and 6 other public catalog pages) redirected
   anonymous users to `/login`
   The Next.js middleware allow-list only covered `/login`, `/register`,
   `/search`, `/listings`, `/auth/callback`. Visiting the industrial
   parks catalog without a session sent users straight to a login
   wall — broken UX since the catalog is supposed to be public.

   Added these prefixes to `publicPaths`:
     /khu-cong-nghiep   (industrial parks)
     /du-an             (real estate projects)
     /chuyen-nhuong     (property transfers)
     /bang-gia          (pricing)
     /forgot-password
     /reset-password
     /about /contact /privacy /terms

Verified live (https://platform.goodgo.vn after rollout):
  - 50 logins in a row with seed-admin → 50× 201, 0× 429
  - Anonymous access: /khu-cong-nghiep, /du-an, /chuyen-nhuong,
    /search, /listings, /khu-cong-nghiep/thang-long → all 200

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-30 15:35:13 +07:00

66 lines
1.9 KiB
TypeScript

import { NextResponse, type NextRequest } from 'next/server';
import createIntlMiddleware from 'next-intl/middleware';
import { routing } from '@/i18n/routing';
const intlMiddleware = createIntlMiddleware(routing);
// Anonymous users may visit these paths without being redirected to /login.
// Use prefixes — every nested route under each entry is also public.
const publicPaths = [
'/login',
'/register',
'/auth/callback',
'/forgot-password',
'/reset-password',
'/search',
'/listings',
'/khu-cong-nghiep', // industrial parks catalog + detail
'/du-an', // projects (real estate developments)
'/chuyen-nhuong', // property transfers
'/bang-gia', // pricing
'/about',
'/contact',
'/privacy',
'/terms',
];
const publicExactPaths = ['/'];
const authOnlyPaths = ['/login', '/register'];
function isPublicPath(pathname: string): boolean {
return (
publicExactPaths.includes(pathname) ||
publicPaths.some((path) => pathname.startsWith(path))
);
}
function stripLocale(pathname: string): string {
const localePattern = /^\/(vi|en)(\/|$)/;
return pathname.replace(localePattern, '/') || '/';
}
export function middleware(request: NextRequest) {
const { pathname } = request.nextUrl;
const strippedPath = stripLocale(pathname);
const hasAuthCookie = request.cookies.has('goodgo_authenticated');
if (!isPublicPath(strippedPath) && !hasAuthCookie) {
const loginUrl = new URL('/login', request.url);
loginUrl.searchParams.set('redirect', strippedPath);
return NextResponse.redirect(loginUrl);
}
const isAuthOnly = authOnlyPaths.some((path) =>
strippedPath.startsWith(path),
);
if (isAuthOnly && hasAuthCookie) {
return NextResponse.redirect(new URL('/dashboard', request.url));
}
return intlMiddleware(request);
}
export const config = {
matcher: ['/((?!api|_next/static|_next/image|favicon.ico|public).*)'],
};