diff --git a/app/actions/discord.ts b/app/actions/discord.ts index 035658f..46d503d 100644 --- a/app/actions/discord.ts +++ b/app/actions/discord.ts @@ -10,11 +10,51 @@ export interface DiscordRole { position: number; } -export async function getGuildRoles(): Promise { +// --- In-memory caches with TTL --- +interface CacheEntry { data: T; expiresAt: number } +const rolesCache: { entry: CacheEntry | null } = { entry: null }; +const profileCache = new Map>(); +const CACHE_TTL = 60_000; // 60 seconds + +function getCached(entry: CacheEntry | 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 { + 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 { 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 { 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 { @@ -51,6 +74,7 @@ export async function getGuildMemberRoles(discordId: string): Promise 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 } 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; diff --git a/app/api/auth/[...nextauth]/route.ts b/app/api/auth/[...nextauth]/route.ts index 86c247f..1d59899 100644 --- a/app/api/auth/[...nextauth]/route.ts +++ b/app/api/auth/[...nextauth]/route.ts @@ -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(); + 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; diff --git a/app/form/page.tsx b/app/form/page.tsx index 2bfb590..1f4758f 100644 --- a/app/form/page.tsx +++ b/app/form/page.tsx @@ -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) { diff --git a/app/page.tsx b/app/page.tsx index 1eebfb3..8ad3172 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -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); } diff --git a/components/ShaderBackground.tsx b/components/ShaderBackground.tsx deleted file mode 100644 index 9ac53bc..0000000 --- a/components/ShaderBackground.tsx +++ /dev/null @@ -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(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 ( - - ); -} \ No newline at end of file diff --git a/components/admin-shell.tsx b/components/admin-shell.tsx index 017f095..c8f6c6a 100644 --- a/components/admin-shell.tsx +++ b/components/admin-shell.tsx @@ -65,10 +65,10 @@ export function AdminShell({
- +
- Admin + Admin
{(userName || profile) && ( }; + +if (!globalForDb.pgClient) { + globalForDb.pgClient = postgres(process.env.DATABASE_URL!, { + max: 5, + idle_timeout: 20, + }); +} + +export const db = drizzle(globalForDb.pgClient, { schema }); diff --git a/docker-compose.yml b/docker-compose.yml index ed64e44..958efbe 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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 diff --git a/nginx.conf b/nginx.conf index 71226b1..2113c54 100644 --- a/nginx.conf +++ b/nginx.conf @@ -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; } diff --git a/package.json b/package.json index f087505..0044da3 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/skill/erika/SKILL.md b/skill/erika/SKILL.md index 67df67c..44d42e1 100644 --- a/skill/erika/SKILL.md +++ b/skill/erika/SKILL.md @@ -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. +