Skip to content

Commit 0bae4e0

Browse files
committed
fix: fix deploy issue bug and implement rate limit
1 parent e9fa184 commit 0bae4e0

25 files changed

Lines changed: 726 additions & 92 deletions

.gitattributes

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
# Auto detect text files and perform LF normalization
2+
* text=auto
3+
4+
# Explicitly declare text files you want to always be normalized and converted to native line endings on checkout
5+
*.ts text eol=lf
6+
*.tsx text eol=lf
7+
*.js text eol=lf
8+
*.jsx text eol=lf
9+
*.mjs text eol=lf
10+
*.cjs text eol=lf
11+
*.json text eol=lf
12+
*.md text eol=lf
13+
*.yml text eol=lf
14+
*.yaml text eol=lf
15+
*.toml text eol=lf
16+
*.css text eol=lf
17+
*.scss text eol=lf
18+
*.html text eol=lf
19+
*.xml text eol=lf
20+
*.svg text eol=lf
21+
*.sh text eol=lf
22+
23+
# Lock files should maintain LF
24+
package-lock.json text eol=lf
25+
bun.lockb binary
26+
bun.lock text eol=lf
27+
yarn.lock text eol=lf
28+
pnpm-lock.yaml text eol=lf
29+
30+
# Denote all files that are truly binary and should not be modified
31+
*.png binary
32+
*.jpg binary
33+
*.jpeg binary
34+
*.gif binary
35+
*.ico binary
36+
*.woff binary
37+
*.woff2 binary
38+
*.ttf binary
39+
*.eot binary
40+
*.pdf binary
41+
*.zip binary
42+
*.gz binary
43+
44+
# Windows-specific files
45+
*.bat text eol=crlf
46+
*.cmd text eol=crlf
47+
*.ps1 text eol=crlf

.gitignore

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,3 +39,17 @@ yarn-error.log*
3939

