Equicord/src/equicordplugins/fileUpload/utils/upload.ts
Key a0c059e4ad
FileUpload: UI stuff (#1043)
* FileUpload: use XMLHttpRequest instead of fetch

* FileUpload: improve the file host picker settings UI

* FileUpload: track the upload progress instead of fallback count

* fix lint

* fix moreStickers

* Update stickers.ts

---------

Co-authored-by: thororen <78185467+thororen1234@users.noreply.github.com>
2026-05-03 16:35:00 -04:00

1314 lines
42 KiB
TypeScript

/*
* 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<typeof import("../native")>
: 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<UploadProgressState>) {
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<Response> {
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<string> {
return typeof this.xhr.response === "string"
? this.xhr.response
: this.xhr.responseText;
}
async json(): Promise<unknown> {
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<XhrResponse> {
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<string> {
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<string, string> = {};
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<string, unknown>).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<string> {
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<string, string> = {
"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<string> {
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<string> {
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<string, string> = { 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<string> {
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<string> {
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<string> {
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<string> {
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<string> {
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<string> {
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<string> {
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<string> {
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<string> {
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<string> {
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<string, string> = {};
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<string> {
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>([
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<void> {
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<string> {
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<void> {
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<void> {
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<void> {
const file = await chooseFile("*/*");
if (!file) return;
await uploadProvidedFiles([file]);
}
export async function uploadProvidedFiles(files: readonly File[]): Promise<void> {
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);
}
}