diff --git a/public/2026-06-12.jpeg b/public/2026-06-12.jpeg new file mode 100644 index 0000000..40634a8 Binary files /dev/null and b/public/2026-06-12.jpeg differ diff --git a/src/app/api/admin/attendees/route.ts b/src/app/api/admin/attendees/route.ts index 3a07ad1..64a20f7 100644 --- a/src/app/api/admin/attendees/route.ts +++ b/src/app/api/admin/attendees/route.ts @@ -12,7 +12,7 @@ export async function GET(req: NextRequest) { } const { searchParams } = new URL(req.url) - const search = searchParams.get('search') || '' + const search = (searchParams.get('search') || '').slice(0, 100) const crewId = searchParams.get('crew_id') const checkedIn = searchParams.get('checked_in') const page = parseInt(searchParams.get('page') || '1', 10) diff --git a/src/app/api/redeem/route.ts b/src/app/api/redeem/route.ts index 526c215..bac34bc 100644 --- a/src/app/api/redeem/route.ts +++ b/src/app/api/redeem/route.ts @@ -7,6 +7,36 @@ const ory = new FrontendApi( new Configuration({ basePath: process.env.NEXT_PUBLIC_ORY_URL }) ) +// In-memory rate limiter: max 5 redemption attempts per identity per hour +const RATE_LIMIT = 5 +const RATE_WINDOW_MS = 60 * 60 * 1000 +const redeemAttempts = new Map() + +function checkRateLimit(identityId: string): boolean { + const now = Date.now() + const record = redeemAttempts.get(identityId) + if (!record || now > record.resetAt) { + redeemAttempts.set(identityId, { count: 1, resetAt: now + RATE_WINDOW_MS }) + return true + } + if (record.count >= RATE_LIMIT) return false + record.count++ + return true +} + +// Safe error codes the RPC may return that can be forwarded to the client +const SAFE_RPC_ERRORS = new Set([ + 'ticket_not_found', + 'ticket_already_redeemed', + 'already_registered', + 'crew_not_found', + 'crew_inactive', +]) + +function safeRedeemError(raw: string): string { + return SAFE_RPC_ERRORS.has(raw) ? raw : 'redemption_failed' +} + export async function POST(req: NextRequest) { const cookie = req.headers.get('cookie') || '' @@ -46,6 +76,14 @@ export async function POST(req: NextRequest) { const identityId = session.identity?.id const email = session.identity?.traits?.email + if (!identityId) { + return NextResponse.json({ error: 'unauthorized' }, { status: 401 }) + } + + if (!checkRateLimit(identityId)) { + return NextResponse.json({ error: 'too_many_attempts' }, { status: 429 }) + } + // Call Supabase function const { data, error } = await supabase.rpc('redeem_ticket', { p_uuid: uuid, @@ -63,7 +101,7 @@ export async function POST(req: NextRequest) { // data is the JSONB result from the function if (data.error) { - return NextResponse.json({ error: data.error }, { status: 400 }) + return NextResponse.json({ error: safeRedeemError(data.error) }, { status: 400 }) } // Resolve crew name for the confirmation email diff --git a/src/content/events/2026-06-12.mdx b/src/content/events/2026-06-12.mdx new file mode 100644 index 0000000..d900191 --- /dev/null +++ b/src/content/events/2026-06-12.mdx @@ -0,0 +1,54 @@ +--- +date: 2026-06-12 +cover: '/2026-06-12.jpeg' +mdxMeta: + title: 'Eisbach Callin Sommerfest' + description: 'Sunny Red, Feierwerk, Hansastraße 39, Munich' +--- + +Eisbach Callin' is celebrating summer in the city with an Open Decks party for new faces in Munich's bass music scene! + +## PLAY AT EISBACH CALLIN' + +If you love spinning Drum & Bass, Dubstep, or similar bass music, get in touch with us via [Instagram](https://www.instagram.com/eisbachcallin/)/[Facebook](https://www.facebook.com/eisbachcallin/) (@eisbachcallin) or send an email to mail@eisbachcallin.com + +Let us know what type of music you play, what equipment you need, and preferably include a link to one of your sets. + +We're looking forward to welcoming many exciting newcomers! And remember, don't act like a clown, dance like one! + +## Time + + + +## Location + + + +## Lineup + + + +### VJ + + + +### MC + + + +## Support underground music