chore: harden deps, env validation, admin errors, and redis warnings
Align nodemailer with Auth.js peers, bump patch deps, validate env on deploy builds, add admin error boundary, and warn when Redis is missing in production. Co-authored-by: Cursor <[email protected]>
This commit is contained in:
1 parent
c865e6f699
commit
c46dadeda4
10 files changed
+876
-461
No files matched your search
+7
-4
@@ -50,7 +50,10 @@ DISCORD_CLIENT_SECRET=
|
||||
GOOGLE_CLIENT_ID=
|
||||
GOOGLE_CLIENT_SECRET=
|
||||
|
||||
# Optional SMTP (password reset / alert emails)
|
||||
# Preferred email provider (HTTP API). Used before SMTP when set.
|
||||
RESEND_API_KEY=
|
||||
|
||||
# Optional SMTP fallback (password reset / alert emails)
|
||||
SMTP_HOST=
|
||||
SMTP_PORT=587
|
||||
SMTP_USER=
|
||||
@@ -71,9 +74,9 @@ PAYPAL_CLIENT_ID=
|
||||
PAYPAL_SECRET=
|
||||
PAYPAL_API=https://api-m.sandbox.paypal.com
|
||||
|
||||
# Optional Redis — enables shared caching for rate limiting and site settings,
|
||||
# allowing horizontal scaling across multiple instances. Falls back to in-process
|
||||
# Maps when unset.
|
||||
# Redis — strongly recommended in production (required for multi-instance).
|
||||
# Shared rate limiting + site-settings cache. Without it, limits are in-process
|
||||
# only and do not hold across restarts or multiple app instances.
|
||||
REDIS_URL=redis://127.0.0.1:6379
|
||||
|
||||
# Logging level (debug | info | warn | error). Defaults to 'info' in production,
|
||||
|
||||
@@ -292,7 +292,8 @@ jobs:
|
||||
pnpm prisma:generate
|
||||
pnpm typecheck
|
||||
pnpm test
|
||||
export SKIP_ENV_VALIDATION=1
|
||||
# Validate production env (AUTH_SECRET, DATABASE_URL, …) during build.
|
||||
# Do not set SKIP_ENV_VALIDATION here — that flag is for tests/tooling only.
|
||||
pnpm build
|
||||
|
||||
sudo chown -R www-data:www-data /var/www/atom-nexst/
|
||||
|
||||
@@ -7,12 +7,15 @@
|
||||
"src/app/**/{error,not-found,loading,template,default,global-error}.{ts,tsx}",
|
||||
"src/app/**/actions.{ts,tsx}",
|
||||
"next.config.ts",
|
||||
"src/middleware.ts"
|
||||
"src/proxy.ts",
|
||||
"src/instrumentation.ts",
|
||||
"sentry.{server,edge}.config.ts"
|
||||
],
|
||||
"project": ["src/**/*.{ts,tsx}"],
|
||||
"ignore": [
|
||||
"src/**/*.test.{ts,tsx}",
|
||||
"scripts/**"
|
||||
],
|
||||
"ignoreDependencies": ["@types/*", "knip"]
|
||||
"ignoreDependencies": ["@types/*", "knip"],
|
||||
"ignoreBinaries": ["corepack"]
|
||||
}
|
||||
+12
-11
@@ -15,6 +15,7 @@
|
||||
"biome:check": "biome check --write .",
|
||||
"biome:lint": "biome lint .",
|
||||
"biome:format": "biome format --write .",
|
||||
"knip": "knip",
|
||||
"analyze": "ANALYZE=true pnpm build",
|
||||
"test": "vitest run",
|
||||
"db:migrate": "tsx scripts/apply-migrations.ts",
|
||||
@@ -27,9 +28,9 @@
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@hookform/resolvers": "^5.4.0",
|
||||
"@prisma/adapter-mariadb": "^7.8.0",
|
||||
"@prisma/client": "^7.8.0",
|
||||
"@sentry/nextjs": "^10.66.0",
|
||||
"@prisma/adapter-mariadb": "^7.9.0",
|
||||
"@prisma/client": "^7.9.0",
|
||||
"@sentry/nextjs": "^10.67.0",
|
||||
"@tanstack/react-virtual": "^3.14.6",
|
||||
"bcryptjs": "^3.0.2",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
@@ -49,13 +50,13 @@
|
||||
"mysql2": "^3.23.0",
|
||||
"next": "^16.2.11",
|
||||
"next-auth": "5.0.0-beta.31",
|
||||
"next-intl": "^4.13.2",
|
||||
"next-intl": "^4.13.3",
|
||||
"next-view-transitions": "^0.3.5",
|
||||
"nodemailer": "^9.0.3",
|
||||
"nodemailer": "^7.0.13",
|
||||
"otplib": "^12.0.1",
|
||||
"pino": "^10.3.1",
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0",
|
||||
"react": "^19.2.8",
|
||||
"react-dom": "^19.2.8",
|
||||
"react-hook-form": "^7.81.0",
|
||||
"resend": "^6.17.2",
|
||||
"sanitize-html": "^2.17.6",
|
||||
@@ -73,16 +74,16 @@
|
||||
"@tailwindcss/postcss": "^4.3.3",
|
||||
"@tailwindcss/typography": "^0.5.20",
|
||||
"@types/node": "^22.10.0",
|
||||
"@types/nodemailer": "^6.4.0",
|
||||
"@types/react": "^19.2.0",
|
||||
"@types/react-dom": "^19.2.0",
|
||||
"@types/nodemailer": "^7.0.12",
|
||||
"@types/react": "^19.2.17",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@types/sanitize-html": "^2.16.1",
|
||||
"babel-plugin-react-compiler": "^1.0.0",
|
||||
"dotenv": "^16.4.0",
|
||||
"knip": "^6.27.0",
|
||||
"pino-pretty": "^13.1.3",
|
||||
"postcss": "^8.5.19",
|
||||
"prisma": "^7.8.0",
|
||||
"prisma": "^7.9.0",
|
||||
"tailwindcss": "^4.3.3",
|
||||
"tsx": "^4.23.1",
|
||||
"typescript": "^5.7.0",
|
||||
|
||||
Generated
+752
-437
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,69 @@
|
||||
"use client";
|
||||
|
||||
import * as Sentry from "@sentry/nextjs";
|
||||
import { LayoutDashboard, RefreshCw } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useEffect } from "react";
|
||||
|
||||
/**
|
||||
* Admin route-segment error boundary. Renders inside the admin layout shell.
|
||||
*/
|
||||
export default function AdminErrorPage({
|
||||
error,
|
||||
reset,
|
||||
}: {
|
||||
error: Error & { digest?: string };
|
||||
reset: () => void;
|
||||
}) {
|
||||
const t = useTranslations("pages.error");
|
||||
const tNav = useTranslations("admin.nav");
|
||||
|
||||
useEffect(() => {
|
||||
console.error(error);
|
||||
Sentry.captureException(error);
|
||||
}, [error]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="rounded-xl border p-8 max-w-lg mx-auto mt-8 text-center"
|
||||
style={{
|
||||
borderColor: "var(--admin-border, rgba(0,0,0,0.08))",
|
||||
background: "var(--admin-surface, #fff)",
|
||||
}}
|
||||
role="alert"
|
||||
>
|
||||
<p
|
||||
className="text-sm font-bold uppercase tracking-wider mb-2"
|
||||
style={{ color: "var(--admin-muted, #6b7280)" }}
|
||||
>
|
||||
{t("code")}
|
||||
</p>
|
||||
<h1 className="text-xl font-extrabold mb-2">{t("title")}</h1>
|
||||
<p className="text-sm mb-1" style={{ color: "var(--admin-muted, #6b7280)" }}>
|
||||
{t("subtitle")}
|
||||
</p>
|
||||
<p className="text-sm mb-6" style={{ color: "var(--admin-muted, #6b7280)" }}>
|
||||
{t("body")}
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-3 justify-center">
|
||||
<button type="button" className="btn btn-primary" onClick={() => reset()}>
|
||||
<RefreshCw className="size-4" aria-hidden />
|
||||
{t("tryAgain")}
|
||||
</button>
|
||||
<Link className="btn btn-outline" href="/admin">
|
||||
<LayoutDashboard className="size-4" aria-hidden />
|
||||
{tNav("dashboard")}
|
||||
</Link>
|
||||
</div>
|
||||
{error.digest ? (
|
||||
<p
|
||||
className="mt-6 text-xs font-mono"
|
||||
style={{ color: "var(--admin-muted, #9ca3af)" }}
|
||||
>
|
||||
{t("reference", { digest: error.digest })}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+4
-3
@@ -69,7 +69,8 @@ const schema = z.object({
|
||||
PAYPAL_CLIENT_ID: z.string().optional(),
|
||||
PAYPAL_SECRET: z.string().optional(),
|
||||
PAYPAL_API: z.string().url().optional(),
|
||||
// Optional Redis — enables shared caching for rate limiting and site settings.
|
||||
// Redis — strongly recommended in production (required for multi-instance).
|
||||
// Without it, rate limits / shared caches are in-process only.
|
||||
REDIS_URL: z.string().optional(),
|
||||
// Logging level.
|
||||
LOG_LEVEL: z.enum(["debug", "info", "warn", "error"]).optional(),
|
||||
@@ -95,8 +96,8 @@ const schema = z.object({
|
||||
|
||||
type Env = z.infer<typeof schema>;
|
||||
|
||||
// SKIP_ENV_VALIDATION lets tooling (typecheck, tests that don't touch the DB)
|
||||
// import modules transitively without a populated .env.
|
||||
// SKIP_ENV_VALIDATION is for tooling only (vitest). Production deploy must NOT
|
||||
// set this — builds should validate AUTH_SECRET, DATABASE_URL, etc.
|
||||
export const env: Env = process.env.SKIP_ENV_VALIDATION
|
||||
? (process.env as unknown as Env)
|
||||
: schema.parse(process.env);
|
||||
@@ -13,7 +13,8 @@ describe("production deploy workflow", () => {
|
||||
expect(workflow).not.toMatch(/rm\s+-rf\s+\.next(?:\s|$)/);
|
||||
expect(workflow).toContain("rm -rf .output dist .next/types .next/dev");
|
||||
expect(workflow).toContain("pnpm install --frozen-lockfile");
|
||||
expect(workflow).toContain("SKIP_ENV_VALIDATION=1");
|
||||
// Production builds must validate env (AUTH_SECRET, DATABASE_URL, …).
|
||||
expect(workflow).not.toContain("SKIP_ENV_VALIDATION=1");
|
||||
});
|
||||
|
||||
it("reclaims ownership before git reset so www-data files can be overwritten", () => {
|
||||
|
||||
@@ -13,6 +13,7 @@ const CLEANUP_INTERVAL_MS = 300_000;
|
||||
const MAX_BUCKETS = 10_000;
|
||||
|
||||
let lastCleanup = Date.now();
|
||||
let redisFailWarned = false;
|
||||
|
||||
function cleanup(): void {
|
||||
const now = Date.now();
|
||||
@@ -54,6 +55,12 @@ export async function rateLimit(
|
||||
return { ok: true, retryAfter: 0 };
|
||||
} catch {
|
||||
// Redis unavailable — fall through to in-memory
|
||||
if (process.env.NODE_ENV === "production" && !redisFailWarned) {
|
||||
redisFailWarned = true;
|
||||
console.error(
|
||||
"[rate-limit] Redis error — falling back to in-process buckets. Limits are not shared across instances until Redis recovers.",
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+16
-2
@@ -2,11 +2,25 @@ import "server-only";
|
||||
|
||||
import Redis from "ioredis";
|
||||
|
||||
const globalForRedis = globalThis as unknown as { redis?: Redis | null };
|
||||
const globalForRedis = globalThis as unknown as {
|
||||
redis?: Redis | null;
|
||||
redisMissingWarned?: boolean;
|
||||
};
|
||||
|
||||
function createRedis(): Redis | null {
|
||||
const url = process.env.REDIS_URL;
|
||||
if (!url) return null;
|
||||
if (!url) {
|
||||
if (
|
||||
process.env.NODE_ENV === "production" &&
|
||||
!globalForRedis.redisMissingWarned
|
||||
) {
|
||||
globalForRedis.redisMissingWarned = true;
|
||||
console.error(
|
||||
"[redis] REDIS_URL is unset in production. Rate limits and shared caches fall back to in-process memory and will not work correctly across multiple instances.",
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
const client = new Redis(url, {
|
||||
maxRetriesPerRequest: 3,
|
||||
|
||||
Reference in new issue
Block a user