fix: presigned URL signatures, bucket consolidation, login & invite status
All checks were successful
Build and Push Docker Image / build (push) Successful in 9m44s
All checks were successful
Build and Push Docker Image / build (push) Successful in 9m44s
- 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 <noreply@anthropic.com>
This commit is contained in:
@@ -58,14 +58,16 @@ export const { handlers, auth, signIn, signOut } = NextAuth({
|
|||||||
from: process.env.EMAIL_FROM || 'MOPC Platform <noreply@monaco-opc.com>',
|
from: process.env.EMAIL_FROM || 'MOPC Platform <noreply@monaco-opc.com>',
|
||||||
maxAge: parseInt(process.env.MAGIC_LINK_EXPIRY || '900'), // 15 minutes
|
maxAge: parseInt(process.env.MAGIC_LINK_EXPIRY || '900'), // 15 minutes
|
||||||
sendVerificationRequest: async ({ identifier: email, url }) => {
|
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({
|
const existingUser = await prisma.user.findUnique({
|
||||||
where: { email: email.toLowerCase().trim() },
|
where: { email: email.toLowerCase().trim() },
|
||||||
select: { status: true },
|
select: { status: true },
|
||||||
})
|
})
|
||||||
if (!existingUser || existingUser.status !== 'ACTIVE') {
|
if (!existingUser || existingUser.status === 'SUSPENDED') {
|
||||||
// Silently skip — don't reveal whether the email exists (prevents enumeration)
|
// 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
|
return
|
||||||
}
|
}
|
||||||
await sendMagicLinkEmail(email, url)
|
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
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import * as Minio from 'minio'
|
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 {
|
const globalForMinio = globalThis as unknown as {
|
||||||
minio: Minio.Client | undefined
|
minio: Minio.Client | undefined
|
||||||
|
minioPublic: Minio.Client | undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
// Internal endpoint for server-to-server communication
|
// 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
|
// If not set, falls back to internal endpoint
|
||||||
export const MINIO_PUBLIC_ENDPOINT = process.env.MINIO_PUBLIC_ENDPOINT || MINIO_ENDPOINT
|
export const MINIO_PUBLIC_ENDPOINT = process.env.MINIO_PUBLIC_ENDPOINT || MINIO_ENDPOINT
|
||||||
|
|
||||||
function createMinioClient(): Minio.Client {
|
function createClientFromUrl(endpoint: string): Minio.Client {
|
||||||
const url = new URL(MINIO_ENDPOINT)
|
const url = new URL(endpoint)
|
||||||
|
|
||||||
const accessKey = process.env.MINIO_ACCESS_KEY
|
const accessKey = process.env.MINIO_ACCESS_KEY
|
||||||
const secretKey = process.env.MINIO_SECRET_KEY
|
const secretKey = process.env.MINIO_SECRET_KEY
|
||||||
@@ -31,18 +32,29 @@ function createMinioClient(): Minio.Client {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the MinIO client instance (lazy-initialized).
|
* Get the internal MinIO client (for server-side operations: bucket ops, delete, etc.)
|
||||||
* The client is only created on first access, not at module import time.
|
|
||||||
* This prevents build-time errors when env vars are not available.
|
|
||||||
*/
|
*/
|
||||||
export function getMinioClient(): Minio.Client {
|
export function getMinioClient(): Minio.Client {
|
||||||
if (!globalForMinio.minio) {
|
if (!globalForMinio.minio) {
|
||||||
globalForMinio.minio = createMinioClient()
|
globalForMinio.minio = createClientFromUrl(MINIO_ENDPOINT)
|
||||||
}
|
}
|
||||||
return globalForMinio.minio
|
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, {
|
export const minio: Minio.Client = new Proxy({} as Minio.Client, {
|
||||||
get(_target, prop, receiver) {
|
get(_target, prop, receiver) {
|
||||||
return Reflect.get(getMinioClient(), 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
|
// Default bucket name
|
||||||
export const BUCKET_NAME = process.env.MINIO_BUCKET || 'mopc-files'
|
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
|
// Helper Functions
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Generate a pre-signed URL for file download or upload
|
* Generate a pre-signed URL for file download or upload.
|
||||||
* Uses MINIO_PUBLIC_ENDPOINT for browser-accessible URLs
|
* 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(
|
export async function getPresignedUrl(
|
||||||
bucket: string,
|
bucket: string,
|
||||||
@@ -87,20 +80,15 @@ export async function getPresignedUrl(
|
|||||||
expirySeconds: number = 900, // 15 minutes default
|
expirySeconds: number = 900, // 15 minutes default
|
||||||
options?: { downloadFileName?: string }
|
options?: { downloadFileName?: string }
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
let url: string
|
const publicClient = getPublicMinioClient()
|
||||||
if (method === 'GET') {
|
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
|
const respHeaders = options?.downloadFileName
|
||||||
? { 'response-content-disposition': `attachment; filename="${options.downloadFileName}"` }
|
? { 'response-content-disposition': `attachment; filename="${options.downloadFileName}"` }
|
||||||
: undefined
|
: undefined
|
||||||
url = await minio.presignedGetObject(bucket, objectKey, expirySeconds, respHeaders)
|
return publicClient.presignedGetObject(bucket, objectKey, expirySeconds, respHeaders)
|
||||||
} else {
|
} 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)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import crypto from 'crypto'
|
|||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
import { TRPCError } from '@trpc/server'
|
import { TRPCError } from '@trpc/server'
|
||||||
import { router, publicProcedure, protectedProcedure } from '../trpc'
|
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 { generateLogoKey, createStorageProvider, type StorageProviderType } from '@/lib/storage'
|
||||||
import { getImageUploadUrl, confirmImageUpload, getImageUrl, deleteImage, type ImageUploadConfig } from '@/server/utils/image-upload'
|
import { getImageUploadUrl, confirmImageUpload, getImageUrl, deleteImage, type ImageUploadConfig } from '@/server/utils/image-upload'
|
||||||
import { sendStyledNotificationEmail, sendTeamMemberInviteEmail } from '@/lib/email'
|
import { sendStyledNotificationEmail, sendTeamMemberInviteEmail } from '@/lib/email'
|
||||||
@@ -12,8 +12,9 @@ import { checkRequirementsAndTransition, triggerInProgressOnActivity, transition
|
|||||||
import { EvaluationConfigSchema, MentoringConfigSchema } from '@/types/competition-configs'
|
import { EvaluationConfigSchema, MentoringConfigSchema } from '@/types/competition-configs'
|
||||||
import type { Prisma } from '@prisma/client'
|
import type { Prisma } from '@prisma/client'
|
||||||
|
|
||||||
// Bucket for applicant submissions
|
// All uploads use the single configured bucket (MINIO_BUCKET / mopc-files).
|
||||||
export const SUBMISSIONS_BUCKET = 'mopc-submissions'
|
// 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
|
const TEAM_INVITE_TOKEN_EXPIRY_MS = 30 * 24 * 60 * 60 * 1000 // 30 days
|
||||||
|
|
||||||
function generateInviteToken(): string {
|
function generateInviteToken(): string {
|
||||||
@@ -346,11 +347,11 @@ export const applicantRouter = router({
|
|||||||
|
|
||||||
const objectKey = generateObjectKey(project.title, input.fileName, roundName)
|
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 {
|
return {
|
||||||
url,
|
url,
|
||||||
bucket: SUBMISSIONS_BUCKET,
|
bucket: BUCKET_NAME,
|
||||||
objectKey,
|
objectKey,
|
||||||
isLate,
|
isLate,
|
||||||
roundId: input.roundId || null,
|
roundId: input.roundId || null,
|
||||||
|
|||||||
@@ -890,11 +890,11 @@ export const dashboardRouter = router({
|
|||||||
)
|
)
|
||||||
|
|
||||||
for (const tm of unactivated) {
|
for (const tm of unactivated) {
|
||||||
// Generate invite token for each user
|
// Generate invite token and mark as INVITED
|
||||||
const token = generateInviteToken()
|
const token = generateInviteToken()
|
||||||
await ctx.prisma.user.update({
|
await ctx.prisma.user.update({
|
||||||
where: { id: tm.user.id },
|
where: { id: tm.user.id },
|
||||||
data: { inviteToken: token, inviteTokenExpiresAt: expiresAt },
|
data: { inviteToken: token, inviteTokenExpiresAt: expiresAt, status: 'INVITED' },
|
||||||
})
|
})
|
||||||
|
|
||||||
const accountUrl = `/accept-invite?token=${token}`
|
const accountUrl = `/accept-invite?token=${token}`
|
||||||
|
|||||||
@@ -1,11 +1,8 @@
|
|||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
import { router, protectedProcedure, adminProcedure } from '../trpc'
|
import { router, protectedProcedure, adminProcedure } from '../trpc'
|
||||||
import { getPresignedUrl } from '@/lib/minio'
|
import { getPresignedUrl, BUCKET_NAME } from '@/lib/minio'
|
||||||
import { logAudit } from '../utils/audit'
|
import { logAudit } from '../utils/audit'
|
||||||
|
|
||||||
// Bucket for partner logos
|
|
||||||
export const PARTNER_BUCKET = 'mopc-partners'
|
|
||||||
|
|
||||||
export const partnerRouter = router({
|
export const partnerRouter = router({
|
||||||
/**
|
/**
|
||||||
* List all partners (admin view)
|
* List all partners (admin view)
|
||||||
@@ -270,13 +267,13 @@ export const partnerRouter = router({
|
|||||||
.mutation(async ({ input }) => {
|
.mutation(async ({ input }) => {
|
||||||
const timestamp = Date.now()
|
const timestamp = Date.now()
|
||||||
const sanitizedName = input.fileName.replace(/[^a-zA-Z0-9.-]/g, '_')
|
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 {
|
return {
|
||||||
url,
|
url,
|
||||||
bucket: PARTNER_BUCKET,
|
bucket: BUCKET_NAME,
|
||||||
objectKey,
|
objectKey,
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -1834,7 +1834,7 @@ export const projectRouter = router({
|
|||||||
const token = generateInviteToken()
|
const token = generateInviteToken()
|
||||||
await ctx.prisma.user.update({
|
await ctx.prisma.user.update({
|
||||||
where: { id: userId },
|
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)
|
tokenMap.set(userId, token)
|
||||||
}
|
}
|
||||||
@@ -2010,7 +2010,7 @@ export const projectRouter = router({
|
|||||||
const token = generateInviteToken()
|
const token = generateInviteToken()
|
||||||
await ctx.prisma.user.update({
|
await ctx.prisma.user.update({
|
||||||
where: { id: userId },
|
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)
|
tokenMap.set(userId, token)
|
||||||
}
|
}
|
||||||
@@ -2142,7 +2142,7 @@ export const projectRouter = router({
|
|||||||
const token = generateInviteToken()
|
const token = generateInviteToken()
|
||||||
await ctx.prisma.user.update({
|
await ctx.prisma.user.update({
|
||||||
where: { id: userId },
|
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)
|
tokenMap.set(userId, token)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1342,7 +1342,7 @@ export const specialAwardRouter = router({
|
|||||||
tokenMap.set(user.id, token)
|
tokenMap.set(user.id, token)
|
||||||
await ctx.prisma.user.update({
|
await ctx.prisma.user.update({
|
||||||
where: { id: user.id },
|
where: { id: user.id },
|
||||||
data: { inviteToken: token, inviteTokenExpiresAt: expiresAt },
|
data: { inviteToken: token, inviteTokenExpiresAt: expiresAt, status: 'INVITED' },
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -879,6 +879,7 @@ export const userRouter = router({
|
|||||||
data: {
|
data: {
|
||||||
inviteToken: token,
|
inviteToken: token,
|
||||||
inviteTokenExpiresAt: new Date(Date.now() + expiryMs),
|
inviteTokenExpiresAt: new Date(Date.now() + expiryMs),
|
||||||
|
status: 'INVITED',
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -715,6 +715,7 @@ export async function confirmFinalization(
|
|||||||
data: {
|
data: {
|
||||||
inviteToken: token,
|
inviteToken: token,
|
||||||
inviteTokenExpiresAt: new Date(Date.now() + expiryMs),
|
inviteTokenExpiresAt: new Date(Date.now() + expiryMs),
|
||||||
|
status: 'INVITED',
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user