From c6d0f900381536fdc0a911532aeb89b18ffc4048 Mon Sep 17 00:00:00 2001 From: Matt Date: Thu, 5 Mar 2026 13:06:17 +0100 Subject: [PATCH] fix: presigned URL signatures, bucket consolidation, login & invite status - MinIO: use separate public client for presigned URLs so AWS V4 signature matches the browser's Host header (fixes SignatureDoesNotMatch on all uploads) - Consolidate applicant/partner uploads to mopc-files bucket (removes non-existent mopc-submissions and mopc-partners buckets) - Auth: allow magic links for any non-SUSPENDED user (was ACTIVE-only, blocking first-time CSV-seeded applicants) - Auth: accept invite tokens for any non-SUSPENDED user (was INVITED-only) - Ensure all 14 invite token locations set status to INVITED Co-Authored-By: Claude Opus 4.6 --- src/lib/auth.ts | 10 ++-- src/lib/minio.ts | 64 +++++++++-------------- src/server/routers/applicant.ts | 11 ++-- src/server/routers/dashboard.ts | 4 +- src/server/routers/partner.ts | 11 ++-- src/server/routers/project.ts | 6 +-- src/server/routers/specialAward.ts | 2 +- src/server/routers/user.ts | 1 + src/server/services/round-finalization.ts | 1 + 9 files changed, 50 insertions(+), 60 deletions(-) diff --git a/src/lib/auth.ts b/src/lib/auth.ts index aaedba3..281a833 100644 --- a/src/lib/auth.ts +++ b/src/lib/auth.ts @@ -58,14 +58,16 @@ export const { handlers, auth, signIn, signOut } = NextAuth({ from: process.env.EMAIL_FROM || 'MOPC Platform ', maxAge: parseInt(process.env.MAGIC_LINK_EXPIRY || '900'), // 15 minutes sendVerificationRequest: async ({ identifier: email, url }) => { - // Only send magic links to existing, ACTIVE users + // Send magic links to any existing, non-suspended user. + // This is the primary first-login path for applicants seeded from CSV + // (status NONE) who have no password yet. const existingUser = await prisma.user.findUnique({ where: { email: email.toLowerCase().trim() }, select: { status: true }, }) - if (!existingUser || existingUser.status !== 'ACTIVE') { + if (!existingUser || existingUser.status === 'SUSPENDED') { // Silently skip — don't reveal whether the email exists (prevents enumeration) - console.log(`[auth] Magic link requested for non-active/unknown email: ${email}`) + console.log(`[auth] Magic link requested for suspended/unknown email: ${email}`) return } await sendMagicLinkEmail(email, url) @@ -96,7 +98,7 @@ export const { handlers, auth, signIn, signOut } = NextAuth({ }, }) - if (!user || user.status !== 'INVITED') { + if (!user || user.status === 'SUSPENDED') { return null } diff --git a/src/lib/minio.ts b/src/lib/minio.ts index e32e7ab..8ebd826 100644 --- a/src/lib/minio.ts +++ b/src/lib/minio.ts @@ -1,8 +1,9 @@ import * as Minio from 'minio' -// MinIO client singleton (lazy-initialized to avoid build-time errors) +// MinIO client singletons (lazy-initialized to avoid build-time errors) const globalForMinio = globalThis as unknown as { minio: Minio.Client | undefined + minioPublic: Minio.Client | undefined } // Internal endpoint for server-to-server communication @@ -12,8 +13,8 @@ const MINIO_ENDPOINT = process.env.MINIO_ENDPOINT || 'http://localhost:9000' // If not set, falls back to internal endpoint export const MINIO_PUBLIC_ENDPOINT = process.env.MINIO_PUBLIC_ENDPOINT || MINIO_ENDPOINT -function createMinioClient(): Minio.Client { - const url = new URL(MINIO_ENDPOINT) +function createClientFromUrl(endpoint: string): Minio.Client { + const url = new URL(endpoint) const accessKey = process.env.MINIO_ACCESS_KEY const secretKey = process.env.MINIO_SECRET_KEY @@ -31,18 +32,29 @@ function createMinioClient(): Minio.Client { } /** - * Get the MinIO client instance (lazy-initialized). - * The client is only created on first access, not at module import time. - * This prevents build-time errors when env vars are not available. + * Get the internal MinIO client (for server-side operations: bucket ops, delete, etc.) */ export function getMinioClient(): Minio.Client { if (!globalForMinio.minio) { - globalForMinio.minio = createMinioClient() + globalForMinio.minio = createClientFromUrl(MINIO_ENDPOINT) } return globalForMinio.minio } -// Backward-compatible export — lazy getter via Proxy +/** + * Get the public MinIO client (for generating presigned URLs). + * Uses MINIO_PUBLIC_ENDPOINT so the AWS V4 signature matches the Host + * header the browser will send. Without this, presigned URLs generated + * against the internal hostname fail with SignatureDoesNotMatch. + */ +function getPublicMinioClient(): Minio.Client { + if (!globalForMinio.minioPublic) { + globalForMinio.minioPublic = createClientFromUrl(MINIO_PUBLIC_ENDPOINT) + } + return globalForMinio.minioPublic +} + +// Backward-compatible export — lazy getter via Proxy (internal client) export const minio: Minio.Client = new Proxy({} as Minio.Client, { get(_target, prop, receiver) { return Reflect.get(getMinioClient(), prop, receiver) @@ -52,33 +64,14 @@ export const minio: Minio.Client = new Proxy({} as Minio.Client, { // Default bucket name export const BUCKET_NAME = process.env.MINIO_BUCKET || 'mopc-files' -/** - * Replace internal endpoint with public endpoint in a URL - */ -function replaceEndpoint(url: string): string { - if (MINIO_PUBLIC_ENDPOINT === MINIO_ENDPOINT) { - return url - } - - try { - const internalUrl = new URL(MINIO_ENDPOINT) - const publicUrl = new URL(MINIO_PUBLIC_ENDPOINT) - return url.replace( - `${internalUrl.protocol}//${internalUrl.host}`, - `${publicUrl.protocol}//${publicUrl.host}` - ) - } catch { - return url - } -} - // ============================================================================= // Helper Functions // ============================================================================= /** - * Generate a pre-signed URL for file download or upload - * Uses MINIO_PUBLIC_ENDPOINT for browser-accessible URLs + * Generate a pre-signed URL for file download or upload. + * Uses the public MinIO client so the AWS V4 signature is computed against + * the public hostname — the browser's Host header will match the signature. */ export async function getPresignedUrl( bucket: string, @@ -87,20 +80,15 @@ export async function getPresignedUrl( expirySeconds: number = 900, // 15 minutes default options?: { downloadFileName?: string } ): Promise { - let url: string + const publicClient = getPublicMinioClient() if (method === 'GET') { - // If downloadFileName is set, include Content-Disposition: attachment header - // so the browser triggers a native download (works on iOS Safari, all browsers) const respHeaders = options?.downloadFileName ? { 'response-content-disposition': `attachment; filename="${options.downloadFileName}"` } : undefined - url = await minio.presignedGetObject(bucket, objectKey, expirySeconds, respHeaders) + return publicClient.presignedGetObject(bucket, objectKey, expirySeconds, respHeaders) } else { - url = await minio.presignedPutObject(bucket, objectKey, expirySeconds) + return publicClient.presignedPutObject(bucket, objectKey, expirySeconds) } - - // Replace internal endpoint with public endpoint for browser access - return replaceEndpoint(url) } /** diff --git a/src/server/routers/applicant.ts b/src/server/routers/applicant.ts index ccd8191..ba7d933 100644 --- a/src/server/routers/applicant.ts +++ b/src/server/routers/applicant.ts @@ -2,7 +2,7 @@ import crypto from 'crypto' import { z } from 'zod' import { TRPCError } from '@trpc/server' import { router, publicProcedure, protectedProcedure } from '../trpc' -import { getPresignedUrl, generateObjectKey } from '@/lib/minio' +import { getPresignedUrl, generateObjectKey, BUCKET_NAME } from '@/lib/minio' import { generateLogoKey, createStorageProvider, type StorageProviderType } from '@/lib/storage' import { getImageUploadUrl, confirmImageUpload, getImageUrl, deleteImage, type ImageUploadConfig } from '@/server/utils/image-upload' import { sendStyledNotificationEmail, sendTeamMemberInviteEmail } from '@/lib/email' @@ -12,8 +12,9 @@ import { checkRequirementsAndTransition, triggerInProgressOnActivity, transition import { EvaluationConfigSchema, MentoringConfigSchema } from '@/types/competition-configs' import type { Prisma } from '@prisma/client' -// Bucket for applicant submissions -export const SUBMISSIONS_BUCKET = 'mopc-submissions' +// All uploads use the single configured bucket (MINIO_BUCKET / mopc-files). +// Files are organized by path prefix: {ProjectName}/{RoundName}/... for submissions, +// avatars/{userId}/... for profile images, logos/{projectId}/... for project logos. const TEAM_INVITE_TOKEN_EXPIRY_MS = 30 * 24 * 60 * 60 * 1000 // 30 days function generateInviteToken(): string { @@ -346,11 +347,11 @@ export const applicantRouter = router({ const objectKey = generateObjectKey(project.title, input.fileName, roundName) - const url = await getPresignedUrl(SUBMISSIONS_BUCKET, objectKey, 'PUT', 3600) + const url = await getPresignedUrl(BUCKET_NAME, objectKey, 'PUT', 3600) return { url, - bucket: SUBMISSIONS_BUCKET, + bucket: BUCKET_NAME, objectKey, isLate, roundId: input.roundId || null, diff --git a/src/server/routers/dashboard.ts b/src/server/routers/dashboard.ts index 8196f1c..ee0dd08 100644 --- a/src/server/routers/dashboard.ts +++ b/src/server/routers/dashboard.ts @@ -890,11 +890,11 @@ export const dashboardRouter = router({ ) for (const tm of unactivated) { - // Generate invite token for each user + // Generate invite token and mark as INVITED const token = generateInviteToken() await ctx.prisma.user.update({ where: { id: tm.user.id }, - data: { inviteToken: token, inviteTokenExpiresAt: expiresAt }, + data: { inviteToken: token, inviteTokenExpiresAt: expiresAt, status: 'INVITED' }, }) const accountUrl = `/accept-invite?token=${token}` diff --git a/src/server/routers/partner.ts b/src/server/routers/partner.ts index 9d9aeb4..53640cb 100644 --- a/src/server/routers/partner.ts +++ b/src/server/routers/partner.ts @@ -1,11 +1,8 @@ import { z } from 'zod' import { router, protectedProcedure, adminProcedure } from '../trpc' -import { getPresignedUrl } from '@/lib/minio' +import { getPresignedUrl, BUCKET_NAME } from '@/lib/minio' import { logAudit } from '../utils/audit' -// Bucket for partner logos -export const PARTNER_BUCKET = 'mopc-partners' - export const partnerRouter = router({ /** * List all partners (admin view) @@ -270,13 +267,13 @@ export const partnerRouter = router({ .mutation(async ({ input }) => { const timestamp = Date.now() const sanitizedName = input.fileName.replace(/[^a-zA-Z0-9.-]/g, '_') - const objectKey = `logos/${timestamp}-${sanitizedName}` + const objectKey = `partners/${timestamp}-${sanitizedName}` - const url = await getPresignedUrl(PARTNER_BUCKET, objectKey, 'PUT', 3600) + const url = await getPresignedUrl(BUCKET_NAME, objectKey, 'PUT', 3600) return { url, - bucket: PARTNER_BUCKET, + bucket: BUCKET_NAME, objectKey, } }), diff --git a/src/server/routers/project.ts b/src/server/routers/project.ts index 0be6be2..2bf4ed0 100644 --- a/src/server/routers/project.ts +++ b/src/server/routers/project.ts @@ -1834,7 +1834,7 @@ export const projectRouter = router({ const token = generateInviteToken() await ctx.prisma.user.update({ where: { id: userId }, - data: { inviteToken: token, inviteTokenExpiresAt: new Date(Date.now() + expiryMs) }, + data: { inviteToken: token, inviteTokenExpiresAt: new Date(Date.now() + expiryMs), status: 'INVITED' }, }) tokenMap.set(userId, token) } @@ -2010,7 +2010,7 @@ export const projectRouter = router({ const token = generateInviteToken() await ctx.prisma.user.update({ where: { id: userId }, - data: { inviteToken: token, inviteTokenExpiresAt: new Date(Date.now() + expiryMs) }, + data: { inviteToken: token, inviteTokenExpiresAt: new Date(Date.now() + expiryMs), status: 'INVITED' }, }) tokenMap.set(userId, token) } @@ -2142,7 +2142,7 @@ export const projectRouter = router({ const token = generateInviteToken() await ctx.prisma.user.update({ where: { id: userId }, - data: { inviteToken: token, inviteTokenExpiresAt: new Date(Date.now() + expiryMs) }, + data: { inviteToken: token, inviteTokenExpiresAt: new Date(Date.now() + expiryMs), status: 'INVITED' }, }) tokenMap.set(userId, token) } diff --git a/src/server/routers/specialAward.ts b/src/server/routers/specialAward.ts index db51655..a98a918 100644 --- a/src/server/routers/specialAward.ts +++ b/src/server/routers/specialAward.ts @@ -1342,7 +1342,7 @@ export const specialAwardRouter = router({ tokenMap.set(user.id, token) await ctx.prisma.user.update({ where: { id: user.id }, - data: { inviteToken: token, inviteTokenExpiresAt: expiresAt }, + data: { inviteToken: token, inviteTokenExpiresAt: expiresAt, status: 'INVITED' }, }) } diff --git a/src/server/routers/user.ts b/src/server/routers/user.ts index c8357b5..5562910 100644 --- a/src/server/routers/user.ts +++ b/src/server/routers/user.ts @@ -879,6 +879,7 @@ export const userRouter = router({ data: { inviteToken: token, inviteTokenExpiresAt: new Date(Date.now() + expiryMs), + status: 'INVITED', }, }) diff --git a/src/server/services/round-finalization.ts b/src/server/services/round-finalization.ts index b090fe9..8eac7e5 100644 --- a/src/server/services/round-finalization.ts +++ b/src/server/services/round-finalization.ts @@ -715,6 +715,7 @@ export async function confirmFinalization( data: { inviteToken: token, inviteTokenExpiresAt: new Date(Date.now() + expiryMs), + status: 'INVITED', }, }) }