Fix iOS download via Content-Disposition header, fix COI gate null check
Some checks failed
Build and Push Docker Image / build (push) Has been cancelled

- Server: presigned GET URLs now include Content-Disposition: attachment header
  when forDownload=true, triggering native browser downloads on all platforms
- Download button uses window.location.href with attachment URL (works on iOS Safari)
- Bulk download uses hidden iframes instead of fetch+blob
- Fix COI gate: getCOIStatus returns null (not undefined) when undeclared,
  so `!== undefined` was always true — changed to `!= null`

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Matt
2026-02-18 14:56:09 +01:00
parent 8d28104d51
commit 9c19661400
4 changed files with 38 additions and 56 deletions

View File

@@ -398,7 +398,8 @@ export default function JuryEvaluatePage({ params: paramsPromise }: PageProps) {
const coiRequired = evalConfig?.coiRequired ?? true const coiRequired = evalConfig?.coiRequired ?? true
// Determine COI state: declared via server or just completed in this session // Determine COI state: declared via server or just completed in this session
const coiDeclared = coiCompleted || coiStatus !== undefined // coiStatus is null when no COI record exists, truthy when declared
const coiDeclared = coiCompleted || (coiStatus != null)
const coiConflict = coiHasConflict || (coiStatus?.hasConflict ?? false) const coiConflict = coiHasConflict || (coiStatus?.hasConflict ?? false)
// Check if round is active // Check if round is active

View File

@@ -490,28 +490,21 @@ function BulkDownloadButton({ projectId, fileIds }: { projectId: string; fileIds
try { try {
const result = await refetch() const result = await refetch()
if (result.data && Array.isArray(result.data)) { if (result.data && Array.isArray(result.data)) {
// Download each file via fetch+blob to handle cross-origin URLs // Each URL already has Content-Disposition: attachment from the server,
// so we can trigger native downloads by navigating to each URL.
// Use hidden iframes to download multiple files without navigating away.
for (let i = 0; i < result.data.length; i++) { for (let i = 0; i < result.data.length; i++) {
const item = result.data[i] as { downloadUrl: string; fileName?: string } const item = result.data[i] as { downloadUrl: string; fileName?: string }
if (item.downloadUrl) { if (item.downloadUrl) {
try { const iframe = document.createElement('iframe')
const response = await fetch(item.downloadUrl) iframe.style.display = 'none'
const blob = await response.blob() iframe.src = item.downloadUrl
const blobUrl = URL.createObjectURL(blob) document.body.appendChild(iframe)
const link = document.createElement('a') // Clean up iframe after download starts
link.href = blobUrl setTimeout(() => document.body.removeChild(iframe), 10_000)
link.download = item.fileName || `file-${i + 1}` // Small delay between downloads to avoid browser throttling
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
URL.revokeObjectURL(blobUrl)
} catch {
// Fall back to opening in new tab if fetch fails
window.open(item.downloadUrl, '_blank')
}
// Small delay between downloads
if (i < result.data.length - 1) { if (i < result.data.length - 1) {
await new Promise((resolve) => setTimeout(resolve, 500)) await new Promise((resolve) => setTimeout(resolve, 800))
} }
} }
} }
@@ -520,7 +513,7 @@ function BulkDownloadButton({ projectId, fileIds }: { projectId: string; fileIds
} catch { } catch {
toast.error('Failed to download files') toast.error('Failed to download files')
} finally { } finally {
setDownloading(false) setTimeout(() => setDownloading(false), 1000)
} }
} }
@@ -598,7 +591,7 @@ function FileDownloadButton({ file, className, label }: { file: ProjectFile; cla
const [downloading, setDownloading] = useState(false) const [downloading, setDownloading] = useState(false)
const { refetch } = trpc.file.getDownloadUrl.useQuery( const { refetch } = trpc.file.getDownloadUrl.useQuery(
{ bucket: file.bucket, objectKey: file.objectKey }, { bucket: file.bucket, objectKey: file.objectKey, forDownload: true, fileName: file.fileName },
{ enabled: false } { enabled: false }
) )
@@ -607,42 +600,18 @@ function FileDownloadButton({ file, className, label }: { file: ProjectFile; cla
try { try {
const result = await refetch() const result = await refetch()
if (result.data?.url) { if (result.data?.url) {
// Try fetch+blob first (works on desktop, some mobile browsers) // The presigned URL includes Content-Disposition: attachment header,
try { // so the browser will natively download the file (works on iOS Safari too).
const response = await fetch(result.data.url) // Use window.location.href for same-tab navigation which triggers download
if (!response.ok) throw new Error('fetch failed') // without popup blockers interfering.
const blob = await response.blob() window.location.href = result.data.url
const blobUrl = URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = blobUrl
link.download = file.fileName
// iOS Safari needs the link in the DOM
link.style.display = 'none'
document.body.appendChild(link)
link.click()
// Delay cleanup for iOS
setTimeout(() => {
document.body.removeChild(link)
URL.revokeObjectURL(blobUrl)
}, 100)
} catch {
// Fallback: open in new tab (iOS Safari often blocks blob downloads)
// Open synchronously via a link click to avoid popup blockers
const link = document.createElement('a')
link.href = result.data.url
link.target = '_blank'
link.rel = 'noopener noreferrer'
link.style.display = 'none'
document.body.appendChild(link)
link.click()
setTimeout(() => document.body.removeChild(link), 100)
}
} }
} catch (error) { } catch (error) {
console.error('Failed to download file:', error) console.error('Failed to download file:', error)
toast.error('Failed to download file') toast.error('Failed to download file')
} finally { } finally {
setDownloading(false) // Small delay before clearing state so the button doesn't flash
setTimeout(() => setDownloading(false), 1000)
} }
} }

View File

@@ -84,11 +84,17 @@ export async function getPresignedUrl(
bucket: string, bucket: string,
objectKey: string, objectKey: string,
method: 'GET' | 'PUT' = 'GET', method: 'GET' | 'PUT' = 'GET',
expirySeconds: number = 900 // 15 minutes default expirySeconds: number = 900, // 15 minutes default
options?: { downloadFileName?: string }
): Promise<string> { ): Promise<string> {
let url: string let url: string
if (method === 'GET') { if (method === 'GET') {
url = await minio.presignedGetObject(bucket, objectKey, expirySeconds) // 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)
} else { } else {
url = await minio.presignedPutObject(bucket, objectKey, expirySeconds) url = await minio.presignedPutObject(bucket, objectKey, expirySeconds)
} }

View File

@@ -15,6 +15,8 @@ export const fileRouter = router({
z.object({ z.object({
bucket: z.string(), bucket: z.string(),
objectKey: z.string(), objectKey: z.string(),
forDownload: z.boolean().optional(),
fileName: z.string().optional(),
}) })
) )
.query(async ({ ctx, input }) => { .query(async ({ ctx, input }) => {
@@ -109,7 +111,9 @@ export const fileRouter = router({
} }
} }
const url = await getPresignedUrl(input.bucket, input.objectKey, 'GET', 900) // 15 min const url = await getPresignedUrl(input.bucket, input.objectKey, 'GET', 900,
input.forDownload ? { downloadFileName: input.fileName || input.objectKey.split('/').pop() || 'download' } : undefined
) // 15 min
// Log file access // Log file access
await logAudit({ await logAudit({
@@ -699,7 +703,9 @@ export const fileRouter = router({
// Generate signed URLs for each file // Generate signed URLs for each file
const results = await Promise.all( const results = await Promise.all(
files.map(async (file) => { files.map(async (file) => {
const downloadUrl = await getPresignedUrl(file.bucket, file.objectKey, 'GET', 900) const downloadUrl = await getPresignedUrl(file.bucket, file.objectKey, 'GET', 900,
{ downloadFileName: file.fileName }
)
return { return {
fileId: file.id, fileId: file.id,
fileName: file.fileName, fileName: file.fileName,