Files
manyangles/apps/web/src/server/api/routers/photos.ts
T

165 lines
5.6 KiB
TypeScript

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 };
}),
});