Initial commit of Vellum, an event photo product for guest uploads, host moderation, and original-quality galleries.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,154 @@
|
||||
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";
|
||||
|
||||
export const photosRouter = createTRPCRouter({
|
||||
create: publicProcedure
|
||||
.input(createPhotoInputSchema)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const [event] = await getDb()
|
||||
.select()
|
||||
.from(events)
|
||||
.where(eq(events.slug, input.eventSlug))
|
||||
.limit(1);
|
||||
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(eq(submissions.id, input.submissionId))
|
||||
.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.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: "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(eq(photos.id, photo.id));
|
||||
const uploadUrl = await createPresignedPutUrl({
|
||||
key,
|
||||
contentType: input.contentType,
|
||||
});
|
||||
return { photoId: photo.id, uploadUrl };
|
||||
}),
|
||||
|
||||
complete: publicProcedure
|
||||
.input(completePhotoInputSchema)
|
||||
.mutation(async ({ input }) => {
|
||||
const [photo] = await getDb()
|
||||
.select()
|
||||
.from(photos)
|
||||
.where(eq(photos.id, input.photoId))
|
||||
.limit(1);
|
||||
if (!photo) throw new TRPCError({ code: "NOT_FOUND" });
|
||||
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) => {
|
||||
await tx
|
||||
.update(photos)
|
||||
.set({
|
||||
processingStatus: "processing",
|
||||
byteSize: size,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(photos.id, photo.id));
|
||||
await tx.insert(photoJobs).values({
|
||||
photoId: photo.id,
|
||||
kind: "transcode",
|
||||
status: "pending",
|
||||
});
|
||||
});
|
||||
return { photoId: photo.id, processingStatus: "processing" as const };
|
||||
}),
|
||||
});
|
||||
Reference in New Issue
Block a user