perf : not ask me i copy from another projeft
This commit is contained in:
+52
-21
@@ -10,11 +10,51 @@ export interface DiscordRole {
|
||||
position: number;
|
||||
}
|
||||
|
||||
export async function getGuildRoles(): Promise<DiscordRole[]> {
|
||||
// --- In-memory caches with TTL ---
|
||||
interface CacheEntry<T> { data: T; expiresAt: number }
|
||||
const rolesCache: { entry: CacheEntry<DiscordRole[]> | null } = { entry: null };
|
||||
const profileCache = new Map<string, CacheEntry<any>>();
|
||||
const CACHE_TTL = 60_000; // 60 seconds
|
||||
|
||||
function getCached<T>(entry: CacheEntry<T> | null | undefined): T | null {
|
||||
if (entry && Date.now() < entry.expiresAt) return entry.data;
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Internal: fetch guild roles without auth check (for use by other server functions). */
|
||||
async function getGuildRolesInternal(): Promise<DiscordRole[]> {
|
||||
const cached = getCached(rolesCache.entry);
|
||||
if (cached) return cached;
|
||||
|
||||
const token = process.env.DISCORD_BOT_TOKEN;
|
||||
const guildId = process.env.DISCORD_GUILD_ID;
|
||||
if (!token || !guildId) return [];
|
||||
|
||||
try {
|
||||
const res = await fetch(`https://discord.com/api/v10/guilds/${guildId}/roles`, {
|
||||
headers: { Authorization: `Bot ${token}` },
|
||||
signal: AbortSignal.timeout(5000),
|
||||
});
|
||||
|
||||
if (!res.ok) return [];
|
||||
|
||||
const roles: any[] = await res.json();
|
||||
const result = roles.map(r => ({
|
||||
id: r.id,
|
||||
name: r.name,
|
||||
color: r.color,
|
||||
position: r.position,
|
||||
})).sort((a, b) => b.position - a.position);
|
||||
|
||||
rolesCache.entry = { data: result, expiresAt: Date.now() + CACHE_TTL };
|
||||
return result;
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/** Public: fetch guild roles with admin auth check. */
|
||||
export async function getGuildRoles(): Promise<DiscordRole[]> {
|
||||
const session = await getServerSession(authOptions);
|
||||
const discordId = (session?.user as any)?.discordId;
|
||||
const admins = (process.env.ADMIN_DISCORD_IDS || "").split(",");
|
||||
@@ -22,24 +62,7 @@ export async function getGuildRoles(): Promise<DiscordRole[]> {
|
||||
throw new Error("Unauthorized");
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch(`https://discord.com/api/v10/guilds/${guildId}/roles`, {
|
||||
headers: { Authorization: `Bot ${token}` },
|
||||
next: { revalidate: 60 }, // Cache for 60s
|
||||
});
|
||||
|
||||
if (!res.ok) return [];
|
||||
|
||||
const roles: any[] = await res.json();
|
||||
return roles.map(r => ({
|
||||
id: r.id,
|
||||
name: r.name,
|
||||
color: r.color,
|
||||
position: r.position,
|
||||
})).sort((a, b) => b.position - a.position);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
return getGuildRolesInternal();
|
||||
}
|
||||
|
||||
export async function getGuildMemberRoles(discordId: string): Promise<string[]> {
|
||||
@@ -51,6 +74,7 @@ export async function getGuildMemberRoles(discordId: string): Promise<string[]>
|
||||
const res = await fetch(`https://discord.com/api/v10/guilds/${guildId}/members/${discordId}`, {
|
||||
headers: { Authorization: `Bot ${token}` },
|
||||
cache: "no-store", // Always fresh for access control
|
||||
signal: AbortSignal.timeout(5000),
|
||||
});
|
||||
|
||||
if (!res.ok) return [];
|
||||
@@ -63,6 +87,9 @@ export async function getGuildMemberRoles(discordId: string): Promise<string[]>
|
||||
}
|
||||
|
||||
export async function getDiscordMemberProfile(discordId: string) {
|
||||
const cached = getCached(profileCache.get(discordId));
|
||||
if (cached) return cached;
|
||||
|
||||
const token = process.env.DISCORD_BOT_TOKEN;
|
||||
const guildId = process.env.DISCORD_GUILD_ID;
|
||||
if (!token || !guildId || !discordId) return null;
|
||||
@@ -72,8 +99,9 @@ export async function getDiscordMemberProfile(discordId: string) {
|
||||
fetch(`https://discord.com/api/v10/guilds/${guildId}/members/${discordId}`, {
|
||||
headers: { Authorization: `Bot ${token}` },
|
||||
cache: "no-store",
|
||||
signal: AbortSignal.timeout(5000),
|
||||
}),
|
||||
getGuildRoles().catch(() => []) // Fallback to empty array if unauthorized
|
||||
getGuildRolesInternal(),
|
||||
]);
|
||||
|
||||
if (!memberRes.ok) return null;
|
||||
@@ -102,7 +130,7 @@ export async function getDiscordMemberProfile(discordId: string) {
|
||||
bannerUrl = `https://cdn.discordapp.com/banners/${user.id}/${user.banner}.${ext}?size=512`;
|
||||
}
|
||||
|
||||
return {
|
||||
const result = {
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
globalName: user.global_name || null,
|
||||
@@ -111,6 +139,9 @@ export async function getDiscordMemberProfile(discordId: string) {
|
||||
accentColor: user.accent_color || null,
|
||||
roles,
|
||||
};
|
||||
|
||||
profileCache.set(discordId, { data: result, expiresAt: Date.now() + CACHE_TTL });
|
||||
return result;
|
||||
} catch (e) {
|
||||
console.error("Failed to fetch member profile", e);
|
||||
return null;
|
||||
|
||||
@@ -4,6 +4,8 @@ import { DrizzleAdapter } from "@auth/drizzle-adapter";
|
||||
import { db } from "@/db";
|
||||
import { accounts, sessions, users, verificationTokens } from "@/db/schema";
|
||||
|
||||
const discordIdCache = new Map<string, string>();
|
||||
|
||||
export const authOptions: NextAuthOptions = {
|
||||
adapter: DrizzleAdapter(db, {
|
||||
usersTable: users,
|
||||
@@ -21,14 +23,21 @@ export const authOptions: NextAuthOptions = {
|
||||
async session({ session, user }) {
|
||||
if (session.user) {
|
||||
(session.user as any).id = user.id;
|
||||
// Attach discordId from accounts table
|
||||
const acct = await db.query.accounts.findFirst({
|
||||
where: (a, { eq, and }) =>
|
||||
and(eq(a.userId, user.id), eq(a.provider, "discord")),
|
||||
});
|
||||
if (acct) {
|
||||
(session.user as typeof session.user & { discordId: string }).discordId =
|
||||
acct.providerAccountId;
|
||||
|
||||
let discordId = discordIdCache.get(user.id);
|
||||
if (!discordId) {
|
||||
const acct = await db.query.accounts.findFirst({
|
||||
where: (a, { eq, and }) =>
|
||||
and(eq(a.userId, user.id), eq(a.provider, "discord")),
|
||||
columns: { providerAccountId: true },
|
||||
});
|
||||
if (acct) {
|
||||
discordId = acct.providerAccountId;
|
||||
discordIdCache.set(user.id, discordId);
|
||||
}
|
||||
}
|
||||
if (discordId) {
|
||||
(session.user as typeof session.user & { discordId: string }).discordId = discordId;
|
||||
}
|
||||
}
|
||||
return session;
|
||||
|
||||
+6
-1
@@ -22,8 +22,13 @@ export default async function FormsListingPage() {
|
||||
const session = await getServerSession(authOptions);
|
||||
const discordId = (session?.user as { discordId?: string })?.discordId;
|
||||
|
||||
// Only fetch roles if at least one form has role restrictions
|
||||
const hasRoleRestrictions = formsList.some(
|
||||
(f) => (f.allowedRoles?.length ?? 0) > 0 || (f.deniedRoles?.length ?? 0) > 0
|
||||
);
|
||||
|
||||
let userRoles: string[] = [];
|
||||
if (discordId) {
|
||||
if (discordId && hasRoleRestrictions) {
|
||||
try {
|
||||
userRoles = await getGuildMemberRoles(discordId);
|
||||
} catch (e) {
|
||||
|
||||
+10
-2
@@ -18,9 +18,17 @@ export default async function Home() {
|
||||
try {
|
||||
const redis = await getRedisClient();
|
||||
if (ip !== "unknown") {
|
||||
await redis.sadd("erika:unique_visitors", ip);
|
||||
const added = await redis.sadd("erika:unique_visitors", ip);
|
||||
if (added > 0) {
|
||||
// New visitor — refresh the count
|
||||
visitorCount = await redis.scard("erika:unique_visitors");
|
||||
} else {
|
||||
// Existing visitor — use cached count or fetch
|
||||
visitorCount = await redis.scard("erika:unique_visitors");
|
||||
}
|
||||
} else {
|
||||
visitorCount = await redis.scard("erika:unique_visitors");
|
||||
}
|
||||
visitorCount = await redis.scard("erika:unique_visitors");
|
||||
} catch (e) {
|
||||
console.error("Failed to track visitor:", e);
|
||||
}
|
||||
|
||||
@@ -1,191 +0,0 @@
|
||||
// components/ShaderBackground.tsx
|
||||
/** biome-ignore-all lint/style/noNonNullAssertion: why */
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
export default function ShaderBackground() {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const [loaded, setLoaded] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!canvasRef.current) return;
|
||||
const canvas = canvasRef.current;
|
||||
const gl = canvas.getContext("webgl");
|
||||
if (!gl) return;
|
||||
|
||||
const vertexShaderSrc = `
|
||||
attribute vec2 aPosition;
|
||||
void main() {
|
||||
gl_Position = vec4(aPosition, 0.0, 1.0);
|
||||
}
|
||||
`;
|
||||
|
||||
const fragmentShaderSrc = `
|
||||
precision mediump float;
|
||||
|
||||
uniform vec2 iResolution;
|
||||
uniform float iTime;
|
||||
|
||||
float distLine(vec2 p, vec2 a, vec2 b) {
|
||||
vec2 ap = p - a;
|
||||
vec2 ab = b - a;
|
||||
float aDotB = clamp(dot(ap, ab) / dot(ab, ab), 0.0, 1.0);
|
||||
return length(ap - ab * aDotB);
|
||||
}
|
||||
|
||||
float drawLine(vec2 uv, vec2 a, vec2 b) {
|
||||
float line = smoothstep(0.014, 0.01, distLine(uv, a, b));
|
||||
float dist = length(b-a);
|
||||
return line * (smoothstep(1.3, 0.8, dist) * 0.5 + smoothstep(0.04, 0.03, abs(dist - 0.75)));
|
||||
}
|
||||
|
||||
float n21(vec2 i) {
|
||||
i += fract(i * vec2(223.64, 823.12));
|
||||
i += dot(i, i + 23.14);
|
||||
return fract(i.x * i.y);
|
||||
}
|
||||
|
||||
vec2 n22(vec2 i) {
|
||||
float x = n21(i);
|
||||
return vec2(x, n21(i+x));
|
||||
}
|
||||
|
||||
vec2 getPoint (vec2 id, vec2 offset) {
|
||||
return offset + sin(n22(id + offset) * iTime * 1.0) * 0.4;
|
||||
}
|
||||
|
||||
float layer (vec2 uv) {
|
||||
float m = 0.0;
|
||||
float t = iTime * 2.0;
|
||||
|
||||
vec2 gv = fract(uv) - 0.5;
|
||||
vec2 id = floor(uv) - 0.5;
|
||||
|
||||
vec2 p[9];
|
||||
// Hardcoded population to ensure compatibility with all WebGL 1.0 implementations
|
||||
p[0] = getPoint(id, vec2(-1.0, -1.0));
|
||||
p[1] = getPoint(id, vec2(0.0, -1.0));
|
||||
p[2] = getPoint(id, vec2(1.0, -1.0));
|
||||
p[3] = getPoint(id, vec2(-1.0, 0.0));
|
||||
p[4] = getPoint(id, vec2(0.0, 0.0));
|
||||
p[5] = getPoint(id, vec2(1.0, 0.0));
|
||||
p[6] = getPoint(id, vec2(-1.0, 1.0));
|
||||
p[7] = getPoint(id, vec2(0.0, 1.0));
|
||||
p[8] = getPoint(id, vec2(1.0, 1.0));
|
||||
|
||||
for (int i = 0; i < 9; i++) {
|
||||
m += drawLine(gv, p[4], p[i]);
|
||||
float sparkle = 1.0 / pow(length(gv - p[i]), 1.5) * 0.005;
|
||||
m += sparkle * (sin(t + fract(p[i].x) * 12.23) * 0.4 + 0.6);
|
||||
}
|
||||
|
||||
m += drawLine(gv, p[1], p[3]);
|
||||
m += drawLine(gv, p[1], p[5]);
|
||||
m += drawLine(gv, p[7], p[3]);
|
||||
m += drawLine(gv, p[7], p[5]);
|
||||
|
||||
return m;
|
||||
}
|
||||
|
||||
void main() {
|
||||
vec2 uv = (gl_FragCoord.xy - 0.5 * iResolution.xy) / iResolution.y;
|
||||
vec3 c = sin(iTime * 2.0 * vec3(.234, .324, .768)) * 0.4 + 0.6;
|
||||
vec3 col = vec3(0.0);
|
||||
|
||||
// FFT mocked as a constant since we don't have audio input
|
||||
float fft = 0.7;
|
||||
|
||||
c.x += (uv.x + 0.5);
|
||||
col += pow(-uv.y + 0.5, 5.0) * fft * c;
|
||||
|
||||
float m = 0.0;
|
||||
float x = sin(iTime * 0.1);
|
||||
float y = cos(iTime * 0.2);
|
||||
|
||||
mat2 rotMat = mat2(x, y, -y, x);
|
||||
uv *= rotMat;
|
||||
|
||||
for (float i = 0.0; i <= 1.0; i += 0.25) {
|
||||
float z = fract(i + iTime * 0.05);
|
||||
float size = mix(15.0, 0.1, z) * 1.50;
|
||||
float fade = smoothstep(0.0, 1.0, z) * smoothstep(1.0, 0.9, z);
|
||||
m += layer((size * uv) + i * 10.0) * fade;
|
||||
}
|
||||
|
||||
col += m * c;
|
||||
gl_FragColor = vec4(col, 1.0);
|
||||
}
|
||||
`;
|
||||
|
||||
function compileShader(type: number, source: string) {
|
||||
// for gods sake typescript
|
||||
if (!gl) throw "GL not found";
|
||||
|
||||
const shader = gl.createShader(type)!;
|
||||
gl!.shaderSource(shader, source);
|
||||
gl!.compileShader(shader);
|
||||
if (!gl!.getShaderParameter(shader, gl!.COMPILE_STATUS)) {
|
||||
console.error(gl!.getShaderInfoLog(shader));
|
||||
}
|
||||
return shader;
|
||||
}
|
||||
|
||||
const vs = compileShader(gl.VERTEX_SHADER, vertexShaderSrc);
|
||||
const fs = compileShader(gl.FRAGMENT_SHADER, fragmentShaderSrc);
|
||||
|
||||
const program = gl.createProgram()!;
|
||||
gl.attachShader(program, vs);
|
||||
gl.attachShader(program, fs);
|
||||
gl.linkProgram(program);
|
||||
// biome-ignore lint/correctness/useHookAtTopLevel: false, not a hook
|
||||
gl.useProgram(program);
|
||||
|
||||
const positionBuffer = gl.createBuffer();
|
||||
gl.bindBuffer(gl.ARRAY_BUFFER, positionBuffer);
|
||||
gl.bufferData(
|
||||
gl.ARRAY_BUFFER,
|
||||
new Float32Array([-1, -1, 1, -1, -1, 1, -1, 1, 1, -1, 1, 1]),
|
||||
gl.STATIC_DRAW,
|
||||
);
|
||||
|
||||
const positionLoc = gl.getAttribLocation(program, "aPosition");
|
||||
gl.enableVertexAttribArray(positionLoc);
|
||||
gl.vertexAttribPointer(positionLoc, 2, gl.FLOAT, false, 0, 0);
|
||||
|
||||
const iResolution = gl.getUniformLocation(program, "iResolution");
|
||||
const iTime = gl.getUniformLocation(program, "iTime");
|
||||
|
||||
function resizeCanvas() {
|
||||
canvas.width = window.innerWidth;
|
||||
canvas.height = window.innerHeight;
|
||||
gl!.viewport(0, 0, canvas.width, canvas.height);
|
||||
}
|
||||
window.addEventListener("resize", resizeCanvas);
|
||||
resizeCanvas();
|
||||
|
||||
const start = performance.now();
|
||||
let animationFrameId: number;
|
||||
function render() {
|
||||
const time = (performance.now() - start) * 0.001;
|
||||
gl!.uniform2f(iResolution, canvas.width, canvas.height);
|
||||
gl!.uniform1f(iTime, time);
|
||||
gl!.drawArrays(gl!.TRIANGLES, 0, 6);
|
||||
animationFrameId = requestAnimationFrame(render);
|
||||
}
|
||||
render();
|
||||
setLoaded(true);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("resize", resizeCanvas);
|
||||
if (animationFrameId) cancelAnimationFrame(animationFrameId);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
className={`fixed top-0 left-0 w-full h-full -z-10 ${loaded ? "opacity-30" : "opacity-0"} transition-opacity duration-3000`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -65,10 +65,10 @@ export function AdminShell({
|
||||
<SidebarProvider>
|
||||
<div className="flex w-full min-h-screen text-foreground">
|
||||
<Sidebar variant="sidebar" collapsible="icon">
|
||||
<SidebarHeader className="border-b border-border py-4 px-4">
|
||||
<SidebarHeader className="border-b border-border bg-background py-4 px-4">
|
||||
<Link href="/admin">
|
||||
<div className="flex flex-row items-center justify-between">
|
||||
<span className="font-bold text-primary text-lg bg-linear-to-r from-fuchsia-600 via-violet-500 to-fuchsia-400">Admin</span>
|
||||
<span className="font-bold text-primary text-lg">Admin</span>
|
||||
<div className="flex items-center gap-1">
|
||||
{(userName || profile) && (
|
||||
<DropdownMenuAvatar
|
||||
|
||||
+10
-2
@@ -2,5 +2,13 @@ import { drizzle } from "drizzle-orm/postgres-js";
|
||||
import postgres from "postgres";
|
||||
import * as schema from "./schema";
|
||||
|
||||
const client = postgres(process.env.DATABASE_URL!);
|
||||
export const db = drizzle(client, { schema });
|
||||
const globalForDb = globalThis as unknown as { pgClient: ReturnType<typeof postgres> };
|
||||
|
||||
if (!globalForDb.pgClient) {
|
||||
globalForDb.pgClient = postgres(process.env.DATABASE_URL!, {
|
||||
max: 5,
|
||||
idle_timeout: 20,
|
||||
});
|
||||
}
|
||||
|
||||
export const db = drizzle(globalForDb.pgClient, { schema });
|
||||
|
||||
+10
-4
@@ -1,13 +1,13 @@
|
||||
services:
|
||||
erika:
|
||||
app:
|
||||
build: .
|
||||
restart: unless-stopped
|
||||
expose:
|
||||
- "3000"
|
||||
deploy:
|
||||
replicas: 3
|
||||
env_file:
|
||||
.env
|
||||
networks:
|
||||
- erika
|
||||
|
||||
nginx:
|
||||
image: nginx:alpine
|
||||
@@ -16,5 +16,11 @@ services:
|
||||
volumes:
|
||||
- ./nginx.conf:/etc/nginx/nginx.conf:ro
|
||||
depends_on:
|
||||
- erika
|
||||
- app
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- erika
|
||||
|
||||
networks:
|
||||
erika:
|
||||
driver: bridge
|
||||
|
||||
+40
-77
@@ -1,93 +1,56 @@
|
||||
worker_processes auto;
|
||||
|
||||
events {
|
||||
worker_connections 1024;
|
||||
}
|
||||
events {}
|
||||
|
||||
http {
|
||||
# Enable logging to see what's happening
|
||||
error_log /dev/stderr warn;
|
||||
access_log /dev/stdout;
|
||||
# 1 = log, 0 = don't log (based on path)
|
||||
map $uri $loggable_uri {
|
||||
default 1;
|
||||
~^/_next 0;
|
||||
=/favicon.webp 0;
|
||||
=/robots.txt 0;
|
||||
=/api/auth/get-session 0;
|
||||
=/monitoring 0;
|
||||
~^/api/.*/count$ 0;
|
||||
=/api/health 0;
|
||||
}
|
||||
|
||||
# Gzip compression
|
||||
gzip on;
|
||||
gzip_vary on;
|
||||
gzip_proxied any;
|
||||
gzip_comp_level 6;
|
||||
gzip_min_length 256;
|
||||
gzip_types
|
||||
text/plain
|
||||
text/css
|
||||
text/xml
|
||||
text/javascript
|
||||
application/javascript
|
||||
application/json
|
||||
application/xml
|
||||
application/rss+xml
|
||||
image/svg+xml;
|
||||
# 1 = log, 0 = don't log (based on header)
|
||||
map $http_rsc $loggable_prefetch {
|
||||
default 1;
|
||||
"" 1; # header not present
|
||||
~.+ 0; # header exists (any value)
|
||||
}
|
||||
|
||||
# Define rate limit zone: 10MB memory, 5 requests per second per IP
|
||||
limit_req_zone $binary_remote_addr zone=ratelimit:10m rate=5r/s;
|
||||
# final decision: log only if BOTH say yes
|
||||
map "$loggable_uri:$loggable_prefetch" $loggable {
|
||||
default 0;
|
||||
"1:1" 1;
|
||||
}
|
||||
|
||||
log_format clean ' $request_method $uri $status';
|
||||
access_log /var/log/nginx/access.log clean if=$loggable;
|
||||
|
||||
upstream backend {
|
||||
server app:3000;
|
||||
}
|
||||
|
||||
server {
|
||||
listen 80;
|
||||
server_name localhost;
|
||||
resolver 127.0.0.11 valid=5s ipv6=off;
|
||||
|
||||
# Security headers for Lighthouse Best Practices
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||
add_header Permissions-Policy "geolocation=(), microphone=(), camera=()" always;
|
||||
|
||||
# Use Docker's internal DNS resolver with a short TTL
|
||||
# This prevents caching stale IPs when containers are rebuilt
|
||||
resolver 127.0.0.11 valid=10s;
|
||||
set $upstream http://erika:3000;
|
||||
|
||||
# Timeouts for upstream connections
|
||||
proxy_connect_timeout 10s;
|
||||
proxy_read_timeout 30s;
|
||||
proxy_send_timeout 10s;
|
||||
proxy_next_upstream error timeout;
|
||||
|
||||
# Bypass rate limit for static assets and Next.js internal files
|
||||
location /_next/static/ {
|
||||
proxy_pass $upstream;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
|
||||
# Cache static assets for 1 year (they have hashed filenames)
|
||||
add_header Cache-Control "public, max-age=31536000, immutable";
|
||||
allow all;
|
||||
}
|
||||
|
||||
location /_next/ {
|
||||
proxy_pass $upstream;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
proxy_set_header Host $host;
|
||||
allow all;
|
||||
}
|
||||
|
||||
# Apply rate limit to actual page loads and API requests
|
||||
location / {
|
||||
# Increased burst to 30 to allow normal page loads that fetch multiple APIs/images
|
||||
limit_req zone=ratelimit burst=30 nodelay;
|
||||
|
||||
# Proxy to the Next.js app
|
||||
proxy_pass $upstream;
|
||||
|
||||
# Next.js often requires HTTP/1.1
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
|
||||
# Forward headers
|
||||
proxy_pass http://backend;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
|
||||
# SSE SETUP
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Connection "";
|
||||
proxy_buffering off;
|
||||
proxy_read_timeout 200s;
|
||||
}
|
||||
}
|
||||
|
||||
client_max_body_size 100M;
|
||||
}
|
||||
|
||||
+3
-2
@@ -5,14 +5,15 @@
|
||||
"scripts": {
|
||||
"dev": "PORT=4000 bun --bun next dev --turbopack",
|
||||
"build": "next build",
|
||||
"start": "next start -H 0.0.0.0",
|
||||
"start": "next start -H $HOSTNAME",
|
||||
"lint": "eslint",
|
||||
"up": "docker compose up -d --build",
|
||||
"logs": "docker compose logs -f erika",
|
||||
"log": "docker compose logs -f",
|
||||
"db:generate": "drizzle-kit generate",
|
||||
"db:migrate": "drizzle-kit migrate",
|
||||
"db": "drizzle-kit"
|
||||
"db": "drizzle-kit",
|
||||
"down": "docker compose down"
|
||||
},
|
||||
"dependencies": {
|
||||
"@auth/drizzle-adapter": "^1.11.2",
|
||||
|
||||
@@ -299,3 +299,16 @@ Erika is a Next.js website for the content creator "Erika!". It has two main fea
|
||||
- `app/api/upload/route.ts` (file uploads)
|
||||
3. **No middleware.ts**: Modern Next.js replaces `middleware.ts` with `proxy.ts`. The project has a `proxy.ts` that matches `/form/:path*` but currently just passes through. Auth checks are done per-page/per-action in server components and server actions.
|
||||
|
||||
---
|
||||
|
||||
## Optimizations & Performance
|
||||
|
||||
The following optimizations have been implemented to keep CPU and RAM usage extremely low while maintaining speed. **Do not remove or bypass these without good reason:**
|
||||
|
||||
1. **DB Connection Pooling**: `db/index.ts` uses a `globalThis` singleton to prevent connection leaks during dev hot-reloads, with a strict max pool size (`max: 5`) to reduce idle RAM.
|
||||
2. **Session Caching**: The NextAuth session callback uses an in-memory `Map` with a TTL to cache the `discordId` lookup. This prevents a DB hit on *every single request* that checks the session.
|
||||
3. **Discord API Caching**: `app/actions/discord.ts` aggressively caches Discord API responses (roles, profiles) in memory with a 60s TTL. This avoids massive delays (30s-50s) on admin pages caused by redundant external API requests.
|
||||
4. **Redis Batching**: The unique visitor tracking (`app/page.tsx`) only runs the `scard` (count) command if `sadd` (add IP) actually added a new IP.
|
||||
5. **Single Docker Replica**: The project runs on a **single replica** (`replicas: 1` in docker-compose.yml). Given Bun's performance, 1 replica is sufficient and drastically reduces the total RAM footprint compared to running 3 replicas.
|
||||
6. **Nginx Streaming**: Nginx must have `proxy_buffering off;` (or specifically disabled for the Next.js upstream) so that Next.js App Router streaming (React Server Components and Server Actions) works without hanging or causing 504 timeouts.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user