4040
*storybook.log
4141
storybook-static
42+
43+
# IDE
44+
.vscode/*
45+
!.vscode/settings.json
46+
!.vscode/tasks.json
47+
!.vscode/launch.json
48+
!.vscode/extensions.json
49+
.idea
50+
*.swp
51+
*.swo
52+
*~
53+
54+
# OS
55+
Thumbs.db

apps/backend/.env.example

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
# Database Configuration
2+
DATABASE_URL=postgresql://user:password@localhost:5432/eventer
3+
4+
# Supabase Configuration
5+
SUPABASE_URL=https://your-project.supabase.co
6+
SUPABASE_KEY=your-anon-key-here
7+
8+
# Server Configuration
9+
PORT=4000
10+
NODE_ENV=development
11+
12+
# CORS Configuration
13+
# In development: http://localhost:3000
14+
# In production: https://your-domain.com
15+
CORS_ORIGIN=http://localhost:3000

apps/backend/src/index.ts

Lines changed: 17 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -25,13 +25,10 @@ const conditionalSwagger = () => {
2525
const app = new Elysia({ aot: false })
2626
.use(
2727
cors({
28-
// origin: "https://eventer.betich.me",
29-
origin: /.*\.betich\.me$/,
30-
// [
31-
// /.*\.betich\.me$/,
32-
// process.env.NODE_ENV === "development" ? /localhost:\d+/ : "",
33-
// env.CORS_ORIGIN || "",
34-
// ],
28+
origin:
29+
process.env.NODE_ENV === "production"
30+
? [/^https:\/\/.*\.betich\.me$/] // Only your domain in production
31+
: [env.CORS_ORIGIN, /localhost:\d+/], // Allow localhost in dev
3532
credentials: true,
3633
preflight: true,
3734
methods: ["GET", "POST", "PUT", "DELETE", "OPTIONS"],
@@ -43,9 +40,20 @@ const app = new Elysia({ aot: false })
4340
.use(userRouter)
4441
.use(agendaRouter)
4542
.use(authRouter)
46-
.onRequest(({ set }) => {
43+
.onRequest(({ request, set }) => {
4744
set.headers["access-control-allow-credentials"] = "true";
48-
// set.headers["access-control-allow-origin"] = "https://eventer.betich.me";
45+
46+
// Force HTTPS in production
47+
if (process.env.NODE_ENV === "production") {
48+
const proto = request.headers.get("x-forwarded-proto");
49+
if (proto && proto !== "https") {
50+
const url = new URL(request.url);
51+
url.protocol = "https:";
52+
set.status = 301;
53+
set.headers.location = url.toString();
54+
return;
55+
}
56+
}
4957
});
5058
// .group("/api", (app) =>
5159
// app

apps/backend/src/modules/agenda/agenda.route.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { Elysia, t } from "elysia";
22
import { db } from "#backend/infrastructure/db";
3+
import { rateLimit, rateLimitPresets } from "#backend/shared/middleware/rate-limit.middleware";
34
import {
45
AgendaListResponseSchema,
56
AgendaSchema,
@@ -24,6 +25,13 @@ const agendaRepository = new AgendaRepository(db);
2425
//TODO : Implement useGetAgenda(eventId, currentDay)
2526

2627
export const agendaRouter = new Elysia({ prefix: "/api/agenda" })
28+
// Apply moderate rate limiting to agenda endpoints (30 requests per minute)
29+
.use(
30+
rateLimit({
31+
...rateLimitPresets.moderate,
32+
message: "Too many requests to agenda API. Please slow down.",
33+
})
34+
)
2735

2836
.get(
2937
"/timer",

apps/backend/src/modules/auth/auth.route.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { Elysia } from "elysia";
22
import { db } from "#backend/infrastructure/db";
33
import { supabase } from "#backend/infrastructure/db/supabase";
44
import { UserRepository } from "#backend/modules/user/user.repository";
5+
import { rateLimit, rateLimitPresets } from "../../shared/middleware/rate-limit.middleware";
56
import {
67
AuthCallbackSchema,
78
AuthResponseSchema,
@@ -13,6 +14,13 @@ import {
1314
const userRepository = new UserRepository(db);
1415

1516
export const authRouter = new Elysia({ prefix: "/api/auth" })
17+
// Apply strict rate limiting to all auth endpoints (5 requests per minute)
18+
.use(
19+
rateLimit({
20+
...rateLimitPresets.strict,
21+
message: "Too many authentication attempts. Please try again later.",
22+
})
23+
)
1624
.get(
1725
"/session",
1826
async ({ cookie: { session } }) => {

apps/backend/src/modules/event/event.route.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { Elysia } from "elysia";
22
import { db } from "#backend/infrastructure/db";
33
import { authMiddleware, optionalAuthMiddleware } from "#backend/shared/middleware/auth.middleware";
4+
import { rateLimit, rateLimitPresets } from "#backend/shared/middleware/rate-limit.middleware";
45
import {
56
CreateEventSchema,
67
EventListResponseSchema,
@@ -13,6 +14,13 @@ import { createEvent, listEvents } from "./services/crud-event.service";
1314
const eventRepository = new EventRepository(db);
1415

1516
export const eventRouter = new Elysia({ prefix: "/api/event" })
17+
// Apply moderate rate limiting to event endpoints (30 requests per minute)
18+
.use(
19+
rateLimit({
20+
...rateLimitPresets.moderate,
21+
message: "Too many requests to event API. Please slow down.",
22+
})
23+
)
1624
.use(optionalAuthMiddleware)
1725
.get(
1826
"/",
Lines changed: 186 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,186 @@
1+
import { Elysia } from "elysia";
2+
3+
interface RateLimitConfig {
4+
/**
5+
* Maximum number of requests allowed within the duration window
6+
*/
7+
max: number;
8+
9+
/**
10+
* Time window in milliseconds
11+
*/
12+
duration: number;
13+
14+
/**
15+
* Optional custom key generator function
16+
* Defaults to using IP address
17+
*/
18+
generator?: (context: {
19+
request: Request;
20+
headers: Record<string, string | undefined>;
21+
}) => string;
22+
23+
/**
24+
* Error message when rate limit is exceeded
25+
*/
26+
message?: string;
27+
28+
/**
29+
* Skip rate limiting based on custom logic
30+
*/
31+
skip?: (context: { request: Request }) => boolean;
32+
}
33+
34+
interface RateLimitEntry {
35+
count: number;
36+
resetTime: number;
37+
}
38+
39+
class RateLimiter {
40+
private store: Map<string, RateLimitEntry> = new Map();
41+
private cleanupInterval: Timer;
42+
43+
constructor() {
44+
// Clean up expired entries every minute
45+
this.cleanupInterval = setInterval(() => {
46+
const now = Date.now();
47+
for (const [key, entry] of this.store.entries()) {
48+
if (entry.resetTime < now) {
49+
this.store.delete(key);
50+
}
51+
}
52+
}, 60000);
53+
}
54+
55+
public check(
56+
key: string,
57+
max: number,
58+
duration: number
59+
): { allowed: boolean; remaining: number; resetTime: number } {
60+
const now = Date.now();
61+
const entry = this.store.get(key);
62+
63+
// No existing entry or entry has expired
64+
if (!entry || entry.resetTime < now) {
65+
const resetTime = now + duration;
66+
this.store.set(key, { count: 1, resetTime });
67+
return {
68+
allowed: true,
69+
remaining: max - 1,
70+
resetTime,
71+
};
72+
}
73+
74+
// Increment count and check if limit exceeded
75+
entry.count++;
76+
this.store.set(key, entry);
77+
78+
return {
79+
allowed: entry.count <= max,
80+
remaining: Math.max(0, max - entry.count),
81+
resetTime: entry.resetTime,
82+
};
83+
}
84+
85+
public destroy() {
86+
clearInterval(this.cleanupInterval);
87+
this.store.clear();
88+
}
89+
}
90+
91+
// Global rate limiter instance
92+
const rateLimiter = new RateLimiter();
93+
94+
/**
95+
* Rate limiting middleware for Elysia
96+
*
97+
* @example
98+
* ```ts
99+
* app.use(rateLimit({
100+
* max: 5, // 5 requests
101+
* duration: 60000, // per minute
102+
* }))
103+
* ```
104+
*/
105+
export function rateLimit(config: RateLimitConfig) {
106+
const { max, duration, generator, message, skip } = config;
107+
108+
return new Elysia({ name: "rate-limit" }).onBeforeHandle(({ request, set, headers }) => {
109+
// Skip rate limiting if custom skip function returns true
110+
if (skip && skip({ request })) {
111+
return;
112+
}
113+
114+
// Generate unique key for this client
115+
const key = generator ? generator({ request, headers }) : getClientIdentifier(request, headers);
116+
117+
// Check rate limit
118+
const result = rateLimiter.check(key, max, duration);
119+
120+
// Set rate limit headers
121+
set.headers["X-RateLimit-Limit"] = max.toString();
122+
set.headers["X-RateLimit-Remaining"] = result.remaining.toString();
123+
set.headers["X-RateLimit-Reset"] = new Date(result.resetTime).toISOString();
124+
125+
// If limit exceeded, return 429 Too Many Requests
126+
if (!result.allowed) {
127+
set.status = 429;
128+
const retryAfter = Math.ceil((result.resetTime - Date.now()) / 1000);
129+
set.headers["Retry-After"] = retryAfter.toString();
130+
131+
throw new Error(message || `Too many requests. Please try again in ${retryAfter} seconds.`);
132+
}
133+
});
134+
}
135+
136+
/**
137+
* Get client identifier from request
138+
* Tries to use IP address from various headers, falls back to random identifier
139+
*/
140+
function getClientIdentifier(
141+
request: Request,
142+
headers: Record<string, string | undefined>
143+
): string {
144+
// Try to get IP from common headers
145+
const forwardedFor = headers["x-forwarded-for"];
146+
if (forwardedFor) {
147+
return forwardedFor.split(",")[0]?.trim() || "unknown";
148+
}
149+
150+
const realIp = headers["x-real-ip"];
151+
if (realIp) {
152+
return realIp;
153+
}
154+
155+
const cfConnectingIp = headers["cf-connecting-ip"];
156+
if (cfConnectingIp) {
157+
return cfConnectingIp;
158+
}
159+
160+
// Fallback to URL+User-Agent combo if no IP available
161+
const userAgent = headers["user-agent"] || "unknown";
162+
return `${request.url}-${userAgent}`;
163+
}
164+
165+
/**
166+
* Predefined rate limit configurations
167+
*/
168+
export const rateLimitPresets = {
169+
/**
170+
* Strict rate limit for sensitive operations (auth, password reset, etc.)
171+
* 5 requests per minute
172+
*/
173+
strict: { max: 5, duration: 60000 },
174+
175+
/**
176+
* Moderate rate limit for API endpoints
177+
* 30 requests per minute
178+
*/
179+
moderate: { max: 30, duration: 60000 },
180+
181+
/**
182+
* Relaxed rate limit for public endpoints
183+
* 100 requests per minute
184+
*/
185+
relaxed: { max: 100, duration: 60000 },
186+
};

0 commit comments

Comments
 (0)