diff --git a/.example.env b/.example.env index 3385beff..5c3ac5ee 100644 --- a/.example.env +++ b/.example.env @@ -44,7 +44,7 @@ NEXT_PUBLIC_APP_NAME=GhostClass # âš ī¸ App version displayed in footer and health checks # 🔨 Build-time (Infisical `/build-time` folder) -NEXT_PUBLIC_APP_VERSION=4.4.4 +NEXT_PUBLIC_APP_VERSION=4.4.5 # âš ī¸ Your production domain WITHOUT https:// # All URL-based variables are derived from this. @@ -359,7 +359,7 @@ JWE_PRIVATE_KEY= # âš ī¸ Minimum supported app version required to bypass forced update # 🚀 Runtime (Infisical `/runtime` folder → Server Env Var) -MIN_APP_VERSION=4.4.4 +MIN_APP_VERSION=4.4.5 # â„šī¸ Enforce Firebase App Check for all mobile clients in production # Valid: "true", "false" (default: false in dev, true recommended in prod) @@ -423,6 +423,13 @@ SYNC_RATE_LIMIT_WINDOW=10 CONTACT_RATE_LIMIT_REQUESTS=10 CONTACT_RATE_LIMIT_WINDOW=10 +# Profile endpoint override (falls back to auth defaults when unset) +# âš ī¸ This limiter is independent from AUTH_RATE_LIMIT_*. Increase this if +# /api/profile hits 429s during startup sync bursts. +# 🚀 Runtime (Infisical `/runtime` folder → Server Env Var — optional) +PROFILE_RATE_LIMIT_REQUESTS=15 +PROFILE_RATE_LIMIT_WINDOW=60 + # Backend proxy endpoint override (EzyGo traffic) # âš ī¸ This limiter is independent from RATE_LIMIT_*. # If you are seeing 429s on /api/backend/*, tune these values. diff --git a/mobile/lib/config/app_config.dart b/mobile/lib/config/app_config.dart index 43d104da..a4ea7bbe 100644 --- a/mobile/lib/config/app_config.dart +++ b/mobile/lib/config/app_config.dart @@ -75,7 +75,7 @@ class AppConfig { /// Current application version (derived from Infisical compilation injection). static String get appVersion => - const String.fromEnvironment('APP_VERSION', defaultValue: '4.4.4'); + const String.fromEnvironment('APP_VERSION', defaultValue: '4.4.5'); /// Commit SHA injected by CI for release builds. static String get appCommitSha => diff --git a/mobile/pubspec.yaml b/mobile/pubspec.yaml index 67635f39..5ef7a080 100644 --- a/mobile/pubspec.yaml +++ b/mobile/pubspec.yaml @@ -16,7 +16,7 @@ publish_to: "none" # Remove this line if you wish to publish to pub.dev # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html # In Windows, build-name is used as the major, minor, and patch parts # of the product and file versions while build-number is used as the build suffix. -version: 4.4.4+1 +version: 4.4.5+1 environment: sdk: ^3.11.4 diff --git a/package-lock.json b/package-lock.json index 255eb2d6..3c8ff002 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "ghostclass", - "version": "4.4.4", + "version": "4.4.5", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "ghostclass", - "version": "4.4.4", + "version": "4.4.5", "dependencies": { "@hookform/resolvers": "^5.2.2", "@radix-ui/react-alert-dialog": "^1.1.15", diff --git a/package.json b/package.json index a122e661..9e3c7b59 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "ghostclass", - "version": "4.4.4", + "version": "4.4.5", "private": true, "engines": { "node": ">=22.12.0", diff --git a/public/openapi/openapi.yaml b/public/openapi/openapi.yaml index 5861bc8a..ad364ac0 100644 --- a/public/openapi/openapi.yaml +++ b/public/openapi/openapi.yaml @@ -6,7 +6,7 @@ openapi: 3.1.0 info: title: GhostClass API - version: 4.4.4 + version: 4.4.5 description: | **GhostClass API** provides endpoints for authentication, profile synchronization, attendance integrations with EzyGo, telemetry, and build provenance. diff --git a/src/app/api/profile/__tests__/route.test.ts b/src/app/api/profile/__tests__/route.test.ts index 727e7012..4d4331ca 100644 --- a/src/app/api/profile/__tests__/route.test.ts +++ b/src/app/api/profile/__tests__/route.test.ts @@ -94,7 +94,7 @@ vi.mock("@/lib/utils.server", () => ({ // --- Mock rate limiter --- const mockRateLimiterLimit = vi.fn(); vi.mock("@/lib/ratelimit", () => ({ - authRateLimiter: { limit: mockRateLimiterLimit }, + profileRateLimiter: { limit: mockRateLimiterLimit }, })); // --- Mock sync logic --- diff --git a/src/app/api/profile/route.ts b/src/app/api/profile/route.ts index 48aea120..b98cf854 100644 --- a/src/app/api/profile/route.ts +++ b/src/app/api/profile/route.ts @@ -7,7 +7,7 @@ import { getAllowedHosts, resolveRequestHostname } from "@/lib/security/origin-v import { logger } from "@/lib/logger"; import * as Sentry from "@sentry/nextjs"; import { getClientIp } from "@/lib/utils.server"; -import { authRateLimiter } from "@/lib/ratelimit"; +import { profileRateLimiter } from "@/lib/ratelimit"; import { z } from "zod"; import { withSecurity } from "@/lib/security/app-check"; import { performProfileSync } from "@/lib/user/sync"; @@ -210,7 +210,7 @@ const getHandler = async (req: NextRequest) => { ); } - const { success, reset, remaining, limit } = await authRateLimiter.limit(ip); + const { success, reset, remaining, limit } = await profileRateLimiter.limit(ip); if (!success) { return NextResponse.json( { error: "Too many requests. Please try again later." }, @@ -305,7 +305,7 @@ const patchHandler = async (req: NextRequest, { decryptedBody }: { decryptedBody ); } - const { success, reset, remaining, limit } = await authRateLimiter.limit(ip); + const { success, reset, remaining, limit } = await profileRateLimiter.limit(ip); if (!success) { return NextResponse.json( { error: "Too many requests. Please try again later." }, diff --git a/src/components/layout/__tests__/private-navbar.test.tsx b/src/components/layout/__tests__/private-navbar.test.tsx index 9185e824..7ae816a0 100644 --- a/src/components/layout/__tests__/private-navbar.test.tsx +++ b/src/components/layout/__tests__/private-navbar.test.tsx @@ -1,5 +1,6 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { render, screen, fireEvent } from "@testing-library/react"; +import { useProfile } from "@/hooks/users/profile"; import { useUserSettings } from "@/providers/user-settings"; import { useInstitutions, useUpdateDefaultInstitutionUser, useDefaultInstitutionUser } from "@/hooks/users/institutions"; import { useTheme } from "@/providers/theme"; @@ -7,7 +8,7 @@ import { handleLogout } from "@/lib/security/auth"; import { isValidAvatarUrl } from "@/lib/utils"; import { Navbar } from "../private-navbar"; import { toast } from "sonner"; -import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { useQueryClient } from "@tanstack/react-query"; import React from "react"; // --------------------------------------------------------------------------- @@ -46,7 +47,6 @@ vi.mock("@/hooks/users/institutions", () => ({ })); vi.mock("@tanstack/react-query", () => ({ - useQuery: vi.fn(), useQueryClient: vi.fn(), })); @@ -140,7 +140,7 @@ describe("Navbar", () => { vi.clearAllMocks(); mockPathname.mockReturnValue("/dashboard"); - vi.mocked(useQuery).mockReturnValue({ + vi.mocked(useProfile).mockReturnValue({ data: { username: "testuser", email: "test@example.com", avatar_url: null }, isLoading: false } as never); @@ -152,9 +152,6 @@ describe("Navbar", () => { isLoading: false, } as never); - vi.mocked(useQuery).mockReturnValue({ - data: { username: "testuser", email: "test@example.com", avatar_url: null }, - } as never); vi.mocked(useInstitutions).mockReturnValue({ data: [], isLoading: false } as never); vi.mocked(useDefaultInstitutionUser).mockReturnValue({ data: null } as never); vi.mocked(useUpdateDefaultInstitutionUser).mockReturnValue({ mutate: vi.fn() } as never); @@ -242,7 +239,7 @@ describe("Navbar", () => { it("renders avatar image if URL is valid", () => { vi.mocked(isValidAvatarUrl).mockReturnValue(true); - vi.mocked(useQuery).mockReturnValue({ + vi.mocked(useProfile).mockReturnValue({ data: { avatar_url: "http://example.com/avatar.png", username: "testuser", email: "test@example.com" }, } as never); diff --git a/src/components/layout/private-navbar.tsx b/src/components/layout/private-navbar.tsx index c618dc6c..9550b14a 100644 --- a/src/components/layout/private-navbar.tsx +++ b/src/components/layout/private-navbar.tsx @@ -14,6 +14,7 @@ import { Button } from "@/components/ui/button"; import { handleLogout } from "@/lib/security/auth"; import { useRouter } from "next/navigation"; import { Switch } from "@/components/ui/switch"; +import { useProfile } from "@/hooks/users/profile"; import { useDefaultInstitutionUser, useInstitutions, @@ -28,7 +29,7 @@ import { SelectValue, } from "@/components/ui/select"; import { toast } from "sonner"; -import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { useQueryClient } from "@tanstack/react-query"; import Link from "next/link"; import { Building2, @@ -54,11 +55,11 @@ import { useNotifications } from "@/hooks/notifications/useNotifications"; import { isValidAvatarUrl } from "@/lib/utils"; import { useTopLoader } from "nextjs-toploader"; import { useTheme } from "@/providers/theme"; -import { UserProfile } from "@/types"; export const Navbar = () => { const router = useRouter(); const topLoader = useTopLoader(); + const { data: profile } = useProfile(); const { settings, updateBunkCalc, updateTarget, isLoading: settingsLoading } = useUserSettings(); const { theme, toggleTheme } = useTheme(); @@ -75,12 +76,6 @@ export const Navbar = () => { const updateDefaultInstitutionUser = useUpdateDefaultInstitutionUser(); const queryClient = useQueryClient(); const selectedInstitution = defaultInstitutionUser?.toString() ?? ""; - const { data: profile } = useQuery({ - queryKey: ["profile", true], - queryFn: async () => null, - enabled: false, - staleTime: 1000 * 60 * 5, - }); const pathname = usePathname(); // countOnly=true: skips the action-conflict query and infinite feed query (both with diff --git a/src/lib/__tests__/ratelimit.test.ts b/src/lib/__tests__/ratelimit.test.ts index 069c4f6c..7dcd785e 100644 --- a/src/lib/__tests__/ratelimit.test.ts +++ b/src/lib/__tests__/ratelimit.test.ts @@ -18,9 +18,10 @@ describe('ratelimit.ts', () => { }); it('exports rate limiter instances and logs configuration', async () => { - const { syncRateLimiter, contactRateLimiter, authRateLimiter, proxyRateLimiter } = await import('../ratelimit'); + const { syncRateLimiter, contactRateLimiter, profileRateLimiter, authRateLimiter, proxyRateLimiter } = await import('../ratelimit'); expect(syncRateLimiter).toBeDefined(); expect(contactRateLimiter).toBeDefined(); + expect(profileRateLimiter).toBeDefined(); expect(authRateLimiter).toBeDefined(); expect(proxyRateLimiter).toBeDefined(); diff --git a/src/lib/ratelimit.ts b/src/lib/ratelimit.ts index d722ce50..89420587 100644 --- a/src/lib/ratelimit.ts +++ b/src/lib/ratelimit.ts @@ -9,6 +9,7 @@ import { logger } from "@/lib/logger"; * Environment variables: * - SYNC_RATE_LIMIT_REQUESTS / SYNC_RATE_LIMIT_WINDOW — cron sync endpoint (default 10/10 s) * - CONTACT_RATE_LIMIT_REQUESTS / CONTACT_RATE_LIMIT_WINDOW — contact form (default 10/10 s) + * - PROFILE_RATE_LIMIT_REQUESTS / PROFILE_RATE_LIMIT_WINDOW — profile GET/PATCH (default 15/60 s) * - AUTH_RATE_LIMIT_REQUESTS / AUTH_RATE_LIMIT_WINDOW — auth endpoints (default 5/60 s) * - PROXY_RATE_LIMIT_REQUESTS / PROXY_RATE_LIMIT_WINDOW — high-throughput proxy-style routes (default 300/60 s) * @@ -44,6 +45,17 @@ const CONTACT_WINDOW = parseInt(process.env.CONTACT_RATE_LIMIT_WINDOW || Strin if (!Number.isFinite(CONTACT_LIMIT) || CONTACT_LIMIT < 1 || CONTACT_LIMIT > 1000) throw new Error(`CONTACT_RATE_LIMIT_REQUESTS must be between 1-1000, got: [value redacted]`); if (!Number.isFinite(CONTACT_WINDOW) || CONTACT_WINDOW < 1 || CONTACT_WINDOW > 3600) throw new Error(`CONTACT_RATE_LIMIT_WINDOW must be between 1-3600 seconds, got: [value redacted]`); +// Profile-specific limits (slightly higher to accommodate cache misses and +// startup sync bursts without affecting auth/logout/csrf budgets). +const PROFILE_LIMIT = parseInt(process.env.PROFILE_RATE_LIMIT_REQUESTS || "15", 10); +const PROFILE_WINDOW = parseInt(process.env.PROFILE_RATE_LIMIT_WINDOW || "60", 10); +if (!Number.isFinite(PROFILE_LIMIT) || PROFILE_LIMIT < 1 || PROFILE_LIMIT > 1000) { + throw new Error(`PROFILE_RATE_LIMIT_REQUESTS must be between 1-1000, got: [value redacted]`); +} +if (!Number.isFinite(PROFILE_WINDOW) || PROFILE_WINDOW < 1 || PROFILE_WINDOW > 3600) { + throw new Error(`PROFILE_RATE_LIMIT_WINDOW must be between 1-3600 seconds, got: [value redacted]`); +} + // Parse and validate auth rate limit settings const AUTH_LIMIT = parseInt(process.env.AUTH_RATE_LIMIT_REQUESTS || "5", 10); const AUTH_WINDOW = parseInt(process.env.AUTH_RATE_LIMIT_WINDOW || "60", 10); @@ -68,6 +80,7 @@ if (!Number.isFinite(PROXY_WINDOW) || PROXY_WINDOW < 1 || PROXY_WINDOW > 3600) { // Log configuration (logger.dev handles environment-specific suppression) logger.dev(`[Rate Limit] sync=${SYNC_LIMIT}/${SYNC_WINDOW}s contact=${CONTACT_LIMIT}/${CONTACT_WINDOW}s`); +logger.dev(`[Profile Rate Limit] ${PROFILE_LIMIT}/${PROFILE_WINDOW}s`); logger.dev(`[Auth Rate Limit] ${AUTH_LIMIT} requests per ${AUTH_WINDOW}s`); logger.dev(`[Proxy Rate Limit] ${PROXY_LIMIT} requests per ${PROXY_WINDOW}s`); @@ -88,6 +101,13 @@ const contactLimiter = new Ratelimit({ prefix: "@ghostclass/contact-ratelimit", }); +const profileLimiter = new Ratelimit({ + redis: redis, + limiter: Ratelimit.slidingWindow(PROFILE_LIMIT, `${PROFILE_WINDOW} s`), + analytics: true, + prefix: "@ghostclass/profile-ratelimit", +}); + const authLimiter = new Ratelimit({ redis: redis, limiter: Ratelimit.slidingWindow(AUTH_LIMIT, `${AUTH_WINDOW} s`), @@ -108,6 +128,9 @@ export const syncRateLimiter = syncLimiter; /** Rate limiter for contact form submissions */ export const contactRateLimiter = contactLimiter; +/** Rate limiter for profile GET/PATCH operations */ +export const profileRateLimiter = profileLimiter; + /** Stricter rate limiter for authentication endpoints */ export const authRateLimiter = authLimiter;