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:
@@ -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<string> {
|
||||
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)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user