From db206bafa34f87b30c02a633fe836a0fbc8f66e3 Mon Sep 17 00:00:00 2001 From: Dominic Ferrando Date: Sat, 26 Sep 2026 20:52:08 -0400 Subject: [PATCH] Basic setup for R2 verification presigning --- apps/api/src/server.ts | 37 +++++++++++++++++++++----- apps/api/src/services/verifications.ts | 23 ++++++++-------- apps/api/src/util.ts | 4 +++ 3 files changed, 46 insertions(+), 18 deletions(-) diff --git a/apps/api/src/server.ts b/apps/api/src/server.ts index 95aa1c8..9a9d280 100644 --- a/apps/api/src/server.ts +++ b/apps/api/src/server.ts @@ -3,6 +3,8 @@ import { ActivityVerificationsSchema, ActivityMediaKeysSchema, PlayerSchema, + minToMs, + Activity, } from "@blade-and-brawn/domain" import { cors } from "@elysiajs/cors"; import { Elysia, redirect, status, t } from "elysia"; @@ -17,7 +19,7 @@ import serverTiming from "@elysia/server-timing"; import jwt from "@elysia/jwt"; import { CommerceService, WOrderStatusSchema } from "./services/commerce"; import cluster from "node:cluster"; -import { randomUUIDv7, sleep } from "bun"; +import { randomUUIDv7, sleep, S3Client } from "bun"; import { DatabaseError } from "pg"; import { CalculatorService, CalculatorUnavailableError } from "./services/calculator"; import { StandardsParamsSchema } from "@blade-and-brawn/calculator"; @@ -47,6 +49,17 @@ const s = (() => { return { Standards, Calculator, Commerce, Accounts, Events, Assessments, Verifications }; })(); +// R2 BUCKETS +// ----------------------- +const r2 = { + verificationMedia: new S3Client({ + accessKeyId: env.R2_ACCESS_KEY_ID, + secretAccessKey: env.R2_SECRET_ACCESS_KEY, + bucket: "verification-media", + endpoint: env.R2_URL, + }) +}; + // QUEUES // ----------------------- const queues = [ @@ -506,20 +519,30 @@ export const app = new Elysia() }, { params: t.Object({ id: BigIntIdSchema }) }) - .post("/verifications", async ({ body: { assessmentId, activityMediaKeys }, accountId }) => { + .post("/verifications", async ({ body: { assessmentId }, accountId }) => { const assessment = await s.Assessments.get(assessmentId, accountId); if (!assessment) return status(404, { error: "Assessment not found" }); // TODO: only allow a single active non-completed verification at a time - // TODO: return R2 POST urls for the media - const created = await s.Verifications.create(assessmentId, activityMediaKeys); + const created = await s.Verifications.create(assessmentId); if (!created) return status(409, { error: "This assessment already has a verification" }); - return created; + + // Generate upload urls + const activityMediaUrls: Record = {}; + for (const activity of Object.values(Activity)) { + activityMediaUrls[activity] = r2.verificationMedia.presign( + `${assessmentId}/${activity}-${crypto.randomUUID()}`, + { + method: "PUT", + expiresIn: 30 * 60 // 30 minutes + }); + } + + return { created, activityMediaUrls }; }, { body: t.Object({ - assessmentId: BigIntIdSchema, - activityMediaKeys: ActivityMediaKeysSchema, + assessmentId: BigIntIdSchema }) }) .patch("/verifications/:id", async ({ params: { id }, body: { assessmentId, activityMediaKeys }, accountId }) => { diff --git a/apps/api/src/services/verifications.ts b/apps/api/src/services/verifications.ts index 898b554..cb783e4 100644 --- a/apps/api/src/services/verifications.ts +++ b/apps/api/src/services/verifications.ts @@ -14,8 +14,6 @@ export const VerificationStatusSchema = t.Union([ ]); export type VerificationStatus = Static; -const REQUIRED_MEDIA_ACTIVITIES = Object.values(Activity); - const MEDIA_KEY_COLUMN = { [Activity.BackSquat]: "media_key_back_squat", [Activity.Deadlift]: "media_key_deadlift", @@ -33,21 +31,24 @@ const ALLOWED_FROM = { } satisfies Record; export class VerificationsService { + static readonly REQUIRED_MEDIA_ACTIVITIES = Object.values(Activity); + static readonly ALLOWED_MEDIA_RE = /^(video\/(mp4|quicktime|webm)|image\/(png|jpeg))$/; + static missingActivityMediaKeys(verification: Pick, (typeof MEDIA_KEY_COLUMN)[Activity]>) { - return REQUIRED_MEDIA_ACTIVITIES.filter((activity) => !verification[MEDIA_KEY_COLUMN[activity]]); + return VerificationsService.REQUIRED_MEDIA_ACTIVITIES.filter((activity) => !verification[MEDIA_KEY_COLUMN[activity]]); } - async create(assessmentId: string, activityMediaKeys: ActivityMediaKeys) { + async create(assessmentId: string, activityMediaKeys?: ActivityMediaKeys) { return await db.insertInto("verifications") .values({ assessment_id: assessmentId, status: "draft" satisfies VerificationStatus, - media_key_back_squat: activityMediaKeys[Activity.BackSquat] ?? null, - media_key_deadlift: activityMediaKeys[Activity.Deadlift] ?? null, - media_key_bench_press: activityMediaKeys[Activity.BenchPress] ?? null, - media_key_broad_jump: activityMediaKeys[Activity.BroadJump] ?? null, - media_key_run: activityMediaKeys[Activity.Run] ?? null, - media_key_cone_drill: activityMediaKeys[Activity.ConeDrill] ?? null, + media_key_back_squat: activityMediaKeys?.[Activity.BackSquat] ?? null, + media_key_deadlift: activityMediaKeys?.[Activity.Deadlift] ?? null, + media_key_bench_press: activityMediaKeys?.[Activity.BenchPress] ?? null, + media_key_broad_jump: activityMediaKeys?.[Activity.BroadJump] ?? null, + media_key_run: activityMediaKeys?.[Activity.Run] ?? null, + media_key_cone_drill: activityMediaKeys?.[Activity.ConeDrill] ?? null, }) .onConflict((oc) => oc.column("assessment_id").doNothing()) .returning(["id", "status"]) @@ -109,7 +110,7 @@ export class VerificationsService { .where("id", "=", id) .where("status", "in", ALLOWED_FROM.submit) .where((eb) => eb.and( - REQUIRED_MEDIA_ACTIVITIES.map((activity) => eb(MEDIA_KEY_COLUMN[activity], "is not", null)) + VerificationsService.REQUIRED_MEDIA_ACTIVITIES.map((activity) => eb(MEDIA_KEY_COLUMN[activity], "is not", null)) )) .set({ status: "submitted" satisfies VerificationStatus, submitted_at: new Date() }) .returning(["id", "status"]) diff --git a/apps/api/src/util.ts b/apps/api/src/util.ts index 1ecaeca..9be31ca 100644 --- a/apps/api/src/util.ts +++ b/apps/api/src/util.ts @@ -32,6 +32,10 @@ export const env = { BOT_CLIENT_SECRET: requireEnv("BOT_CLIENT_SECRET"), BOT_REDIRECT_URL: requireEnv("BOT_REDIRECT_URL"), BOT_LOGIN_REDIRECT_URL: requireEnv("BOT_LOGIN_REDIRECT_URL"), + CLOUDFLARE_TOKEN: requireEnv("CLOUDFLARE_TOKEN"), + R2_ACCESS_KEY_ID: requireEnv("R2_ACCESS_KEY_ID"), + R2_SECRET_ACCESS_KEY: requireEnv("R2_SECRET_ACCESS_KEY"), + R2_URL: requireEnv("R2_URL") }; export const WORKER_COUNT = Math.min(os.availableParallelism(), +env.MAX_WORKER_COUNT);