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
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:
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user