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
8 changes: 8 additions & 0 deletions .claude/settings.local.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
{
"permissions": {
"allow": [
"Bash(pnpm run build:*)",
"Bash(npx tsc:*)"
]
}
}
9 changes: 9 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -72,3 +72,12 @@ NEXT_PUBLIC_ENABLE_APPLICATIONS="true"
# -------------------------
NEXT_PUBLIC_VERCEL_ANALYTICS_ID=""
NEXT_PUBLIC_GA_MEASUREMENT_ID=""

# -------------------------
# Security & Performance
# -------------------------
# Cache TTL in seconds (default: 300 = 5 minutes)
CACHE_TTL_DEFAULT="300"

# Enable/disable rate limiting (default: true)
RATE_LIMIT_ENABLED="true"
1 change: 1 addition & 0 deletions packages/api/.cache/tsbuildinfo.json

Large diffs are not rendered by default.

6 changes: 5 additions & 1 deletion packages/api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,9 @@
"./server": "./src/index.ts",
"./context": "./src/context.ts",
"./middleware": "./src/middleware.ts",
"./middleware/cache": "./src/middleware/cache.ts",
"./middleware/http-security": "./src/middleware/http-security.ts",
"./middleware/security": "./src/middleware/security.ts",
"./trpc": "./src/trpc.ts"
},
"scripts": {
Expand All @@ -25,6 +28,7 @@
"@trpc/react-query": "^11.7.2",
"@trpc/server": "^11.7.2",
"minimatch": "^10.1.1",
"sanitize-html": "^2.17.0",
"superjson": "^2.2.6",
"zod": "^3.23.8"
},
Expand All @@ -33,4 +37,4 @@
"@types/sanitize-html": "^2.16.0",
"typescript": "^5.6.3"
}
}
}
7 changes: 6 additions & 1 deletion packages/api/src/context.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,19 @@
import { auth } from "@query/auth";
import { db } from "@query/db";
import type { FetchCreateContextFnOptions } from "@trpc/server/adapters/fetch";
import { cache } from "./middleware/cache";

export async function createContext(opts?: FetchCreateContextFnOptions) {
export async function createContext(
opts?: FetchCreateContextFnOptions & { clientIp?: string }
) {
const session = await auth();

return {
db,
session,
userId: session?.user?.id,
cache,
clientIp: opts?.clientIp || 'unknown',
};
}

Expand Down
182 changes: 182 additions & 0 deletions packages/api/src/middleware/cache.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,182 @@
/**
* In-Memory Cache Service
* Provides TTL-based caching with automatic cleanup
*/

interface CacheEntry<T> {
value: T;
expiresAt: number;
}

interface CacheStats {
hits: number;
misses: number;
size: number;
}

export class CacheService {
private cache = new Map<string, CacheEntry<unknown>>();
private stats: CacheStats = { hits: 0, misses: 0, size: 0 };
private cleanupInterval: NodeJS.Timeout;

constructor(private defaultTTL: number = 300) {
// Cleanup expired entries every 60 seconds
this.cleanupInterval = setInterval(() => {
this.cleanup();
}, 60 * 1000);
}

/**
* Get a value from cache
*/
get<T>(key: string): T | null {
const entry = this.cache.get(key) as CacheEntry<T> | undefined;

if (!entry) {
this.stats.misses++;
return null;
}

if (Date.now() > entry.expiresAt) {
this.cache.delete(key);
this.stats.misses++;
this.stats.size = this.cache.size;
return null;
}

this.stats.hits++;
return entry.value;
}

/**
* Set a value in cache with optional TTL
*/
set<T>(key: string, value: T, ttl?: number): void {
const expiresAt = Date.now() + (ttl || this.defaultTTL) * 1000;
this.cache.set(key, { value, expiresAt });
this.stats.size = this.cache.size;
}

/**
* Delete a specific key from cache
*/
delete(key: string): boolean {
const result = this.cache.delete(key);
this.stats.size = this.cache.size;
return result;
}

/**
* Delete all keys matching a pattern
*/
deletePattern(pattern: string): number {
let count = 0;
const regex = new RegExp(pattern.replace(/\*/g, '.*'));

for (const key of this.cache.keys()) {
if (regex.test(key)) {
this.cache.delete(key);
count++;
}
}

this.stats.size = this.cache.size;
return count;
}

/**
* Clear all cache entries
*/
clear(): void {
this.cache.clear();
this.stats = { hits: 0, misses: 0, size: 0 };
}

/**
* Get cache statistics
*/
getStats(): CacheStats {
return { ...this.stats };
}

/**
* Check if a key exists and is not expired
*/
has(key: string): boolean {
return this.get(key) !== null;
}

/**
* Get or set pattern - fetch from cache or compute and cache
*/
async getOrSet<T>(
key: string,
factory: () => Promise<T> | T,
ttl?: number
): Promise<T> {
const cached = this.get<T>(key);
if (cached !== null) {
return cached;
}

const value = await factory();
this.set(key, value, ttl);
return value;
}

/**
* Remove expired entries
*/
private cleanup(): void {
const now = Date.now();
let removed = 0;

for (const [key, entry] of this.cache.entries()) {
if (now > entry.expiresAt) {
this.cache.delete(key);
removed++;
}
}

if (removed > 0) {
this.stats.size = this.cache.size;
}
}

/**
* Destroy the cache service and cleanup intervals
*/
destroy(): void {
clearInterval(this.cleanupInterval);
this.cache.clear();
}
}

// Global cache instance
export const cache = new CacheService(300); // 5 minutes default TTL

// Cache key builders for consistency
export const CacheKeys = {
user: (userId: string) => `user:${userId}`,
userProfile: (userId: string) => `user:${userId}:profile`,
admin: (userId: string) => `admin:${userId}`,
hackathon: (id: string) => `hackathon:${id}`,
hackathons: () => `hackathons:list`,
event: (id: string) => `event:${id}`,
events: () => `events:list`,
judge: (userId: string) => `judge:${userId}`,
member: (userId: string) => `member:${userId}`,
} as const;

// Cache invalidation helpers
export const invalidateUser = (userId: string) => {
cache.deletePattern(`user:${userId}*`);
};

export const invalidateHackathons = () => {
cache.deletePattern('hackathon*');
};

export const invalidateEvents = () => {
cache.deletePattern('event*');
};
Loading
Loading