Skip to content
Merged

Dev #70

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
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ Guidelines for AI coding agents working on the official Micro Club website.
- Rate limits are counted in MongoDB, shared by every server instance: the Server Actions call `isRateLimited()` from `src/lib/rate-limit.ts` (`rate_limits` collection, keyed by `clientIp()`), Better Auth uses `rateLimit.storage: "database"` (`rateLimit` collection, on in production only). Both read the client IP from `x-real-ip`, then `x-forwarded-for`.
- `pnpm db:seed-registrations` fills the local database with fake registrations (emails `@seed.test`, replaced on each run; refuses a non-local `MONGODB_URI`). Their student IDs do not exist, so their page shows "File unavailable".
- Emails go through `sendEmail()` in `src/lib/email.ts` (Resend, `RESEND_API_KEY`) with the shared branded layout (`renderEmail`). Without a key, development prints them to the server console.
- Student IDs are uploaded by the browser straight to Cloudinary with a signature from `src/actions/public/sign-student-id-upload.ts` (`authenticated` assets, no public URL), then `src/actions/public/submit-registration.ts` reads the upload back and saves the registration. Needs the `CLOUDINARY_*` variables.
- Student IDs are uploaded by the browser straight to Cloudinary with a signature from `src/actions/public/sign-student-id-upload.ts` (`authenticated` assets, no public URL), then `src/actions/public/submit-registration.ts` checks Cloudinary's signature of the upload response (no Admin API call: the free plan allows 500 per hour) and saves the registration. Rate limits there are per email, with a loose per-IP backstop (campus networks and CGNAT share one IP). Needs the `CLOUDINARY_*` variables.
- Registrations open and close with `REGISTRATIONS_WINDOW` in `src/config/registrations.ts` (`opensAt` / `closesAt`, ISO dates or `null`), read through `registrationsPhase()` in `src/lib/registrations/window.ts`. Outside the window `/registrations` is a 404 and the actions refuse submissions; the menu bar shows "Opening soon" or "Register now" with a countdown (`RegistrationsButton` in `RegistrationsCorner`), or nothing when closed.

## Admin area and auth
Expand Down
5 changes: 3 additions & 2 deletions src/actions/public/sign-student-id-upload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,12 @@ export type SignatureResult =
| { status: "rate-limited" }
| { status: "error" }

const RATE_LIMIT = { limit: 10, windowMs: 60 * 60 * 1000 }
/** Per IP, loose: many students share one address (campus network, CGNAT), and retries re-sign. */
const RATE_LIMIT = { limit: 60, windowMs: 60 * 60 * 1000 }

