Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 9 additions & 2 deletions .example.env
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion mobile/lib/config/app_config.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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 =>
Expand Down
2 changes: 1 addition & 1 deletion mobile/pubspec.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "ghostclass",
"version": "4.4.4",
"version": "4.4.5",
"private": true,
"engines": {
"node": ">=22.12.0",
Expand Down
2 changes: 1 addition & 1 deletion public/openapi/openapi.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion src/app/api/profile/__tests__/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 ---
Expand Down
6 changes: 3 additions & 3 deletions src/app/api/profile/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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." },
Expand Down Expand Up @@ -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." },
Expand Down
11 changes: 4 additions & 7 deletions src/components/layout/__tests__/private-navbar.test.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,14 @@
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";
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";

// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -46,7 +47,6 @@ vi.mock("@/hooks/users/institutions", () => ({
}));

vi.mock("@tanstack/react-query", () => ({
useQuery: vi.fn(),
useQueryClient: vi.fn(),
}));

Expand Down Expand Up @@ -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);
Expand All @@ -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);
Expand Down Expand Up @@ -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);

Expand Down
11 changes: 3 additions & 8 deletions src/components/layout/private-navbar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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,
Expand All @@ -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();
Expand All @@ -75,12 +76,6 @@ export const Navbar = () => {
const updateDefaultInstitutionUser = useUpdateDefaultInstitutionUser();
const queryClient = useQueryClient();
const selectedInstitution = defaultInstitutionUser?.toString() ?? "";
const { data: profile } = useQuery<UserProfile | null>({
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
Expand Down
3 changes: 2 additions & 1 deletion src/lib/__tests__/ratelimit.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand Down
23 changes: 23 additions & 0 deletions src/lib/ratelimit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
*
Expand Down Expand Up @@ -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);
Expand All @@ -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`);

Expand All @@ -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`),
Expand All @@ -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;

Expand Down
Loading