feat: end to end api type safety with redis rate limiting (#573)

* feat: support hono rpc

* add wrangler file

* update name of wrangler file

* add wrangler as a dependency

* add compatibility_flags

* trigger ci

* core: add health check to docker file

* expose port 80

* update custom healthcheck

* feat: implement openapi

* install dependencies in root

* debug yml

* update yml

* update port

* update dockerfile with envs

* completely move away from apiClient

* rename backend to api

* refactor: update server descriptions and comment out local spec saving

* update lock file

* use api.classroomio.com

* add support for redis

* fix: add missing line break in WelcomeModal.svelte

* refactor: remove wrangler configuration and update Vite config for API integration
This commit is contained in:
Rotimi Best
2025-09-21 19:35:41 +01:00
committed by GitHub
parent a6880f1b54
commit 6d68351a46
102 changed files with 2938 additions and 1633 deletions
+105
View File
@@ -0,0 +1,105 @@
/**
* Key generation utilities for rate limiting
* These functions help create consistent and secure rate limit keys
*/
import type { Context } from 'hono';
import { supabase } from '$src/utils/supabase';
/**
* Extract user ID from JWT token
* In a real implementation, you'd decode and verify the JWT
*/
export const extractUserIdFromToken = async (token: string): Promise<string | null> => {
try {
const { data } = await supabase.auth.getUser(token);
return data.user?.id || null;
} catch {
return null;
}
};
/**
* Extract real client IP address considering various proxy headers
*/
export const extractClientIp = (c: Context): string => {
// Priority order (most trusted first):
// 1. CF-Connecting-IP (Cloudflare)
// 2. X-Real-IP (nginx)
// 3. X-Forwarded-For (standard, but can be spoofed)
// 4. Connection IP (least reliable)
const cfConnectingIp = c.req.header('cf-connecting-ip');
const realIp = c.req.header('x-real-ip');
const forwardedFor = c.req.header('x-forwarded-for');
if (cfConnectingIp) {
return cfConnectingIp;
}
if (realIp) {
return realIp;
}
if (forwardedFor) {
// x-forwarded-for can contain multiple IPs: "client, proxy1, proxy2"
// The first IP is usually the original client
return forwardedFor.split(',')[0].trim();
}
// Fallback - this might be a proxy IP
return 'unknown';
};
/**
* Generate rate limit key for authenticated users
*/
export const userKeyGenerator = (c: Context): string => {
const authHeader = c.req.header('authorization');
if (authHeader) {
const token = authHeader.split(' ')[1];
if (token && token !== 'null' && token !== 'undefined') {
const userId = extractUserIdFromToken(token);
if (userId) {
return `user:${userId}`;
}
// Fallback to token if user ID extraction fails
return `token:${token}`;
}
}
// No valid auth, fall back to IP
return `ip:${extractClientIp(c)}`;
};
/**
* Generate rate limit key for API keys
*/
export const apiKeyGenerator = (c: Context): string => {
const apiKey = c.req.header('X-API-Key');
if (apiKey) {
return `api:${apiKey}`;
}
// Fallback to IP if no API key
return `ip:${extractClientIp(c)}`;
};
/**
* Generate rate limit key based on IP only
*/
export const ipKeyGenerator = (c: Context): string => {
return `ip:${extractClientIp(c)}`;
};
/**
* Generate rate limit key for specific endpoints
*/
export const endpointKeyGenerator =
(endpoint: string) =>
(c: Context): string => {
const baseKey = userKeyGenerator(c);
return `${baseKey}:${endpoint}`;
};
+71
View File
@@ -0,0 +1,71 @@
import {
DEFAULT_MAX_REQUESTS,
DEFAULT_WINDOW_MS,
ERROR_MESSAGES,
RATE_LIMIT_KEY_PREFIX
} from '$src/constants/rate-limiter';
import { Redis } from 'ioredis';
import { redis } from './redis';
export class RedisRateLimiter {
private redis: Redis;
private windowMs: number;
private maxRequests: number;
constructor(
redis: Redis,
windowMs: number = DEFAULT_WINDOW_MS,
maxRequests: number = DEFAULT_MAX_REQUESTS
) {
this.redis = redis;
this.windowMs = windowMs;
this.maxRequests = maxRequests;
}
async isAllowed(
key: string
): Promise<{ allowed: boolean; remaining: number; resetTime: number }> {
const now = Date.now();
const windowStart = now - this.windowMs;
const keyWithPrefix = `${RATE_LIMIT_KEY_PREFIX}${key}`;
// Use Redis pipeline for atomic operations
const pipeline = this.redis.pipeline();
// Remove expired entries
pipeline.zremrangebyscore(keyWithPrefix, '-inf', windowStart);
// Count current requests in window
pipeline.zcard(keyWithPrefix);
// Add current request
pipeline.zadd(keyWithPrefix, now, `${now}-${Math.random()}`);
// Set expiration for the key
pipeline.expire(keyWithPrefix, Math.ceil(this.windowMs / 1000));
const results = await pipeline.exec();
if (!results || results.some((result) => result[0] !== null)) {
throw new Error(ERROR_MESSAGES.REDIS_PIPELINE_FAILED);
}
const currentCount = results[1][1] as number;
const remaining = Math.max(0, this.maxRequests - currentCount - 1);
const resetTime = now + this.windowMs;
return {
allowed: currentCount < this.maxRequests,
remaining,
resetTime
};
}
async reset(key: string): Promise<void> {
const keyWithPrefix = `${RATE_LIMIT_KEY_PREFIX}${key}`;
await this.redis.del(keyWithPrefix);
}
}
export const rateLimiter = new RedisRateLimiter(redis);
+4
View File
@@ -0,0 +1,4 @@
import { Redis } from 'ioredis';
import { env } from '$src/config/env';
export const redis = new Redis(env.REDIS_URL);