165 lines
5.6 KiB
TypeScript
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 };
|
|
}),
|
|
});
|