import { parse as parseQueryString } from "query-string" ; import { ALL_DOWNLOADABLE_PROTOCOLS , AUTO_DOWNLOAD_TORRENT_FILE_PROTOCOLS , EMULE_PROTOCOL , MAGNET_PROTOCOL , startsWithAnyProtocol , } from "../../common/apis/protocols" ; import type { OmitStrict } from "../../common/types" ; interface MetadataFileType { mediaType : string ; extension : string ; } const METADATA_FILE_TYPES = [ { mediaType : "application/x-bittorrent" , extension : ".torrent" } , { mediaType : "application/x-nzb" , extension : ".nzb" } , ] ; const ARBITRARY_FILE_FETCH_SIZE_CUTOFF = 1024 * 1024 * 5 ; const FILENAME_PROPERTY_REGEX = /filename=("([^"]+)"|([^"][^ ]+))/ ; function makeAuthorizationHeader ( username , password ) { return username || password ? { Authorization : `Basic ${ btoa ( ( username || "" ) + ":" + ( password || "" ) ) }` } : { } ; } function guessDownloadFileName ( url , headers , metadataFileType ) { let maybeFilename ; const contentDisposition = headers . get ( "content-disposition" ) ; if ( contentDisposition != null && contentDisposition . indexOf ( "filename=" ) !== - 1 ) { const regexMatch = FILENAME_PROPERTY_REGEX . exec ( contentDisposition ) ; maybeFilename = ( regexMatch && ( regexMatch [ 2 ] || regexMatch [ 3 ] ) ) || undefined ; } else { maybeFilename = url . pathname . slice ( url . pathname . lastIndexOf ( "/" ) + 1 ) ; } if ( maybeFilename == null || maybeFilename . length === 0 ) { maybeFilename = "download" ; } return maybeFilename . endsWith ( metadataFileType . extension ) ? maybeFilename : maybeFilename + metadataFileType . extension ; } async function fetchWithTimeout ( url , init , timeout ) { const abortController = new AbortController ( ) ; const timeoutTimer = setTimeout ( ( ) => { abortController . abort ( ) ; } , timeout ) ; try { return await fetch ( url . toString ( ) , { ... init , credentials : "include" , signal : abortController . signal , } ) ; } finally { clearTimeout ( timeoutTimer ) ; } } async function getMetadataFileType ( url , username , password ) { const headResponse = await fetchWithTimeout ( url , { method : "HEAD" , headers : makeAuthorizationHeader ( username , password ) , } , 10000 ) ; if ( ! headResponse . ok ) { return undefined ; } const contentType = ( headResponse . headers . get ( "content-type" ) ?? "" ) . toLowerCase ( ) ; const metadataFileType = METADATA_FILE_TYPES . find ( ( fileType ) => contentType . includes ( fileType . mediaType ) || url . pathname . endsWith ( fileType . extension ) ) ; const rawContentLength = headResponse . headers . get ( "content-length" ) ; const contentLength = rawContentLength == null || isNaN ( + rawContentLength ) ? undefined : + rawContentLength ; return metadataFileType && ( contentLength == null || contentLength < ARBITRARY_FILE_FETCH_SIZE_CUTOFF ) ? metadataFileType : undefined ; } export const EMULE_FILENAME_REGEX = /\|file\|([^\|]+)\|/ ; export function guessFileNameFromUrl ( url ) { if ( startsWithAnyProtocol ( url , MAGNET_PROTOCOL ) ) { const dn = parseQueryString ( url ) . dn ; if ( dn ) { return typeof dn === "string" ? dn : dn [ 0 ] ; } else { return undefined ; } } else if ( startsWithAnyProtocol ( url , EMULE_PROTOCOL ) ) { return url . match ( EMULE_FILENAME_REGEX ) ?. [ 1 ] || undefined ; } else { return undefined ; } } export function sanitizeUrlForSynology ( url ) { return new URL ( url . toString ( ) . replace ( /,/g , "%2C" ) ) ; } export interface DirectDownloadUrl { type : "direct-download" ; url : URL ; } export interface MetadataFileUrl { type : "metadata-file" ; url : URL ; content : Blob ; filename : string ; } export interface MissingOrIllegalUrl { type : "missing-or-illegal" ; } export type ResolvedUrl = DirectDownloadUrl | MetadataFileUrl | MissingOrIllegalUrl ; export async function resolveUrl ( url , username , password ) { function bailAndAssumeDirectDownload ( error , debugDescription ) { let guessedReason ; if ( error instanceof DOMException && error . name === "AbortError" ) { guessedReason = "timeout" ; } else if ( /networkerror/i . test ( error ?. message ) ) { guessedReason = "network-error" ; } else { guessedReason = "unknown" ; } console . error ( debugDescription , `(guessed reason: ${ guessedReason })` , error ) ; return { type : "direct-download" , url : parsedUrl , } ; } let parsedUrl ; try { parsedUrl = new URL ( url ) ; } catch ( e ) { if ( e instanceof TypeError ) { return { type : "missing-or-illegal" , } ; } else { throw e ; } } if ( startsWithAnyProtocol ( url , AUTO_DOWNLOAD_TORRENT_FILE_PROTOCOLS ) ) { let metadataFileType ; try { metadataFileType = await getMetadataFileType ( parsedUrl , username , password ) ; } catch ( e ) { return bailAndAssumeDirectDownload ( e , "error while trying to fetch metadata file type for download url" ) ; } if ( metadataFileType != null ) { let response ; try { response = await fetchWithTimeout ( parsedUrl , { headers : makeAuthorizationHeader ( username , password ) } , 10000 ) ; } catch ( e ) { return bailAndAssumeDirectDownload ( e , "error while trying to fetch metadata file" ) ; } let bytes ; try { bytes = await response . arrayBuffer ( ) ; } catch ( e ) { return bailAndAssumeDirectDownload ( e , "error while trying to get bytes for metadata file" ) ; } return { type : "metadata-file" , url : parsedUrl , content : new Blob ( [ bytes ] , { type : metadataFileType . mediaType } ) , filename : guessDownloadFileName ( parsedUrl , response . headers , metadataFileType ) , } ; } else { return { type : "direct-download" , url : parsedUrl , } ; } } else if ( startsWithAnyProtocol ( parsedUrl . toString ( ) , ALL_DOWNLOADABLE_PROTOCOLS ) ) { return { type : "direct-download" , url : parsedUrl , } ; } else { return { type : "missing-or-illegal" , } ; } }