/**
* Hands the browser a signed, short-lived permission to upload one student ID to Cloudinary.
* Each IP gets 10 per hour, so the signature cannot be used to fill the storage.
* Each IP gets 60 per hour, so the signature cannot be used to fill the storage.
*/
export async function createStudentIdUploadSignature(): Promise<SignatureResult> {
if (!areRegistrationsOpen()) return { status: "error" }
Expand Down
31 changes: 22 additions & 9 deletions src/actions/public/submit-registration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
import { headers } from "next/headers"

import { PHONE_COUNTRIES, STUDENT_ID_RULES } from "@/const/registrations"
import { deleteStudentId, getStudentIdUpload, type UploadedStudentId } from "@/lib/cloudinary"
import { deleteStudentId, verifyStudentIdUpload, type UploadedStudentId } from "@/lib/cloudinary"
import { connectToDatabase } from "@/lib/mongodb"
import { clientIp, isRateLimited } from "@/lib/rate-limit"
import { RegistrationModel } from "@/lib/registrations/model"
Expand Down Expand Up @@ -35,7 +35,12 @@ export type RegistrationResult =
| { status: "rate-limited" }
| { status: "error" }

const RATE_LIMIT = { limit: 5, windowMs: 60 * 60 * 1000 }
/**
* Per IP, a loose backstop only: a campus network or a mobile carrier (CGNAT) puts many students
* behind one address. The real limit is per email, the one key a registration has.
*/
const IP_RATE_LIMIT = { limit: 30, windowMs: 60 * 60 * 1000 }
const EMAIL_RATE_LIMIT = { limit: 5, windowMs: 60 * 60 * 1000 }
const MONGO_DUPLICATE_KEY = 11000

function readUploadReference(raw: FormDataEntryValue | null): StudentIdUpload | null {
Expand All @@ -44,7 +49,11 @@ function readUploadReference(raw: FormDataEntryValue | null): StudentIdUpload |
const valid =
typeof upload.publicId === "string" &&
STUDENT_ID_PUBLIC_ID.test(upload.publicId) &&
(upload.resourceType === "image" || upload.resourceType === "raw")
(upload.resourceType === "image" || upload.resourceType === "raw") &&
typeof upload.version === "number" &&
typeof upload.signature === "string" &&
typeof upload.format === "string" &&
typeof upload.bytes === "number"
return valid ? (upload as StudentIdUpload) : null
} catch {
return null
Expand All @@ -63,8 +72,8 @@ function uploadProblem(stored: UploadedStudentId) {
* Saves a registration: the form values (`values`, JSON), a reference to the student ID the browser
* already uploaded to Cloudinary (`upload`, JSON) and the honeypot, as one `FormData`.
*
* Order: validate -> rate limit -> duplicate email -> read the upload back from Cloudinary ->
* save in MongoDB. A rejected registration (invalid file, duplicate) deletes its upload; after a
* Order: validate -> rate limit (per IP and per email) -> duplicate email -> check Cloudinary's
* signature of the upload -> save in MongoDB. A rejected registration (invalid file, duplicate) deletes its upload; after a
* failure that may pass (database down) the upload stays, so the retry reuses it. A filled
* honeypot gets a fake success (bots learn nothing). Needs `MONGODB_URI` and the `CLOUDINARY_*`
* variables (`.env.example`).
Expand Down Expand Up @@ -108,8 +117,12 @@ export async function submitRegistration(formData: FormData): Promise<Registrati

const requestHeaders = await headers()
const ip = clientIp(requestHeaders)
if (await isRateLimited(`registration:${ip}`, RATE_LIMIT.limit, RATE_LIMIT.windowMs))
return { status: "rate-limited" }
const email = values.email.toLowerCase()
const [ipLimited, emailLimited] = await Promise.all([
isRateLimited(`registration:${ip}`, IP_RATE_LIMIT.limit, IP_RATE_LIMIT.windowMs),
isRateLimited(`registration-email:${email}`, EMAIL_RATE_LIMIT.limit, EMAIL_RATE_LIMIT.windowMs),
])
if (ipLimited || emailLimited) return { status: "rate-limited" }

const discard = () =>
deleteStudentId(upload).catch((error) =>
Expand All @@ -119,13 +132,13 @@ export async function submitRegistration(formData: FormData): Promise<Registrati
try {
await connectToDatabase()

const email = values.email.toLowerCase()
if (await RegistrationModel.exists({ email })) {
await discard()
return { status: "duplicate" }
}

const stored = await getStudentIdUpload(upload.publicId, upload.resourceType)
// A forged reference is refused without touching the file: it may not be this visitor's.
const stored = verifyStudentIdUpload(upload)
const problem = stored ? uploadProblem(stored) : "Please upload your ID again."
if (!stored || problem) {
if (stored) await discard()
Expand Down
54 changes: 34 additions & 20 deletions src/lib/cloudinary.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,30 +63,44 @@ export function signStudentIdUpload(): StudentIdUploadSignature {
}
}

/** The SDK's check of an upload response's signature, missing from its type definitions. */
const verifyApiResponseSignature = (
cloudinary.utils as unknown as {
verify_api_response_signature: (publicId: string, version: number, signature: string) => boolean
}
).verify_api_response_signature

/**
* Reads an uploaded student ID back from Cloudinary, so the size and format that get stored are
* the real ones, not what the browser claims. `null` when there is no such upload.
* Checks an upload the browser reports against Cloudinary's signature of its upload response
* (`public_id` and `version`, signed with the API secret), so no Admin API call is needed: the
* free plan allows 500 of those per hour, which would cap the registrations. The format was already
* enforced by Cloudinary (`allowed_formats` is part of the signed upload), the size by the plan's
* file limit; the stored URL is signed here. `null` when the signature does not match.
*/
export async function getStudentIdUpload(
publicId: string,
resourceType: string,
): Promise<UploadedStudentId | null> {
export function verifyStudentIdUpload(upload: {
publicId: string
resourceType: string
version: number
signature: string
format: string
bytes: number
}): UploadedStudentId | null {
configure()
try {
const resource = await cloudinary.api.resource(publicId, {
if (!verifyApiResponseSignature(upload.publicId, upload.version, upload.signature)) return null

return {
url: cloudinary.url(upload.publicId, {
type: "authenticated",
resource_type: resourceType,
})
return {
url: resource.secure_url,
publicId: resource.public_id,
resourceType: resource.resource_type,
format: resource.format,
bytes: resource.bytes,
}
} catch (error) {
if ((error as { error?: { http_code?: number } }).error?.http_code === 404) return null
throw error
resource_type: upload.resourceType,
version: upload.version,
format: upload.format,
sign_url: true,
secure: true,
}),
publicId: upload.publicId,
resourceType: upload.resourceType,
format: upload.format,
bytes: upload.bytes,
}
}

Expand Down
18 changes: 16 additions & 2 deletions src/lib/registrations/upload-student-id.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,16 @@
import type { StudentIdUploadSignature } from "@/lib/cloudinary"

/** Reference to a student ID already stored in Cloudinary, sent with the registration. */
/**
* Reference to a student ID already stored in Cloudinary, sent with the registration: Cloudinary's
* upload response, whose `signature` (over `publicId` and `version`) the server checks.
*/
export type StudentIdUpload = {
publicId: string
resourceType: string
version: number
signature: string
format: string
bytes: number
}

/** Public ids the server accepts: a file in the student ID folder, no path tricks. */
Expand Down Expand Up @@ -34,5 +41,12 @@ export async function uploadStudentId(
if (!response.ok || !result?.public_id)
throw new Error(result?.error?.message ?? `Upload failed (${response.status})`)

return { publicId: result.public_id, resourceType: result.resource_type }
return {
publicId: result.public_id,
resourceType: result.resource_type,
version: result.version,
signature: result.signature,
format: result.format,
bytes: result.bytes,
}
}
Loading