import { TRPCError } from "@trpc/server"; import { and, eq } from "drizzle-orm"; import { events, getDb, photoJobs, photos, submissions } from "@album/database"; import { completePhotoInputSchema, createPhotoInputSchema, MAX_PHOTO_BYTES, } from "@album/contracts"; import { createPresignedPutUrl, headObject, originalObjectKey, } from "@album/storage"; import { createTRPCRouter, publicProcedure } from "../trpc"; import { consumeRateLimit } from "@/server/rate-limit"; import { hashToken } from "@/server/tokens"; import { guests } from "@album/database"; import { effectiveEvent } from "@/lib/event-lifecycle"; export const photosRouter = createTRPCRouter({ create: publicProcedure .input(createPhotoInputSchema) .mutation(async ({ ctx, input }) => { const [stored] = await getDb() .select() .from(events) .where(eq(events.slug, input.eventSlug)) .limit(1); const event = stored ? effectiveEvent(stored) : null; if (!event || event.status === "draft") { throw new TRPCError({ code: "NOT_FOUND", message: "Event not found" }); } if (event.status === "closed" || !event.uploadEnabled) { throw new TRPCError({ code: "FORBIDDEN", message: "Uploads are closed for this event", }); } const [submission] = await getDb() .select() .from(submissions) .where(and(eq(submissions.id, input.submissionId), eq(submissions.eventId, event.id))) .limit(1); if (!submission || submission.eventId !== event.id) { throw new TRPCError({ code: "BAD_REQUEST", message: "Submission not found", }); } const token = ctx.guestTokenForEvent(event.id); if (!token) { throw new TRPCError({ code: "BAD_REQUEST", message: "Guest session is missing", }); } const [guest] = await getDb() .select() .from(guests) .where( and( eq(guests.id, submission.guestId), eq(guests.eventId, event.id), eq(guests.tokenHash, hashToken(token)), ), ) .limit(1); if (!guest) { throw new TRPCError({ code: "FORBIDDEN", message: "Guest session does not match this submission", }); } const limit = await consumeRateLimit({ namespace: `upload:${event.id}`, identifier: ctx.clientIdentifier, limit: 40, windowMs: 10 * 60 * 1000, }); if (!limit.allowed) { throw new TRPCError({ code: "TOO_MANY_REQUESTS", message: "Too many uploads. Try again shortly.", }); } const [photo] = await getDb() .insert(photos) .values({ eventId: event.id, submissionId: submission.id, processingStatus: "uploading", visibility: event.galleryPolicy === "automatic" ? "public" : "pending", originalKey: "pending", contentType: input.contentType, byteSize: input.byteSize, }) .returning(); if (!photo) throw new TRPCError({ code: "INTERNAL_SERVER_ERROR" }); const key = originalObjectKey(event.id, photo.id); await getDb() .update(photos) .set({ originalKey: key, updatedAt: new Date() }) .where(and(eq(photos.id, photo.id), eq(photos.eventId, event.id))); const uploadUrl = await createPresignedPutUrl({ key, contentType: input.contentType, }); return { photoId: photo.id, uploadUrl }; }), complete: publicProcedure .input(completePhotoInputSchema) .mutation(async ({ ctx, input }) => { const [photo] = await getDb() .select() .from(photos) .where(eq(photos.id, input.photoId)) .limit(1); if (!photo) throw new TRPCError({ code: "NOT_FOUND" }); const token = ctx.guestTokenForEvent(photo.eventId); const [owner] = token ? await getDb().select({ id: guests.id }).from(guests) .innerJoin(submissions, and(eq(submissions.guestId, guests.id), eq(submissions.eventId, photo.eventId))) .where(and(eq(submissions.id, photo.submissionId), eq(guests.eventId, photo.eventId), eq(guests.tokenHash, hashToken(token)))).limit(1) : []; if (!owner) throw new TRPCError({ code: "FORBIDDEN", message: "Guest session does not match this upload" }); if (photo.processingStatus !== "uploading") { return { photoId: photo.id, processingStatus: photo.processingStatus, }; } const head = await headObject(photo.originalKey); if (!head) { throw new TRPCError({ code: "BAD_REQUEST", message: "Upload was not found. Try again.", }); } const size = Number(head.ContentLength ?? 0); if (size <= 0 || size > MAX_PHOTO_BYTES) { throw new TRPCError({ code: "BAD_REQUEST", message: "That file is empty or larger than 25 MB.", }); } await getDb().transaction(async (tx) => { const changed = await tx .update(photos) .set({ processingStatus: "processing", byteSize: size, updatedAt: new Date(), }) .where(and(eq(photos.id, photo.id), eq(photos.eventId, photo.eventId), eq(photos.processingStatus, "uploading"))) .returning({ id: photos.id }); if (!changed.length) return; await tx.insert(photoJobs).values({ photoId: photo.id, kind: "transcode", status: "pending", }); }); return { photoId: photo.id, processingStatus: "processing" as const }; }), });