/* * Vencord, a Discord client mod * Copyright (c) 2024 Vendicated and contributors * SPDX-License-Identifier: GPL-3.0-or-later */ import { normalizeCorsProxyUrl, toProxiedUrl } from "@equicordplugins/fileUpload/constants"; import { settings } from "@equicordplugins/fileUpload/settings"; import { fallbackServiceOrder, serviceLabels, ServiceType, ShareXUploaderConfig, UploadResponse } from "@equicordplugins/fileUpload/types"; import { copyToClipboard } from "@utils/clipboard"; import { insertTextIntoChatInputBox } from "@utils/discord"; import { Logger } from "@utils/Logger"; import { PluginNative } from "@utils/types"; import { chooseFile } from "@utils/web"; import { showToast, Toasts } from "@webpack/common"; import { convertApngToGif } from "./apngToGif"; import { getExtensionFromBytes, getExtensionFromMime, getMimeFromExtension, getUrlExtension } from "./getMediaUrl"; import { isS3Configured, uploadToS3 } from "./s3"; import { parseShareXConfig, resolveShareXTemplate } from "./sharex"; const Native = IS_DISCORD_DESKTOP ? VencordNative.pluginHelpers.FileUpload as PluginNative : null; const logger = new Logger("FileUpload", "#7cb7ff"); function toProxyUrl(url: string): string { const corsProxyUrl = normalizeCorsProxyUrl((settings.store as { corsProxyUrl?: string; }).corsProxyUrl); if (Native || url.startsWith(`${corsProxyUrl}?url=`)) { return url; } return toProxiedUrl(url, corsProxyUrl); } let isUploading = false; type UploadPhase = "idle" | "preparing" | "uploading" | "retrying" | "success" | "failed" | "cancelled"; type EmbedProxyService = "cors" | "nfp"; export interface UploadProgressState { phase: UploadPhase; fileName: string; currentService: ServiceType | null; currentServiceLabel: string; attempt: number; totalAttempts: number; percent: number; transferredBytes: number; totalBytes: number; status: string; canCancel: boolean; } const defaultUploadState: UploadProgressState = { phase: "idle", fileName: "", currentService: null, currentServiceLabel: "", attempt: 0, totalAttempts: 0, percent: 0, transferredBytes: 0, totalBytes: 0, status: "", canCancel: false }; let uploadState: UploadProgressState = { ...defaultUploadState }; const uploadStateListeners = new Set<() => void>(); let activeAbortController: AbortController | null = null; let activeXhr: XMLHttpRequest | null = null; let cancelRequested = false; function isUploadCancelledError(error: unknown): boolean { if (cancelRequested) return true; if (!(error instanceof Error)) return false; const message = error.message.toLowerCase(); return message.includes("cancelled") || message.includes("canceled") || message.includes("aborted") || message.includes("aborterror"); } function getFallbackServices(): ServiceType[] { const configuredOrder = (settings.store as { fallbackOrder?: string; }).fallbackOrder || fallbackServiceOrder.join(","); const services = configuredOrder .split(/[\n,]/) .map(service => service.trim()) .filter((service): service is ServiceType => Object.values(ServiceType).includes(service as ServiceType)); return services.length === fallbackServiceOrder.length && new Set(services).size === fallbackServiceOrder.length ? services : fallbackServiceOrder; } function emitUploadState() { for (const listener of uploadStateListeners) { listener(); } } function setUploadState(patch: Partial) { uploadState = { ...uploadState, ...patch }; emitUploadState(); } function resetUploadState() { uploadState = { ...defaultUploadState }; emitUploadState(); } export function subscribeUploadState(listener: () => void): () => void { uploadStateListeners.add(listener); return () => uploadStateListeners.delete(listener); } export function getUploadState(): UploadProgressState { return uploadState; } export function cancelCurrentUpload() { if (!isUploading) { return; } cancelRequested = true; activeAbortController?.abort(); activeXhr?.abort(); setUploadState({ phase: "cancelled", status: "Upload cancelled.", canCancel: false, percent: 0 }); } function getUploadTimeoutMs(): number { const value = (settings.store as { uploadTimeoutMs?: number; }).uploadTimeoutMs; if (!Number.isFinite(value) || !value) { return 300000; } return Math.max(5000, value); } async function fetchWithTimeout(url: string, options: RequestInit): Promise { const controller = new AbortController(); activeAbortController = controller; const timeout = setTimeout(() => controller.abort(), getUploadTimeoutMs()); const requestUrl = toProxyUrl(url); try { return await fetch(requestUrl, { ...options, signal: controller.signal }); } catch (error) { if (cancelRequested || controller.signal.aborted) { throw new Error(cancelRequested ? "Upload cancelled by user" : "Upload timed out"); } throw error; } finally { clearTimeout(timeout); if (activeAbortController === controller) { activeAbortController = null; } } } function getHeaderEntries(headers?: HeadersInit): [string, string][] { if (!headers) return []; if (headers instanceof Headers) return Array.from(headers.entries()); if (Array.isArray(headers)) return headers.map(([key, value]) => [key, value]); return Object.entries(headers); } class XhrResponse { ok: boolean; headers: Headers; status: number; statusText: string; url: string; constructor(private xhr: XMLHttpRequest) { this.status = xhr.status; this.statusText = xhr.statusText; this.url = xhr.responseURL; this.ok = this.status >= 200 && this.status < 300; this.headers = new Headers(); const rawHeaders = xhr.getAllResponseHeaders(); for (const line of rawHeaders.trim().split(/[\r\n]+/)) { if (!line) continue; const separatorIndex = line.indexOf(":"); if (separatorIndex < 0) continue; this.headers.append(line.slice(0, separatorIndex).trim(), line.slice(separatorIndex + 1).trim()); } } async text(): Promise { return typeof this.xhr.response === "string" ? this.xhr.response : this.xhr.responseText; } async json(): Promise { return JSON.parse(await this.text()); } } function setXhrUploadProgress(event: ProgressEvent) { if (!event.lengthComputable || event.total <= 0) { setUploadState({ status: uploadState.currentServiceLabel ? `Uploading via ${uploadState.currentServiceLabel}...` : "Uploading..." }); return; } const percent = Math.round(Math.max(0, Math.min(100, event.loaded / event.total * 100))); setUploadState({ phase: "uploading", percent, transferredBytes: event.loaded, totalBytes: event.total, status: uploadState.currentServiceLabel ? `Uploading via ${uploadState.currentServiceLabel}...` : "Uploading..." }); } async function uploadRequestWithTimeout(url: string, options: RequestInit): Promise { const requestUrl = toProxyUrl(url); return new Promise((resolve, reject) => { const xhr = new XMLHttpRequest(); activeXhr = xhr; const timeout = setTimeout(() => xhr.abort(), getUploadTimeoutMs()); xhr.open(options.method || "GET", requestUrl); for (const [key, value] of getHeaderEntries(options.headers)) { xhr.setRequestHeader(key, value); } xhr.upload.onprogress = setXhrUploadProgress; xhr.onload = () => resolve(new XhrResponse(xhr)); xhr.onerror = () => reject(new Error("Upload failed")); xhr.onabort = () => reject(new Error(cancelRequested ? "Upload cancelled by user" : "Upload timed out")); xhr.onloadend = () => { clearTimeout(timeout); xhr.upload.onprogress = null; if (activeXhr === xhr) { activeXhr = null; } }; const { body } = options; xhr.send(body instanceof ReadableStream ? null : body as XMLHttpRequestBodyInit | null); }); } function resolveShareXRequestValue(value: string | number | boolean, filename: string): string { return String(value) .replace(/\$filename\$/g, filename) .replace(/\{filename\}/g, filename); } function parseShareXConfigFromSettings(): ShareXUploaderConfig { const configText = settings.store.sharexConfig || ""; if (!configText.trim()) { throw new Error("ShareX config is required"); } return parseShareXConfig(configText); } async function uploadToShareX(fileBlob: Blob, filename: string): Promise { const config = parseShareXConfigFromSettings(); const method = (config.RequestMethod || "POST").toUpperCase(); const requestUrl = config.RequestURL!.trim(); const bodyType = (config.Body || "MultipartFormData").toLowerCase(); const headers = new Headers(); for (const [key, value] of Object.entries(config.Headers || {})) { headers.set(key, resolveShareXRequestValue(value, filename)); } const buildArguments = () => { const args: Record = {}; for (const [key, value] of Object.entries(config.Arguments || {})) { args[key] = resolveShareXRequestValue(value, filename); } return args; }; let body: BodyInit; if (bodyType === "multipartformdata" || bodyType === "formdata") { headers.delete("content-type"); const formData = new FormData(); const fileField = config.FileFormName || "file"; formData.append(fileField, fileBlob, filename); const args = buildArguments(); for (const [key, value] of Object.entries(args)) { formData.append(key, value); } body = formData; } else if (bodyType === "binary") { body = fileBlob; } else if (bodyType === "json") { if (!headers.has("content-type")) { headers.set("content-type", "application/json"); } const payload = buildArguments(); body = JSON.stringify(payload); } else { throw new Error(`Unsupported ShareX Body type: ${config.Body || "unknown"}`); } const response = await uploadRequestWithTimeout(requestUrl, { method, headers, body }); const responseText = await response.text(); let responseJson: unknown = null; try { responseJson = responseText ? JSON.parse(responseText) : null; } catch { responseJson = null; } if (!response.ok) { const configuredError = resolveShareXTemplate(config.ErrorMessage, responseText, responseJson); throw new Error(configuredError || `Upload failed: ${response.status} ${response.statusText}`); } const configuredUrl = resolveShareXTemplate(config.URL, responseText, responseJson)?.trim(); const fallbackUrl = typeof responseJson === "object" && responseJson && "url" in responseJson ? String((responseJson as Record).url || "") : responseText.trim(); const resultUrl = configuredUrl || fallbackUrl; if (!resultUrl) { throw new Error("No URL returned from ShareX uploader"); } return resultUrl; } async function uploadToZipline(fileBlob: Blob, filename: string): Promise { const { serviceUrl, ziplineToken, folderId } = settings.store; if (!serviceUrl || !ziplineToken) { throw new Error("Service URL and auth token are required"); } const baseUrl = serviceUrl.replace(/\/+$/, ""); const formData = new FormData(); formData.append("file", fileBlob, filename); const headers: Record = { "Authorization": ziplineToken }; if (folderId) { headers["x-zipline-folder"] = folderId; } const response = await uploadRequestWithTimeout(`${baseUrl}/api/upload`, { method: "POST", headers, body: formData }); const responseContentType = response.headers.get("content-type") || ""; if (!response.ok) { const errorText = await response.text(); throw new Error(`Upload failed: ${response.status} ${errorText}`); } if (!responseContentType.includes("application/json")) { throw new Error("Server returned invalid response (not JSON)"); } const data = await response.json() as UploadResponse; if (data.files && data.files.length > 0 && data.files[0].url) { return data.files[0].url; } throw new Error("No URL returned from upload"); } async function uploadToNest(fileBlob: Blob, filename: string): Promise { const { nestToken } = settings.store; if (!nestToken) { throw new Error("Auth token is required"); } if (Native) { const arrayBuffer = await fileBlob.arrayBuffer(); const result = await Native.uploadToNest(arrayBuffer, filename, nestToken); if (!result.success) { throw new Error(result.error || "Upload failed"); } if (!result.url) { throw new Error("No URL returned from upload"); } return result.url; } const formData = new FormData(); formData.append("file", fileBlob, filename); const response = await uploadRequestWithTimeout("https://nest.rip/api/files/upload", { method: "POST", headers: { "Authorization": nestToken }, body: formData }); if (!response.ok) { const errorText = await response.text(); throw new Error(`Upload failed: ${response.status} ${errorText}`); } const data = await response.json() as { fileURL?: string; }; if (data.fileURL) { return data.fileURL; } throw new Error("No URL returned from upload"); } export function isConfigured(): boolean { const { serviceType, serviceUrl, ziplineToken, nestToken } = settings.store as { serviceType: ServiceType; serviceUrl?: string; ziplineToken?: string; nestToken?: string; }; switch (serviceType) { case ServiceType.NEST: return Boolean(nestToken); case ServiceType.EZHOST: return Boolean((settings.store as { ezHostKey?: string; }).ezHostKey); case ServiceType.S3: return isS3Configured(); case ServiceType.CATBOX: return true; case ServiceType.ZEROX0: return Boolean(Native); case ServiceType.LITTERBOX: case ServiceType.GOFILE: case ServiceType.TMPFILES: case ServiceType.BUZZHEAVIER: case ServiceType.TEMPSH: case ServiceType.FILEBIN: case ServiceType.PIXELDRAIN: return true; case ServiceType.PIXELVAULT: return Boolean((settings.store as { pixelVaultKey?: string; }).pixelVaultKey); case ServiceType.SHAREX: try { parseShareXConfigFromSettings(); return true; } catch { return false; } case ServiceType.ZIPLINE: default: return Boolean(serviceUrl && ziplineToken); } } async function uploadToEzHost(fileBlob: Blob, filename: string): Promise { const { ezHostKey } = (settings.store as { ezHostKey?: string; }); if (!ezHostKey) throw new Error("E-Z Host API key is required"); if (Native) { const arrayBuffer = await fileBlob.arrayBuffer(); const result = await Native.uploadToEzHost(arrayBuffer, filename, ezHostKey); if (!result.success) { throw new Error(result.error || "Upload failed"); } if (!result.url) { throw new Error("No URL returned from upload"); } return result.url; } const formData = new FormData(); formData.append("file", fileBlob, filename); const headers: Record = { key: ezHostKey }; const response = await uploadRequestWithTimeout("https://api.e-z.host/files", { method: "POST", headers, body: formData }); if (!response.ok) { const text = await response.text(); throw new Error(`Upload failed: ${response.status} ${text}`); } const data = await response.json() as { success?: boolean; error?: string; imageUrl?: string; rawUrl?: string; }; if (!data || !data.success) { throw new Error(data?.error || "Upload failed"); } const url = data.imageUrl || data.rawUrl; if (!url) throw new Error("No URL returned from upload"); return url; } async function uploadToCatbox(fileBlob: Blob, filename: string): Promise { const { catboxUserhash } = settings.store; if (Native) { const result = await Native.uploadToCatbox(await fileBlob.arrayBuffer(), filename, catboxUserhash || undefined); if (!result.success || !result.url) throw new Error(result.error || "No URL returned from upload"); return result.url; } const formData = new FormData(); formData.append("reqtype", "fileupload"); if (catboxUserhash) formData.append("userhash", catboxUserhash); formData.append("fileToUpload", fileBlob, filename); const response = await uploadRequestWithTimeout("https://catbox.moe/user/api.php", { method: "POST", body: formData }); if (!response.ok) throw new Error(`Upload failed: ${response.status} ${await response.text()}`); const text = (await response.text()).trim(); if (!text) throw new Error("No URL returned from upload"); return text; } async function uploadTo0x0(fileBlob: Blob, filename: string): Promise { if (!Native) { throw new Error("0x0.st uploads are only supported on the desktop client"); } const arrayBuffer = await fileBlob.arrayBuffer(); const result = await Native.uploadTo0x0(arrayBuffer, filename); if (!result.success) { throw new Error(result.error || "Upload failed"); } if (!result.url) { throw new Error("No URL returned from upload"); } return result.url; } async function uploadToLitterbox(fileBlob: Blob, filename: string): Promise { const expiry = settings.store.litterboxExpiry || "24h"; if (Native) { const result = await Native.uploadToLitterbox(await fileBlob.arrayBuffer(), filename, expiry); if (!result.success || !result.url) throw new Error(result.error || "No URL returned from upload"); return result.url; } const formData = new FormData(); formData.append("reqtype", "fileupload"); formData.append("time", expiry); formData.append("fileToUpload", fileBlob, filename); const response = await uploadRequestWithTimeout("https://litterbox.catbox.moe/resources/internals/api.php", { method: "POST", body: formData }); if (!response.ok) throw new Error(`Upload failed: ${response.status} ${await response.text()}`); const text = (await response.text()).trim(); if (!text) throw new Error("No URL returned from upload"); return text; } async function uploadToGofile(fileBlob: Blob, filename: string): Promise { const { gofileToken } = settings.store as { gofileToken?: string; }; if (Native) { const result = await Native.uploadToGofile(await fileBlob.arrayBuffer(), filename, gofileToken || undefined); if (!result.success || !result.url) throw new Error(result.error || "No URL returned from upload"); return result.url; } const formData = new FormData(); if (gofileToken?.trim()) { formData.append("token", gofileToken.trim()); } formData.append("file", fileBlob, filename); const uploadUrl = "https://upload.gofile.io/uploadfile"; const response = await uploadRequestWithTimeout(uploadUrl, { method: "POST", body: formData }); if (!response.ok) { throw new Error(`Upload failed: ${response.status} ${await response.text()}`); } const data = await response.json() as { status?: string; error?: string; data?: { downloadPage?: string; code?: string; }; }; if (data.status !== "ok") { throw new Error(data.error || "Upload failed"); } const url = data.data?.downloadPage || (data.data?.code ? `https://gofile.io/d/${data.data.code}` : ""); if (!url) throw new Error("No URL returned from upload"); return url; } async function uploadToTmpfiles(fileBlob: Blob, filename: string): Promise { if (Native) { const result = await Native.uploadToTmpfiles(await fileBlob.arrayBuffer(), filename); if (!result.success || !result.url) throw new Error(result.error || "No URL returned from upload"); return result.url; } const formData = new FormData(); formData.append("file", fileBlob, filename); const uploadUrl = "https://tmpfiles.org/api/v1/upload"; const response = await uploadRequestWithTimeout(uploadUrl, { method: "POST", body: formData }); if (!response.ok) { throw new Error(`Upload failed: ${response.status} ${await response.text()}`); } const data = await response.json() as { status?: string; data?: { url?: string; }; }; const url = data.data?.url; if (!url || data.status !== "success") { throw new Error("No URL returned from upload"); } return url.includes("tmpfiles.org/") && !url.includes("/dl/") ? url.replace(/tmpfiles\.org\/(\d+)/, "tmpfiles.org/dl/$1") : url; } async function uploadToBuzzheavier(fileBlob: Blob, filename: string): Promise { if (Native) { const result = await Native.uploadToBuzzheavier(await fileBlob.arrayBuffer(), filename); if (!result.success || !result.url) throw new Error(result.error || "No URL returned from upload"); return result.url; } const uploadUrl = `https://w.buzzheavier.com/${encodeURIComponent(filename)}`; const response = await uploadRequestWithTimeout(uploadUrl, { method: "PUT", body: fileBlob }); const text = await response.text(); if (!response.ok) { throw new Error(`Upload failed: ${response.status} ${text}`); } try { const data = JSON.parse(text) as { code?: number; data?: { id?: string; }; }; const id = data.data?.id; if (data.code === 201 && id) { return `https://buzzheavier.com/${id}`; } } catch { } const fallback = text.trim(); if (!fallback) throw new Error("No URL returned from upload"); return fallback; } async function uploadToTempSh(fileBlob: Blob, filename: string): Promise { if (Native) { const result = await Native.uploadToTempSh(await fileBlob.arrayBuffer(), filename); if (!result.success || !result.url) throw new Error(result.error || "No URL returned from upload"); return result.url; } const formData = new FormData(); formData.append("file", fileBlob, filename); const uploadUrl = "https://temp.sh/upload"; const response = await uploadRequestWithTimeout(uploadUrl, { method: "POST", body: formData }); if (!response.ok) { throw new Error(`Upload failed: ${response.status} ${await response.text()}`); } const text = (await response.text()).trim(); if (!text) throw new Error("No URL returned from upload"); return text; } function makeRandomHex(length = 12): string { const bytes = new Uint8Array(length); crypto.getRandomValues(bytes); return Array.from(bytes, b => b.toString(16).padStart(2, "0")).join(""); } async function uploadToFilebin(fileBlob: Blob, filename: string): Promise { if (Native) { const result = await Native.uploadToFilebin(await fileBlob.arrayBuffer(), filename); if (!result.success || !result.url) throw new Error(result.error || "No URL returned from upload"); return result.url; } const binId = makeRandomHex(6); const uploadUrl = `https://filebin.net/${binId}/${encodeURIComponent(filename)}`; const formData = new FormData(); formData.append("file", fileBlob, filename); const response = await uploadRequestWithTimeout(uploadUrl, { method: "POST", body: formData }); if (!response.ok) { throw new Error(`Upload failed: ${response.status} ${await response.text()}`); } return `https://filebin.net/${binId}/${encodeURIComponent(filename)}`; } async function uploadToPixelVault(fileBlob: Blob, filename: string): Promise { const { pixelVaultKey } = settings.store as { pixelVaultKey?: string; }; if (!pixelVaultKey?.trim()) { throw new Error("PixelVault upload key is required"); } if (Native) { const result = await Native.uploadToPixelVault(await fileBlob.arrayBuffer(), filename, pixelVaultKey.trim()); if (!result.success) { throw new Error(result.error || "Upload failed"); } if (!result.url) { throw new Error("No URL returned from upload"); } return result.url; } const formData = new FormData(); formData.append("file", fileBlob, filename); const response = await uploadRequestWithTimeout("https://pixelvault.co/", { method: "POST", headers: { Authorization: pixelVaultKey.trim() }, body: formData }); const text = await response.text(); let data: { resource?: string; url?: string; } | null = null; try { data = text ? JSON.parse(text) : null; } catch { data = null; } if (!response.ok) { throw new Error(`Upload failed: ${response.status} ${text}`); } const url = data?.resource || data?.url || text.trim(); if (!url) { throw new Error("No URL returned from upload"); } return url; } async function uploadToPixelDrain(fileBlob: Blob, filename: string): Promise { const { pixelDrainKey } = settings.store as { pixelDrainKey?: string; }; if (Native) { const result = await Native.uploadToPixelDrain(await fileBlob.arrayBuffer(), filename, pixelDrainKey?.trim() || undefined); if (!result.success || !result.url) throw new Error(result.error || "No URL returned from upload"); return result.url; } const headers: Record = {}; if (pixelDrainKey?.trim()) { headers.Authorization = `Basic ${btoa(`:${pixelDrainKey.trim()}`)}`; } const response = await uploadRequestWithTimeout(`https://pixeldrain.com/api/file/${encodeURIComponent(filename)}`, { method: "PUT", headers, body: fileBlob }); const text = await response.text(); let data: { id?: string; success?: boolean; message?: string; } | null = null; try { data = text ? JSON.parse(text) : null; } catch { data = null; } if (!response.ok) { throw new Error(data?.message || `Upload failed: ${response.status} ${text}`); } if (!data?.id) { throw new Error(data?.message || "No URL returned from upload"); } return `https://pixeldrain.com/u/${data.id}`; } async function uploadToService(serviceType: ServiceType, fileBlob: Blob, filename: string): Promise { switch (serviceType) { case ServiceType.ZIPLINE: return uploadToZipline(fileBlob, filename); case ServiceType.NEST: return uploadToNest(fileBlob, filename); case ServiceType.EZHOST: return uploadToEzHost(fileBlob, filename); case ServiceType.S3: return uploadToS3(fileBlob, filename, Native, uploadRequestWithTimeout); case ServiceType.CATBOX: return uploadToCatbox(fileBlob, filename); case ServiceType.ZEROX0: return uploadTo0x0(fileBlob, filename); case ServiceType.LITTERBOX: return uploadToLitterbox(fileBlob, filename); case ServiceType.SHAREX: return uploadToShareX(fileBlob, filename); case ServiceType.GOFILE: return uploadToGofile(fileBlob, filename); case ServiceType.TMPFILES: return uploadToTmpfiles(fileBlob, filename); case ServiceType.BUZZHEAVIER: return uploadToBuzzheavier(fileBlob, filename); case ServiceType.TEMPSH: return uploadToTempSh(fileBlob, filename); case ServiceType.FILEBIN: return uploadToFilebin(fileBlob, filename); case ServiceType.PIXELVAULT: return uploadToPixelVault(fileBlob, filename); case ServiceType.PIXELDRAIN: return uploadToPixelDrain(fileBlob, filename); default: throw new Error("Unknown service type"); } } const EXE_BLOCKED_SERVICES = new Set([ ServiceType.CATBOX, ServiceType.LITTERBOX, ServiceType.ZEROX0 ]); function getEmbedProxyService(): EmbedProxyService { const service = settings.store.embedProxyService; return service === "nfp" ? service : "cors"; } function shouldProxyEmbedUrl(url: string): boolean { const ext = getUrlExtension(url)?.toLowerCase(); if (!ext) { return false; } return getMimeFromExtension(ext).startsWith("video/"); } function applyEmbedProxy(url: string): string { if (!settings.store.embedProxyEnabled) { return url; } const service = getEmbedProxyService(); if (!shouldProxyEmbedUrl(url)) { return url; } switch (service) { case "cors": return toProxyUrl(url); case "nfp": return `https://discord.nfp.is/?v=${encodeURIComponent(url)}`; default: return toProxyUrl(url); } } function isExeFileName(fileName: string): boolean { return fileName.toLowerCase().endsWith(".exe"); } function canServiceHandleFile(service: ServiceType, fileName: string): boolean { if (service === ServiceType.ZEROX0 && !Native) { return false; } if (isExeFileName(fileName) && EXE_BLOCKED_SERVICES.has(service)) { return false; } return true; } function normalizePrimaryService(primary: ServiceType, fileName: string): ServiceType { if (canServiceHandleFile(primary, fileName)) { return primary; } if (isExeFileName(fileName)) { return ServiceType.GOFILE; } if (!Native && primary === ServiceType.ZEROX0) { return ServiceType.CATBOX; } return primary; } function buildUploadOrder(primary: ServiceType, fileName: string): ServiceType[] { const disableFallbacks = Boolean((settings.store as { disableFallbacks?: boolean; }).disableFallbacks); const effectivePrimary = normalizePrimaryService(primary, fileName); const order: ServiceType[] = [effectivePrimary]; if (disableFallbacks) { return order; } for (const fallback of getFallbackServices()) { if (fallback !== effectivePrimary && canServiceHandleFile(fallback, fileName)) { order.push(fallback); } } return order; } function finalizeUploadedUrl(url: string): string { if (!settings.store.stripQueryParams) { return url; } try { const parsed = new URL(url); parsed.search = ""; return parsed.href; } catch { return url; } } function getFilenameExtension(filename: string): string | undefined { const dotIndex = filename.lastIndexOf("."); if (dotIndex < 1 || dotIndex === filename.length - 1) return undefined; const ext = filename.slice(dotIndex + 1).toLowerCase(); return ext.length <= 10 ? ext : undefined; } async function notifyUploadSuccess(finalUrl: string): Promise { if (settings.store.autoCopy) { if (!finalUrl || !finalUrl.trim()) { showToast("Upload successful, but no URL was available to copy", Toasts.Type.MESSAGE); return; } try { await copyToClipboard(finalUrl); showToast("Upload successful, URL copied to clipboard", Toasts.Type.SUCCESS); } catch (error) { logger.warn("Upload succeeded but clipboard copy failed", error); showToast("Upload successful, but failed to copy URL", Toasts.Type.MESSAGE); } } else { showToast("Upload successful", Toasts.Type.SUCCESS); } const autoSend = Boolean((settings.store as { autoSend?: boolean; }).autoSend); const autoFormat = Boolean((settings.store as { autoFormat?: boolean; }).autoFormat); if (autoSend) { insertTextIntoChatInputBox(autoFormat ? `<${finalUrl}>` : finalUrl); } } async function uploadWithFallbacks(fileBlob: Blob, filename: string, primary: ServiceType): Promise { const uploadOrder = buildUploadOrder(primary, filename); const attempted: string[] = []; let lastError = "Unknown error"; setUploadState({ phase: "uploading", fileName: filename, totalAttempts: uploadOrder.length, attempt: 1, percent: 5, transferredBytes: 0, totalBytes: fileBlob.size, status: `Starting upload via ${serviceLabels[uploadOrder[0]]}...`, currentService: uploadOrder[0], currentServiceLabel: serviceLabels[uploadOrder[0]], canCancel: true }); for (const service of uploadOrder) { if (cancelRequested) throw new Error("Upload cancelled by user"); const attempt = attempted.length + 1; setUploadState({ phase: attempt === 1 ? "uploading" : "retrying", attempt, currentService: service, currentServiceLabel: serviceLabels[service], transferredBytes: 0, totalBytes: fileBlob.size, percent: 0, status: attempt === 1 ? `Uploading via ${serviceLabels[service]}...` : `Retrying with ${serviceLabels[service]} (${attempt}/${uploadOrder.length})...` }); try { const uploadedUrl = await uploadToService(service, fileBlob, filename); if (attempted.length) { showToast(`Upload succeeded with ${serviceLabels[service]} after fallback`, Toasts.Type.SUCCESS); } setUploadState({ phase: "success", percent: 100, attempt, currentService: service, currentServiceLabel: serviceLabels[service], status: `Uploaded successfully via ${serviceLabels[service]}.`, canCancel: false }); return uploadedUrl; } catch (error) { if (isUploadCancelledError(error)) { throw error; } const message = error instanceof Error ? error.message : "Unknown error"; attempted.push(serviceLabels[service]); lastError = message; logger.warn(`Upload failed for ${serviceLabels[service]}: ${message}`); setUploadState({ phase: "retrying", attempt, currentService: service, currentServiceLabel: serviceLabels[service], status: `${serviceLabels[service]} failed: ${message}`, percent: Math.min(95, 10 + Math.round((attempt / uploadOrder.length) * 80)) }); } } setUploadState({ phase: "failed", status: `All upload services failed. Last error: ${lastError}`, canCancel: false, percent: 0 }); throw new Error(`All upload services failed. Last error: ${lastError}. Tried: ${attempted.join(", ")}`); } async function normalizeUploadBlob(blob: Blob, sourceUrl?: string): Promise<{ blob: Blob; filename: string; }> { let sourceFileName = ""; if (blob instanceof File && blob.name) { sourceFileName = blob.name; } else if (sourceUrl && URL.canParse(sourceUrl)) { const segment = new URL(sourceUrl).pathname.split("/").pop(); if (segment) sourceFileName = decodeURIComponent(segment); } const extGuessFromSource = sourceUrl ? getUrlExtension(sourceUrl) : undefined; let ext = await getExtensionFromBytes(blob) || getExtensionFromMime(blob.type) || getFilenameExtension(sourceFileName) || extGuessFromSource || "bin"; if (ext === "apng" && settings.store.apngToGif) { const gifBlob = await convertApngToGif(blob); if (gifBlob) { blob = gifBlob; ext = "gif"; } else { showToast("APNG to GIF conversion failed, uploading as APNG", Toasts.Type.FAILURE); } } const mimeType = getMimeFromExtension(ext); const { preserveOriginalFilename } = settings.store; let filename = `upload.${ext}`; if (preserveOriginalFilename && sourceFileName) { const dotIndex = sourceFileName.lastIndexOf("."); filename = `${sourceFileName.slice(0, dotIndex < 0 ? undefined : dotIndex)}.${ext}`; } return { blob: new Blob([blob], { type: mimeType }), filename }; } async function uploadPreparedBlob(blob: Blob, sourceUrl?: string): Promise { const primary = settings.store.serviceType as ServiceType; const { blob: normalizedBlob, filename } = await normalizeUploadBlob(blob, sourceUrl); setUploadState({ fileName: filename, status: "File ready, starting upload...", percent: 4 }); const uploadedUrl = await uploadWithFallbacks(normalizedBlob, filename, primary); const finalUrl = applyEmbedProxy(finalizeUploadedUrl(uploadedUrl)); await notifyUploadSuccess(finalUrl); } export async function uploadFile(url: string): Promise { if (isUploading) { showToast("Upload already in progress", Toasts.Type.MESSAGE); return; } if (!isConfigured()) { showToast("Please configure FileUpload settings first", Toasts.Type.FAILURE); return; } isUploading = true; cancelRequested = false; setUploadState({ phase: "preparing", fileName: "", currentService: null, currentServiceLabel: "", attempt: 0, totalAttempts: 0, percent: 1, status: "Preparing upload...", canCancel: true }); try { let fetchUrl = url; if (url.includes("/stickers/") && url.includes("passthrough=false")) { fetchUrl = url.replace("passthrough=false", "passthrough=true"); } let blob: Blob; let contentType = ""; if (Native) { const res = await Native.fetchFile(fetchUrl); if (res.success && res.data) { contentType = res.contentType || ""; blob = new Blob([res.data], { type: contentType }); } else { const response = await fetch(fetchUrl); if (!response.ok) { throw new Error(`Failed to fetch file: ${response.status}`); } contentType = response.headers.get("content-type") || ""; blob = await response.blob(); } } else { const response = await fetchWithTimeout(fetchUrl, { method: "GET" }); if (!response.ok) { throw new Error(`Failed to fetch file: ${response.status}`); } contentType = response.headers.get("content-type") || ""; blob = await response.blob(); } if (contentType && !blob.type) { blob = new Blob([blob], { type: contentType }); } await uploadPreparedBlob(blob, url); } catch (error) { const message = error instanceof Error ? error.message : "Unknown error"; if (isUploadCancelledError(error)) { showToast("Upload cancelled", Toasts.Type.MESSAGE); setUploadState({ phase: "cancelled", status: "Upload cancelled.", canCancel: false, percent: 0 }); } else { showToast(`Upload failed: ${message}`, Toasts.Type.FAILURE); logger.error("Upload error", error); setUploadState({ phase: "failed", status: `Upload failed: ${message}`, canCancel: false, percent: 0 }); } } finally { isUploading = false; activeAbortController = null; activeXhr = null; setTimeout(() => resetUploadState(), 1800); } } export async function uploadPickedFile(): Promise { const file = await chooseFile("*/*"); if (!file) return; await uploadProvidedFiles([file]); } export async function uploadProvidedFiles(files: readonly File[]): Promise { if (isUploading) { showToast("Upload already in progress", Toasts.Type.MESSAGE); return; } if (!isConfigured()) { showToast("Please configure FileUpload settings first", Toasts.Type.FAILURE); return; } if (!files.length) return; const uploadFiles = files.filter(file => Boolean(file)); if (!uploadFiles.length) return; isUploading = true; cancelRequested = false; try { for (let i = 0; i < uploadFiles.length; i++) { const file = uploadFiles[i]; const current = i + 1; const suffix = uploadFiles.length > 1 ? ` (${current}/${uploadFiles.length})` : ""; setUploadState({ phase: "preparing", fileName: file.name, currentService: null, currentServiceLabel: "", attempt: 0, totalAttempts: 0, percent: 2, status: `Preparing ${file.name}${suffix}...`, canCancel: true }); await uploadPreparedBlob(file); } } catch (error) { const message = error instanceof Error ? error.message : "Unknown error"; if (isUploadCancelledError(error)) { showToast("Upload cancelled", Toasts.Type.MESSAGE); setUploadState({ phase: "cancelled", status: "Upload cancelled.", canCancel: false, percent: 0 }); } else { showToast(`Upload failed: ${message}`, Toasts.Type.FAILURE); logger.error("Manual upload error", error); setUploadState({ phase: "failed", status: `Upload failed: ${message}`, canCancel: false, percent: 0 }); } } finally { isUploading = false; activeAbortController = null; activeXhr = null; setTimeout(() => resetUploadState(), 1800); } }