You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
234 lines
7.8 KiB
234 lines
7.8 KiB
/** |
|
* Download utility for repository downloads |
|
* Handles downloading repository archives with proper error handling and logging |
|
*/ |
|
|
|
import { get } from 'svelte/store'; |
|
import { userStore } from '$lib/stores/user-store.js'; |
|
import logger from '$lib/services/logger.js'; |
|
|
|
interface DownloadOptions { |
|
npub: string; |
|
repo: string; |
|
ref?: string; |
|
filename?: string; |
|
} |
|
|
|
let isDownloading = false; |
|
|
|
/** |
|
* Builds API headers with user pubkey for authenticated requests |
|
*/ |
|
function buildApiHeaders(): Record<string, string> { |
|
const headers: Record<string, string> = {}; |
|
const currentUser = get(userStore); |
|
const currentUserPubkeyHex = currentUser?.userPubkeyHex; |
|
if (currentUserPubkeyHex) { |
|
headers['X-User-Pubkey'] = currentUserPubkeyHex; |
|
logger.debug({ pubkey: currentUserPubkeyHex.substring(0, 16) + '...' }, '[Download] Sending X-User-Pubkey header'); |
|
} else { |
|
logger.debug('[Download] No user pubkey available, sending request without X-User-Pubkey header'); |
|
} |
|
return headers; |
|
} |
|
|
|
/** |
|
* Downloads a repository archive (ZIP or TAR.GZ) |
|
* @param options Download options including npub, repo, ref, and filename |
|
* @returns Promise that resolves when download is initiated |
|
*/ |
|
export async function downloadRepository(options: DownloadOptions): Promise<void> { |
|
const { npub, repo, ref, filename } = options; |
|
|
|
if (typeof window === 'undefined') { |
|
logger.warn('[Download] Attempted download in SSR context'); |
|
return; |
|
} |
|
|
|
// Prevent multiple simultaneous downloads |
|
if (isDownloading) { |
|
logger.debug('[Download] Download already in progress, skipping...'); |
|
return; |
|
} |
|
isDownloading = true; |
|
|
|
// Prevent page navigation during download |
|
const preventReloadHandler = (e: BeforeUnloadEvent) => { |
|
if (!isDownloading) { |
|
return; |
|
} |
|
e.preventDefault(); |
|
e.returnValue = ''; |
|
return ''; |
|
}; |
|
|
|
window.addEventListener('beforeunload', preventReloadHandler); |
|
|
|
try { |
|
// Build download URL |
|
const params = new URLSearchParams(); |
|
if (ref) { |
|
params.set('ref', ref); |
|
} |
|
params.set('format', 'zip'); |
|
const downloadUrl = `/api/repos/${npub}/${repo}/download?${params.toString()}`; |
|
|
|
logger.info({ url: downloadUrl, ref }, '[Download] Starting download'); |
|
|
|
// Fetch with proper headers |
|
const response = await fetch(downloadUrl, { |
|
method: 'GET', |
|
credentials: 'same-origin', |
|
headers: buildApiHeaders() |
|
}); |
|
|
|
logger.debug({ status: response.status, statusText: response.statusText }, '[Download] Response received'); |
|
|
|
if (!response.ok) { |
|
// Try to get error message from response |
|
let errorMessage = `Download failed: ${response.status} ${response.statusText}`; |
|
try { |
|
const errorData = await response.json(); |
|
if (errorData.message) { |
|
errorMessage = errorData.message; |
|
} else if (errorData.error) { |
|
errorMessage = errorData.error; |
|
} |
|
} catch { |
|
// If response is not JSON, use status text |
|
try { |
|
const text = await response.text(); |
|
if (text) { |
|
errorMessage = text.substring(0, 200); // Limit length |
|
} |
|
} catch { |
|
// Ignore text parsing errors |
|
} |
|
} |
|
logger.error({ error: errorMessage, status: response.status }, '[Download] Download failed'); |
|
throw new Error(errorMessage); |
|
} |
|
|
|
// Check content type |
|
const contentType = response.headers.get('content-type'); |
|
if (!contentType || (!contentType.includes('zip') && !contentType.includes('octet-stream'))) { |
|
logger.warn({ contentType }, '[Download] Unexpected content type'); |
|
} |
|
|
|
logger.debug('[Download] Converting to blob...'); |
|
const blob = await response.blob(); |
|
logger.debug({ size: blob.size }, '[Download] Blob created'); |
|
|
|
if (blob.size === 0) { |
|
throw new Error('Downloaded file is empty'); |
|
} |
|
|
|
// Use File System Access API if available (most reliable, no navigation) |
|
const downloadFileName = filename || `${repo}${ref ? `-${ref}` : ''}.zip`; |
|
|
|
if ('showSaveFilePicker' in window) { |
|
try { |
|
// @ts-ignore - File System Access API |
|
const fileHandle = await window.showSaveFilePicker({ |
|
suggestedName: downloadFileName, |
|
types: [{ |
|
description: 'ZIP files', |
|
accept: { 'application/zip': ['.zip'] } |
|
}] |
|
}); |
|
|
|
const writable = await fileHandle.createWritable(); |
|
await writable.write(blob); |
|
await writable.close(); |
|
|
|
logger.info('[Download] File saved using File System Access API'); |
|
return; // Success, exit early - no navigation possible |
|
} catch (saveErr: any) { |
|
// User cancelled or API not fully supported |
|
if (saveErr.name === 'AbortError') { |
|
logger.debug('[Download] User cancelled file save'); |
|
return; |
|
} |
|
logger.debug({ error: saveErr }, '[Download] File System Access API failed, using fallback'); |
|
} |
|
} |
|
|
|
// Use direct link method (more reliable, works with CSP) |
|
const url = window.URL.createObjectURL(blob); |
|
logger.debug('[Download] Created blob URL, using direct link method'); |
|
|
|
// Create a temporary link element and trigger download |
|
const link = document.createElement('a'); |
|
link.href = url; |
|
link.download = downloadFileName; |
|
link.style.display = 'none'; |
|
link.setAttribute('download', downloadFileName); // Ensure download attribute is set |
|
|
|
// Append to body temporarily |
|
document.body.appendChild(link); |
|
|
|
// Use requestAnimationFrame to ensure DOM is ready |
|
requestAnimationFrame(() => { |
|
try { |
|
// Trigger click |
|
link.click(); |
|
logger.debug('[Download] Download triggered via direct link'); |
|
|
|
// Clean up after a short delay |
|
setTimeout(() => { |
|
try { |
|
if (link.parentNode) { |
|
document.body.removeChild(link); |
|
} |
|
// Revoke blob URL after a delay to ensure download started |
|
setTimeout(() => { |
|
window.URL.revokeObjectURL(url); |
|
logger.debug('[Download] Cleaned up link and blob URL'); |
|
}, 1000); |
|
} catch (cleanupErr) { |
|
logger.error({ error: cleanupErr }, '[Download] Cleanup error'); |
|
// Still try to revoke the URL |
|
try { |
|
window.URL.revokeObjectURL(url); |
|
} catch (revokeErr) { |
|
logger.error({ error: revokeErr }, '[Download] Failed to revoke blob URL'); |
|
} |
|
} |
|
}, 100); |
|
} catch (clickErr) { |
|
logger.error({ error: clickErr }, '[Download] Error triggering download'); |
|
// Clean up on error |
|
try { |
|
if (link.parentNode) { |
|
document.body.removeChild(link); |
|
} |
|
window.URL.revokeObjectURL(url); |
|
} catch (cleanupErr) { |
|
logger.error({ error: cleanupErr }, '[Download] Cleanup error after click failure'); |
|
} |
|
throw new Error('Failed to trigger download'); |
|
} |
|
}); |
|
|
|
logger.info('[Download] Download initiated successfully'); |
|
} catch (err) { |
|
logger.error({ error: err, npub, repo, ref }, '[Download] Download error'); |
|
const errorMessage = err instanceof Error ? err.message : String(err); |
|
// Show user-friendly error message |
|
alert(`Download failed: ${errorMessage}`); |
|
// Don't re-throw - handle error gracefully to prevent page navigation issues |
|
} finally { |
|
// Remove beforeunload listener |
|
try { |
|
window.removeEventListener('beforeunload', preventReloadHandler); |
|
} catch (removeErr) { |
|
logger.warn({ error: removeErr }, '[Download] Error removing beforeunload listener'); |
|
} |
|
|
|
// Reset download flag after a delay |
|
setTimeout(() => { |
|
isDownloading = false; |
|
}, 3000); // Longer delay to ensure download completed |
|
} |
|
|
|
}
|
|
|