- Move admin login lockout from client cookie to AppConfig (DB), keyed by hashed client IP — clearing browser cookies no longer bypasses it - Replace rate-limit $transaction (TOCTOU) with atomic SQL INSERT...ON CONFLICT...RETURNING; add stale-entry cleanup on each submission to prevent table bloat - Add 5 s module-level cache for middleware runtime state fetch, reducing per-request DB roundtrips - Rename middleware.ts → proxy.ts to resolve Next.js 16 deprecation warning; update test import accordingly - Require ADMIN_PASSWORD, ADMIN_AUTH_SECRET, ADMIN_BASIC_AUTH_USER, and ADMIN_BASIC_AUTH_PASS in docker-compose.yml (:? syntax) — startup fails loudly instead of using placeholder defaults - Add set -e and informative echo lines to Dockerfile CMD for clearer startup failure attribution - Export requireAdminAuth() from lib/admin-auth for centralised use in admin pages - Add CLAUDE.md with architecture notes and working rules Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,125 @@
|
|||||||
|
# CLAUDE.md
|
||||||
|
|
||||||
|
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||||
|
|
||||||
|
## Commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Development
|
||||||
|
npm run dev # Start Next.js dev server
|
||||||
|
npm run build # Build for production (--webpack flag applied in package.json)
|
||||||
|
npm run lint # Run ESLint
|
||||||
|
npm run test # Run all tests with Vitest
|
||||||
|
npx vitest run tests/some-file.test.ts # Run a single test file
|
||||||
|
|
||||||
|
# Database
|
||||||
|
npm run prisma:generate # Regenerate Prisma client after schema changes
|
||||||
|
npm run db:migrate # Apply migrations (production)
|
||||||
|
npm run db:migrate:dev # Create and apply dev migration
|
||||||
|
npm run db:seed # Seed the database
|
||||||
|
```
|
||||||
|
|
||||||
|
### Docker (production/staging)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make start # Build and start all containers
|
||||||
|
make stop # Stop containers
|
||||||
|
make deploy # Pull + rebuild + restart
|
||||||
|
make logs # Follow container logs
|
||||||
|
make db-init # Generate client, apply migrations, and seed (first run)
|
||||||
|
make db-shell # Open psql shell
|
||||||
|
make app-shell # Open shell in app container
|
||||||
|
make health # Hit /api/health via public URL
|
||||||
|
```
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
This is a multilingual Next.js (App Router) portfolio site with an admin workspace. Stack: TypeScript, next-intl, Prisma + PostgreSQL, Tailwind CSS, Radix UI, framer-motion, nodemailer.
|
||||||
|
|
||||||
|
### Routing overview
|
||||||
|
|
||||||
|
There are two applications sharing one Next.js instance:
|
||||||
|
|
||||||
|
**Public site** — `app/[locale]/(site)/`
|
||||||
|
Localized routes for `de`, `en`, `ar`. Default locale is dynamic (stored in `AppConfig`), read at request time via `/api/site/default-locale`. Maintenance mode redirects visitors to `/coming-soon`.
|
||||||
|
|
||||||
|
**Admin workspace** — `app/_admin/` (canonical source)
|
||||||
|
Accessed via a dedicated subdomain (`root.mohfarawati.de`) in production, or via the `/root` path prefix in development. The middleware rewrites both to `app/admin-internal/`. The `app/root/` and `app/admin-internal/` directories mirror `app/_admin/` — treat `app/_admin/` as the source of truth.
|
||||||
|
|
||||||
|
The full routing rewrite logic lives in `lib/admin-routing.ts` and `middleware.ts`.
|
||||||
|
|
||||||
|
### i18n
|
||||||
|
|
||||||
|
- Locales: `de`, `en`, `ar` — defined in `i18n/routing.ts`
|
||||||
|
- Default locale is configurable at runtime via `AppConfig` (key: `default_locale`)
|
||||||
|
- `localePrefix: "as-needed"` — default locale has no prefix in URLs
|
||||||
|
- No locale cookie or browser detection; locale is set explicitly by user
|
||||||
|
- Translation messages live in `messages/{locale}.json`
|
||||||
|
|
||||||
|
### Persistence
|
||||||
|
|
||||||
|
Prisma client is in `lib/prisma.ts`. All DB access must go through server-side modules in `lib/`. Client components must never access Prisma.
|
||||||
|
|
||||||
|
`AppConfig` is a key-value table used for all runtime configuration: site settings, SMTP, contact protection, marquee, maintenance mode, default locale. `lib/app-config.ts` is the aggregate entry point; individual settings are in `lib/site-settings.ts`, `lib/mail-settings.ts`, `lib/contact-protection.ts`, `lib/marquee-settings.ts`.
|
||||||
|
|
||||||
|
### Module boundaries
|
||||||
|
|
||||||
|
- `lib/*` — server-side application logic (queries, services, config)
|
||||||
|
- `components/ui/` — shared Radix UI primitives (design system base)
|
||||||
|
- `components/layout/`, `components/site/` — public site UI
|
||||||
|
- `components/admin/`, `components/dashboard/` — admin UI
|
||||||
|
- Server actions (`actions.ts` files in page directories) are the entry points for form submissions; they call `lib/*` modules
|
||||||
|
- Business logic must not live inside UI components
|
||||||
|
|
||||||
|
### Key canonical files
|
||||||
|
|
||||||
|
| Concern | File |
|
||||||
|
|---|---|
|
||||||
|
| i18n routing | `i18n/routing.ts` |
|
||||||
|
| Admin routing logic | `lib/admin-routing.ts` |
|
||||||
|
| Middleware (routing + auth) | `middleware.ts` |
|
||||||
|
| Prisma client | `lib/prisma.ts` |
|
||||||
|
| AppConfig aggregate | `lib/app-config.ts` |
|
||||||
|
| Portfolio queries | `lib/portfolio.ts` |
|
||||||
|
| Media handling | `lib/media.ts` |
|
||||||
|
| Contact flow | `lib/contact-guard.ts`, `lib/mail.ts` |
|
||||||
|
|
||||||
|
### Documentation to read by task scope
|
||||||
|
|
||||||
|
- **Small UI/copy/style fixes**: read only the relevant files
|
||||||
|
- **Feature changes**: read `specs/<feature>.md` + `docs/ARCHITECTURE.md` if structure is affected
|
||||||
|
- **Cross-cutting/architecture changes**: read `docs/ARCHITECTURE.md`, `docs/DOMAIN_RULES.md`, `docs/FEATURES.md`, and the relevant `specs/` file
|
||||||
|
|
||||||
|
Update `docs/` and `specs/` only when the change affects feature scope, business rules, architecture, or public behavior.
|
||||||
|
|
||||||
|
## Environment variables
|
||||||
|
|
||||||
|
Key variables (see `.env.example` for full list):
|
||||||
|
|
||||||
|
```
|
||||||
|
DATABASE_URL PostgreSQL connection string
|
||||||
|
NEXT_PUBLIC_SITE_URL Public site URL
|
||||||
|
NEXT_PUBLIC_ADMIN_URL Admin subdomain URL
|
||||||
|
ADMIN_HOST Admin hostname (used by middleware for host-based routing)
|
||||||
|
ADMIN_PASSWORD In-app admin session password
|
||||||
|
ADMIN_AUTH_SECRET JWT/cookie secret for admin session
|
||||||
|
ADMIN_BASIC_AUTH_USER HTTP Basic Auth user (optional, adds middleware-level protection)
|
||||||
|
ADMIN_BASIC_AUTH_PASS HTTP Basic Auth password
|
||||||
|
SITE_RUNTIME_ORIGIN Internal origin for middleware to fetch runtime state (defaults to http://127.0.0.1:3000 in production)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Working rules
|
||||||
|
|
||||||
|
- Before making any change, first explain the plan briefly and list the files that will be touched.
|
||||||
|
- Make the smallest safe change that solves the task.
|
||||||
|
- Do not modify unrelated files.
|
||||||
|
- Preserve existing architecture, naming, and folder conventions.
|
||||||
|
- Prefer server-side logic in `lib/*` and keep business logic out of UI components.
|
||||||
|
- Never access Prisma from client components.
|
||||||
|
- For admin-related changes, treat `app/_admin/` as the canonical source of truth unless explicitly told otherwise.
|
||||||
|
- Do not add new dependencies unless absolutely necessary and explicitly justified.
|
||||||
|
- After code changes, run only the minimum relevant checks (for example: targeted test, lint on changed files, or build if necessary).
|
||||||
|
- If a task may affect routing, auth, i18n, or runtime config, inspect `middleware.ts`, `lib/admin-routing.ts`, `i18n/routing.ts`, and the relevant `lib/app-config.ts` modules first.
|
||||||
|
- For schema or database changes, inspect Prisma schema, migration flow, and seed impact before editing.
|
||||||
|
- Ask before performing large refactors, file moves, destructive changes, or broad formatting changes.
|
||||||
|
- When updating behavior, also update docs/specs if the change affects public behavior, business rules, or architecture.
|
||||||
+1
-1
@@ -23,4 +23,4 @@ COPY --from=builder /app/next.config.mjs ./next.config.mjs
|
|||||||
|
|
||||||
EXPOSE 3000
|
EXPOSE 3000
|
||||||
|
|
||||||
CMD ["/bin/sh", "-c", "mkdir -p /app/public/uploads/media && npm run db:migrate && npm run start -- --hostname 0.0.0.0 --port 3000"]
|
CMD ["/bin/sh", "-c", "set -e; mkdir -p /app/public/uploads/media && echo '[startup] Running database migrations...' && npm run db:migrate && echo '[startup] Migrations complete. Starting server...' && npm run start -- --hostname 0.0.0.0 --port 3000"]
|
||||||
|
|||||||
+4
-4
@@ -13,10 +13,10 @@ services:
|
|||||||
NEXT_PUBLIC_ADMIN_URL: ${NEXT_PUBLIC_ADMIN_URL:-https://root.mohfarawati.de}
|
NEXT_PUBLIC_ADMIN_URL: ${NEXT_PUBLIC_ADMIN_URL:-https://root.mohfarawati.de}
|
||||||
SITE_RUNTIME_ORIGIN: ${SITE_RUNTIME_ORIGIN:-http://127.0.0.1:3000}
|
SITE_RUNTIME_ORIGIN: ${SITE_RUNTIME_ORIGIN:-http://127.0.0.1:3000}
|
||||||
DATABASE_URL: postgresql://postgres:postgres@db:5432/moh_sass?schema=public
|
DATABASE_URL: postgresql://postgres:postgres@db:5432/moh_sass?schema=public
|
||||||
ADMIN_PASSWORD: ${ADMIN_PASSWORD:-change-me}
|
ADMIN_PASSWORD: ${ADMIN_PASSWORD:?ADMIN_PASSWORD must be set in .env}
|
||||||
ADMIN_AUTH_SECRET: ${ADMIN_AUTH_SECRET:-change-me-long-secret}
|
ADMIN_AUTH_SECRET: ${ADMIN_AUTH_SECRET:?ADMIN_AUTH_SECRET must be set in .env (use a long random string)}
|
||||||
ADMIN_BASIC_AUTH_USER: ${ADMIN_BASIC_AUTH_USER:-root}
|
ADMIN_BASIC_AUTH_USER: ${ADMIN_BASIC_AUTH_USER:?ADMIN_BASIC_AUTH_USER must be set in .env}
|
||||||
ADMIN_BASIC_AUTH_PASS: ${ADMIN_BASIC_AUTH_PASS:-change-me-root}
|
ADMIN_BASIC_AUTH_PASS: ${ADMIN_BASIC_AUTH_PASS:?ADMIN_BASIC_AUTH_PASS must be set in .env}
|
||||||
depends_on:
|
depends_on:
|
||||||
db:
|
db:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
|
|||||||
+88
-22
@@ -1,11 +1,15 @@
|
|||||||
import { createHmac, timingSafeEqual } from "crypto";
|
import { createHash, createHmac, timingSafeEqual } from "crypto";
|
||||||
import { cookies } from "next/headers";
|
import { cookies, headers } from "next/headers";
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
|
|
||||||
|
import { prisma } from "./prisma";
|
||||||
|
import { getAdminAppPath } from "./admin-routing";
|
||||||
|
|
||||||
export const ADMIN_SESSION_COOKIE = "moh_admin_session";
|
export const ADMIN_SESSION_COOKIE = "moh_admin_session";
|
||||||
const ADMIN_FAIL_COOKIE = "moh_admin_fail";
|
|
||||||
const ADMIN_SESSION_VALUE = "superadmin";
|
const ADMIN_SESSION_VALUE = "superadmin";
|
||||||
const MAX_FAILED_ATTEMPTS = 5;
|
const MAX_FAILED_ATTEMPTS = 5;
|
||||||
const LOCKOUT_SECONDS = 15 * 60;
|
const LOCKOUT_SECONDS = 15 * 60;
|
||||||
|
const ADMIN_LOCKOUT_KEY_PREFIX = "admin_lockout";
|
||||||
|
|
||||||
function getSecret(): string {
|
function getSecret(): string {
|
||||||
return process.env.ADMIN_AUTH_SECRET ?? "";
|
return process.env.ADMIN_AUTH_SECRET ?? "";
|
||||||
@@ -114,6 +118,35 @@ function verifyToken(token: string): boolean {
|
|||||||
return timingSafeEqual(left, right);
|
return timingSafeEqual(left, right);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function getClientIp(): Promise<string> {
|
||||||
|
const requestHeaders = await headers();
|
||||||
|
const forwardedFor = requestHeaders.get("x-forwarded-for");
|
||||||
|
|
||||||
|
if (forwardedFor) {
|
||||||
|
return forwardedFor.split(",")[0]?.trim() ?? "unknown";
|
||||||
|
}
|
||||||
|
|
||||||
|
return requestHeaders.get("x-real-ip")?.trim() ?? "unknown";
|
||||||
|
}
|
||||||
|
|
||||||
|
function getLockoutKey(ip: string): string {
|
||||||
|
const hash = createHash("sha256").update(ip).digest("hex").slice(0, 16);
|
||||||
|
return `${ADMIN_LOCKOUT_KEY_PREFIX}:${hash}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function cleanupExpiredLockouts(): Promise<void> {
|
||||||
|
try {
|
||||||
|
const cutoff = new Date(Date.now() - LOCKOUT_SECONDS * 2 * 1000);
|
||||||
|
await prisma.$executeRaw`
|
||||||
|
DELETE FROM "AppConfig"
|
||||||
|
WHERE key LIKE ${`${ADMIN_LOCKOUT_KEY_PREFIX}:%`}
|
||||||
|
AND "updatedAt" < ${cutoff}
|
||||||
|
`;
|
||||||
|
} catch {
|
||||||
|
// Non-critical — ignore cleanup errors.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function isAdminAuthConfigured(): boolean {
|
export function isAdminAuthConfigured(): boolean {
|
||||||
return getPassword().length > 0 && getSecret().length > 0;
|
return getPassword().length > 0 && getSecret().length > 0;
|
||||||
}
|
}
|
||||||
@@ -180,8 +213,14 @@ function parseFailState(rawValue: string | undefined): FailState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function getAdminLockState(): Promise<{ locked: boolean; remainingSeconds: number }> {
|
export async function getAdminLockState(): Promise<{ locked: boolean; remainingSeconds: number }> {
|
||||||
const store = await cookies();
|
try {
|
||||||
const state = parseFailState(store.get(ADMIN_FAIL_COOKIE)?.value);
|
const ip = await getClientIp();
|
||||||
|
const key = getLockoutKey(ip);
|
||||||
|
const config = await prisma.appConfig.findUnique({
|
||||||
|
where: { key },
|
||||||
|
select: { value: true },
|
||||||
|
});
|
||||||
|
const state = parseFailState(config?.value);
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
|
|
||||||
if (state.lockUntil > now) {
|
if (state.lockUntil > now) {
|
||||||
@@ -190,41 +229,57 @@ export async function getAdminLockState(): Promise<{ locked: boolean; remainingS
|
|||||||
remainingSeconds: Math.ceil((state.lockUntil - now) / 1000),
|
remainingSeconds: Math.ceil((state.lockUntil - now) / 1000),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
} catch {
|
||||||
|
// DB unavailable — fail open to avoid blocking the login page.
|
||||||
|
}
|
||||||
|
|
||||||
return { locked: false, remainingSeconds: 0 };
|
return { locked: false, remainingSeconds: 0 };
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function registerFailedAdminAttempt(): Promise<{ locked: boolean; remainingSeconds: number }> {
|
export async function registerFailedAdminAttempt(): Promise<{ locked: boolean; remainingSeconds: number }> {
|
||||||
const store = await cookies();
|
try {
|
||||||
|
const ip = await getClientIp();
|
||||||
|
const key = getLockoutKey(ip);
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
const current = parseFailState(store.get(ADMIN_FAIL_COOKIE)?.value);
|
|
||||||
const attempts = current.lockUntil > now ? current.attempts : current.attempts + 1;
|
await cleanupExpiredLockouts();
|
||||||
|
|
||||||
|
const config = await prisma.appConfig.findUnique({
|
||||||
|
where: { key },
|
||||||
|
select: { value: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
const current = parseFailState(config?.value);
|
||||||
|
// If a previous lockout has expired, reset the counter.
|
||||||
|
const baseAttempts = current.lockUntil > 0 && current.lockUntil < now ? 0 : current.attempts;
|
||||||
|
const attempts = baseAttempts + 1;
|
||||||
const locked = attempts >= MAX_FAILED_ATTEMPTS;
|
const locked = attempts >= MAX_FAILED_ATTEMPTS;
|
||||||
const lockUntil = locked ? now + LOCKOUT_SECONDS * 1000 : 0;
|
const lockUntil = locked ? now + LOCKOUT_SECONDS * 1000 : 0;
|
||||||
|
|
||||||
store.set(ADMIN_FAIL_COOKIE, JSON.stringify({ attempts, lockUntil }), {
|
await prisma.appConfig.upsert({
|
||||||
httpOnly: true,
|
where: { key },
|
||||||
sameSite: "lax",
|
update: { value: JSON.stringify({ attempts, lockUntil }) },
|
||||||
secure: process.env.NODE_ENV === "production",
|
create: { key, value: JSON.stringify({ attempts, lockUntil }) },
|
||||||
path: "/",
|
|
||||||
maxAge: LOCKOUT_SECONDS,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
locked,
|
locked,
|
||||||
remainingSeconds: locked ? LOCKOUT_SECONDS : 0,
|
remainingSeconds: locked ? LOCKOUT_SECONDS : 0,
|
||||||
};
|
};
|
||||||
|
} catch {
|
||||||
|
// DB error — don't lock out so admin can still log in.
|
||||||
|
return { locked: false, remainingSeconds: 0 };
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function resetAdminFailedAttempts(): Promise<void> {
|
export async function resetAdminFailedAttempts(): Promise<void> {
|
||||||
const store = await cookies();
|
try {
|
||||||
store.set(ADMIN_FAIL_COOKIE, "", {
|
const ip = await getClientIp();
|
||||||
httpOnly: true,
|
const key = getLockoutKey(ip);
|
||||||
sameSite: "lax",
|
await prisma.appConfig.deleteMany({ where: { key } });
|
||||||
secure: process.env.NODE_ENV === "production",
|
} catch {
|
||||||
path: "/",
|
// Non-critical — ignore.
|
||||||
maxAge: 0,
|
}
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function isAdminAuthenticated(): Promise<boolean> {
|
export async function isAdminAuthenticated(): Promise<boolean> {
|
||||||
@@ -241,3 +296,14 @@ export async function isAdminAuthenticated(): Promise<boolean> {
|
|||||||
|
|
||||||
return verifyToken(token);
|
return verifyToken(token);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Call at the top of every authenticated admin page or server action.
|
||||||
|
* Clears the session cookie and redirects to the login page if not authenticated.
|
||||||
|
*/
|
||||||
|
export async function requireAdminAuth(): Promise<void> {
|
||||||
|
if (!(await isAdminAuthenticated())) {
|
||||||
|
await clearAdminSessionCookie();
|
||||||
|
redirect(getAdminAppPath("/"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+20
-19
@@ -44,28 +44,29 @@ export async function enforceContactRateLimit(settings: ContactProtectionSetting
|
|||||||
const ip = await getClientIpFromHeaders();
|
const ip = await getClientIpFromHeaders();
|
||||||
const key = getRateLimitKey(ip, settings.rateLimit.windowMinutes);
|
const key = getRateLimitKey(ip, settings.rateLimit.windowMinutes);
|
||||||
|
|
||||||
await prisma.$transaction(async (tx) => {
|
// Clean up stale rate limit entries (older than 2x the window) to prevent table bloat.
|
||||||
const current = await tx.appConfig.findUnique({
|
const cutoffDate = new Date(Date.now() - settings.rateLimit.windowMinutes * 2 * 60 * 1000);
|
||||||
where: { key },
|
await prisma.$executeRaw`
|
||||||
select: { value: true },
|
DELETE FROM "AppConfig"
|
||||||
});
|
WHERE key LIKE ${`${CONTACT_RATE_LIMIT_KEY_PREFIX}:%`}
|
||||||
const nextCount = parseCount(current?.value) + 1;
|
AND "updatedAt" < ${cutoffDate}
|
||||||
|
`;
|
||||||
|
|
||||||
if (nextCount > settings.rateLimit.maxRequests) {
|
// Atomically insert or increment the counter for this IP + window.
|
||||||
|
const result = await prisma.$queryRaw<Array<{ count: number }>>`
|
||||||
|
INSERT INTO "AppConfig" (id, key, value, "createdAt", "updatedAt")
|
||||||
|
VALUES (gen_random_uuid()::text, ${key}, '1', NOW(), NOW())
|
||||||
|
ON CONFLICT (key) DO UPDATE
|
||||||
|
SET value = (CAST("AppConfig".value AS INTEGER) + 1)::text,
|
||||||
|
"updatedAt" = NOW()
|
||||||
|
RETURNING CAST(value AS INTEGER) AS count
|
||||||
|
`;
|
||||||
|
|
||||||
|
const count = result[0]?.count ?? 0;
|
||||||
|
|
||||||
|
if (count > settings.rateLimit.maxRequests) {
|
||||||
throw new Error("Too many contact requests. Please try again later.");
|
throw new Error("Too many contact requests. Please try again later.");
|
||||||
}
|
}
|
||||||
|
|
||||||
await tx.appConfig.upsert({
|
|
||||||
where: { key },
|
|
||||||
update: {
|
|
||||||
value: String(nextCount),
|
|
||||||
},
|
|
||||||
create: {
|
|
||||||
key,
|
|
||||||
value: "1",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function verifyTurnstileToken(
|
export async function verifyTurnstileToken(
|
||||||
|
|||||||
+1
-1
@@ -145,7 +145,7 @@
|
|||||||
},
|
},
|
||||||
"projects": {
|
"projects": {
|
||||||
"eyebrow": "مشاريع مختارة",
|
"eyebrow": "مشاريع مختارة",
|
||||||
"title": "ثلاثة أمثلة على نوع الشغل الذي أحب أن أطلقه.",
|
"title": "مشاريع مميزة",
|
||||||
"description": "الأعمال المختارة تجمع بين جودة واجهة قوية وبنية تقنية تبقى ثابتة بعد الإطلاق.",
|
"description": "الأعمال المختارة تجمع بين جودة واجهة قوية وبنية تقنية تبقى ثابتة بعد الإطلاق.",
|
||||||
"stackLabel": "Stack",
|
"stackLabel": "Stack",
|
||||||
"cta": "فتح المشروع",
|
"cta": "فتح المشروع",
|
||||||
|
|||||||
+1
-1
@@ -145,7 +145,7 @@
|
|||||||
},
|
},
|
||||||
"projects": {
|
"projects": {
|
||||||
"eyebrow": "Ausgewaehlte Projekte",
|
"eyebrow": "Ausgewaehlte Projekte",
|
||||||
"title": "Drei Beispiele fuer die Art von Arbeit, die ich gerne ausliefere.",
|
"title": "Hervorgehobene Projekte",
|
||||||
"description": "Ausgewaehlte Projekte verbinden starke Interface-Qualitaet mit technischer Struktur, die auch nach dem Launch stabil bleibt.",
|
"description": "Ausgewaehlte Projekte verbinden starke Interface-Qualitaet mit technischer Struktur, die auch nach dem Launch stabil bleibt.",
|
||||||
"stackLabel": "Stack",
|
"stackLabel": "Stack",
|
||||||
"cta": "Projekt oeffnen",
|
"cta": "Projekt oeffnen",
|
||||||
|
|||||||
+1
-1
@@ -145,7 +145,7 @@
|
|||||||
},
|
},
|
||||||
"projects": {
|
"projects": {
|
||||||
"eyebrow": "Selected projects",
|
"eyebrow": "Selected projects",
|
||||||
"title": "Three examples of the kind of work I like to ship.",
|
"title": "Highlighted Projects",
|
||||||
"description": "Featured work combines strong interface quality with technical structure that stays stable after launch.",
|
"description": "Featured work combines strong interface quality with technical structure that stays stable after launch.",
|
||||||
"stackLabel": "Stack",
|
"stackLabel": "Stack",
|
||||||
"cta": "Open project",
|
"cta": "Open project",
|
||||||
|
|||||||
@@ -28,6 +28,9 @@ type SiteRuntimeState = {
|
|||||||
maintenanceEnabled: boolean;
|
maintenanceEnabled: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
let runtimeStateCache: { value: SiteRuntimeState; expiresAt: number } | null = null;
|
||||||
|
const RUNTIME_STATE_CACHE_TTL_MS = 5_000;
|
||||||
|
|
||||||
function getSiteRuntimeStateOrigin(request: NextRequest): string {
|
function getSiteRuntimeStateOrigin(request: NextRequest): string {
|
||||||
const configuredOrigin = process.env.SITE_RUNTIME_ORIGIN?.trim();
|
const configuredOrigin = process.env.SITE_RUNTIME_ORIGIN?.trim();
|
||||||
|
|
||||||
@@ -53,6 +56,12 @@ function isComingSoonPath(pathname: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function getSiteRuntimeState(request: NextRequest): Promise<SiteRuntimeState> {
|
async function getSiteRuntimeState(request: NextRequest): Promise<SiteRuntimeState> {
|
||||||
|
const now = Date.now();
|
||||||
|
|
||||||
|
if (runtimeStateCache !== null && runtimeStateCache.expiresAt > now) {
|
||||||
|
return runtimeStateCache.value;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const runtimeStateUrl = new URL("/api/site/default-locale", getSiteRuntimeStateOrigin(request));
|
const runtimeStateUrl = new URL("/api/site/default-locale", getSiteRuntimeStateOrigin(request));
|
||||||
const response = await fetch(runtimeStateUrl, {
|
const response = await fetch(runtimeStateUrl, {
|
||||||
@@ -74,10 +83,14 @@ async function getSiteRuntimeState(request: NextRequest): Promise<SiteRuntimeSta
|
|||||||
maintenanceEnabled?: boolean;
|
maintenanceEnabled?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
return {
|
const value: SiteRuntimeState = {
|
||||||
defaultLocale: isSupportedLocale(data.defaultLocale) ? data.defaultLocale : FALLBACK_LOCALE,
|
defaultLocale: isSupportedLocale(data.defaultLocale) ? data.defaultLocale : FALLBACK_LOCALE,
|
||||||
maintenanceEnabled: data.maintenanceEnabled === true,
|
maintenanceEnabled: data.maintenanceEnabled === true,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
runtimeStateCache = { value, expiresAt: now + RUNTIME_STATE_CACHE_TTL_MS };
|
||||||
|
|
||||||
|
return value;
|
||||||
} catch {
|
} catch {
|
||||||
return {
|
return {
|
||||||
defaultLocale: FALLBACK_LOCALE,
|
defaultLocale: FALLBACK_LOCALE,
|
||||||
@@ -63,7 +63,7 @@ describe("middleware locale runtime config", () => {
|
|||||||
})),
|
})),
|
||||||
);
|
);
|
||||||
|
|
||||||
const { default: middleware } = await import("../middleware");
|
const { default: middleware } = await import("../proxy");
|
||||||
const request = createMockRequest("https://example.com/");
|
const request = createMockRequest("https://example.com/");
|
||||||
|
|
||||||
await middleware(request as never);
|
await middleware(request as never);
|
||||||
@@ -89,7 +89,7 @@ describe("middleware locale runtime config", () => {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
const { default: middleware } = await import("../middleware");
|
const { default: middleware } = await import("../proxy");
|
||||||
const request = createMockRequest("https://example.com/");
|
const request = createMockRequest("https://example.com/");
|
||||||
|
|
||||||
await middleware(request as never);
|
await middleware(request as never);
|
||||||
@@ -113,7 +113,7 @@ describe("middleware locale runtime config", () => {
|
|||||||
})),
|
})),
|
||||||
);
|
);
|
||||||
|
|
||||||
const { default: middleware } = await import("../middleware");
|
const { default: middleware } = await import("../proxy");
|
||||||
const request = createMockRequest("https://example.com/");
|
const request = createMockRequest("https://example.com/");
|
||||||
|
|
||||||
const response = await middleware(request as never);
|
const response = await middleware(request as never);
|
||||||
@@ -136,7 +136,7 @@ describe("middleware locale runtime config", () => {
|
|||||||
})),
|
})),
|
||||||
);
|
);
|
||||||
|
|
||||||
const { default: middleware } = await import("../middleware");
|
const { default: middleware } = await import("../proxy");
|
||||||
const request = createMockRequest("https://example.com/");
|
const request = createMockRequest("https://example.com/");
|
||||||
|
|
||||||
await middleware(request as never);
|
await middleware(request as never);
|
||||||
|
|||||||
Reference in New Issue
Block a user