From 0cb8a34ec678a9b25697500bb3fd8d2f8865672e Mon Sep 17 00:00:00 2001 From: Key Date: Thu, 30 Apr 2026 16:38:47 +0100 Subject: [PATCH] FileUpload: add pixelvault host and fallback uploader order configuration (#1032) * FileUpload: add pixelvault host - also add optional configuration of fallback order * FileUpload: improve UI of fallback order rows --- src/equicordplugins/fileUpload/native.ts | 42 ++++++++ src/equicordplugins/fileUpload/settings.tsx | 98 ++++++++++++++++++- src/equicordplugins/fileUpload/styles.css | 37 ++++++- src/equicordplugins/fileUpload/types.ts | 23 ++++- .../fileUpload/utils/upload.ts | 90 ++++++++++++++--- 5 files changed, 270 insertions(+), 20 deletions(-) diff --git a/src/equicordplugins/fileUpload/native.ts b/src/equicordplugins/fileUpload/native.ts index e20121dc5..1cd40c374 100644 --- a/src/equicordplugins/fileUpload/native.ts +++ b/src/equicordplugins/fileUpload/native.ts @@ -376,6 +376,48 @@ export async function uploadToFilebin( } } +export async function uploadToPixelVault( + _: IpcMainInvokeEvent, + fileBuffer: ArrayBuffer, + filename: string, + uploadKey: string +): Promise { + try { + const formData = new FormData(); + formData.append("file", new Blob([fileBuffer]), filename); + + const response = await fetch("https://pixelvault.co/", { + method: "POST", + headers: { + Authorization: uploadKey + }, + 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) { + return { success: false, error: `Upload failed: ${response.status} ${text}` }; + } + + const url = data?.resource || data?.url || text.trim(); + if (!url) { + return { success: false, error: "No URL returned from upload" }; + } + + return { success: true, url }; + } catch (e) { + return { success: false, error: e instanceof Error ? e.message : "Unknown error" }; + } +} + export async function fetchFile( _: IpcMainInvokeEvent, diff --git a/src/equicordplugins/fileUpload/settings.tsx b/src/equicordplugins/fileUpload/settings.tsx index 9f2951b16..234b8d85c 100644 --- a/src/equicordplugins/fileUpload/settings.tsx +++ b/src/equicordplugins/fileUpload/settings.tsx @@ -8,14 +8,18 @@ import { definePluginSettings } from "@api/Settings"; import { Button } from "@components/Button"; import { SettingsSection } from "@components/settings/tabs/plugins/components/Common"; import { Switch } from "@components/Switch"; +import { classNameFactory } from "@utils/css"; import { useForceUpdater } from "@utils/react"; import { OptionType } from "@utils/types"; import { React, Select, showToast, TextArea, TextInput, Toasts } from "@webpack/common"; import { CORS_PROXY } from "./constants"; -import { ServiceType } from "./types"; +import { fallbackServiceOrder, serviceLabels, ServiceType } from "./types"; import { parseShareXConfig } from "./utils/sharex"; +const defaultFallbackOrder = fallbackServiceOrder.join(","); +const cl = classNameFactory("vc-file-upload-settings-"); + const serviceOptions = [ { label: "Zipline", value: ServiceType.ZIPLINE, default: true }, { label: "E-Z Host", value: ServiceType.EZHOST }, @@ -29,6 +33,7 @@ const serviceOptions = [ { label: "buzzheavier.com", value: ServiceType.BUZZHEAVIER }, { label: "temp.sh", value: ServiceType.TEMPSH }, { label: "filebin.net", value: ServiceType.FILEBIN }, + { label: "PixelVault", value: ServiceType.PIXELVAULT }, { label: "ShareX Custom Uploader", value: ServiceType.SHAREX } ]; @@ -190,6 +195,18 @@ export const settings = definePluginSettings({ default: "", hidden: true }, + fallbackOrder: { + type: OptionType.STRING, + description: "Fallback uploader order", + default: defaultFallbackOrder, + hidden: true + }, + pixelVaultKey: { + type: OptionType.STRING, + description: "PixelVault upload key", + default: "", + hidden: true + }, uploadTimeoutMs: { type: OptionType.NUMBER, description: "Upload timeout in milliseconds", @@ -271,6 +288,72 @@ function SettingTextInput(props: { ); } +function FallbackOrderSettings() { + const update = useForceUpdater(); + const { store } = settings; + const [dragIndex, setDragIndex] = React.useState(null); + const [order, setOrder] = React.useState(() => { + const configured = (store.fallbackOrder || defaultFallbackOrder) + .split(/[\n,]/) + .map(entry => entry.trim()) + .filter((entry): entry is ServiceType => Object.values(ServiceType).includes(entry as ServiceType)); + + return configured.length === fallbackServiceOrder.length && new Set(configured).size === fallbackServiceOrder.length + ? configured + : fallbackServiceOrder; + }); + + const commitOrder = (nextOrder: ServiceType[]) => { + setOrder(nextOrder); + store.fallbackOrder = nextOrder.join(","); + update(); + }; + + return ( + +
+ {order.map((service, index) => ( +
{ + setDragIndex(index); + event.dataTransfer.effectAllowed = "move"; + event.dataTransfer.setData("text/plain", String(index)); + }} + onDragOver={event => event.preventDefault()} + onDrop={event => { + event.preventDefault(); + const sourceIndex = dragIndex ?? Number(event.dataTransfer.getData("text/plain")); + if (!Number.isInteger(sourceIndex) || sourceIndex === index || sourceIndex < 0 || sourceIndex >= order.length) { + setDragIndex(null); + return; + } + + const nextOrder = [...order]; + const [moved] = nextOrder.splice(sourceIndex, 1); + nextOrder.splice(index, 0, moved); + setDragIndex(null); + commitOrder(nextOrder); + }} + onDragEnd={() => setDragIndex(null)} + data-dragging={dragIndex === index} + > + {serviceLabels[service]} + Drag +
+ ))} +
+
+ +
+
+ ); +} + export function SettingsComponent() { const update = useForceUpdater(); const { store } = settings; @@ -282,6 +365,7 @@ export function SettingsComponent() { const isCatbox = store.serviceType === ServiceType.CATBOX; const isLitterbox = store.serviceType === ServiceType.LITTERBOX; const isGofile = store.serviceType === ServiceType.GOFILE; + const isPixelVault = store.serviceType === ServiceType.PIXELVAULT; const isShareX = store.serviceType === ServiceType.SHAREX; const validateShareXConfig = () => { @@ -482,6 +566,16 @@ export function SettingsComponent() { /> )} + {isPixelVault && ( + store.pixelVaultKey = v} + placeholder="Your PixelVault upload key" + /> + )} + {isShareX && ( <> + + ); } diff --git a/src/equicordplugins/fileUpload/styles.css b/src/equicordplugins/fileUpload/styles.css index 67bcf3893..609929b15 100644 --- a/src/equicordplugins/fileUpload/styles.css +++ b/src/equicordplugins/fileUpload/styles.css @@ -70,4 +70,39 @@ .vc-file-upload-progress-wrap[data-phase="success"] .vc-file-upload-progress-fill { background: var(--status-positive); -} \ No newline at end of file +} + +.vc-file-upload-settings-fallback-order-list { + display: grid; + gap: 6px; +} + +.vc-file-upload-settings-fallback-order-item { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 8px 10px; + border-radius: 8px; + border: 1px solid var(--border-subtle); + background: var(--background-secondary); + color: var(--text-default); + cursor: grab; +} + +.vc-file-upload-settings-fallback-order-item[data-dragging="true"] { + background: var(--background-modifier-hover); +} + +.vc-file-upload-settings-fallback-order-label { + color: var(--text-default); +} + +.vc-file-upload-settings-fallback-order-handle { + color: var(--text-muted); + font-size: 12px; +} + +.vc-file-upload-settings-fallback-order-actions { + margin-top: 8px; +} diff --git a/src/equicordplugins/fileUpload/types.ts b/src/equicordplugins/fileUpload/types.ts index 2f7c52b57..26bd99d00 100644 --- a/src/equicordplugins/fileUpload/types.ts +++ b/src/equicordplugins/fileUpload/types.ts @@ -17,7 +17,8 @@ export enum ServiceType { TMPFILES = "tmpfiles", BUZZHEAVIER = "buzzheavier", TEMPSH = "tempsh", - FILEBIN = "filebin" + FILEBIN = "filebin", + PIXELVAULT = "pixelvault" } export const serviceLabels: Record = { @@ -33,9 +34,27 @@ export const serviceLabels: Record = { [ServiceType.TMPFILES]: "tmpfiles.org", [ServiceType.BUZZHEAVIER]: "buzzheavier.com", [ServiceType.TEMPSH]: "temp.sh", - [ServiceType.FILEBIN]: "filebin.net" + [ServiceType.FILEBIN]: "filebin.net", + [ServiceType.PIXELVAULT]: "PixelVault" }; +export const fallbackServiceOrder: ServiceType[] = [ + ServiceType.ZIPLINE, + ServiceType.EZHOST, + ServiceType.NEST, + ServiceType.S3, + ServiceType.CATBOX, + ServiceType.ZEROX0, + ServiceType.LITTERBOX, + ServiceType.GOFILE, + ServiceType.TMPFILES, + ServiceType.BUZZHEAVIER, + ServiceType.TEMPSH, + ServiceType.FILEBIN, + ServiceType.PIXELVAULT, + ServiceType.SHAREX +]; + export interface UploadResponse { files: { id: string; diff --git a/src/equicordplugins/fileUpload/utils/upload.ts b/src/equicordplugins/fileUpload/utils/upload.ts index 48ba36891..ee749ba8c 100644 --- a/src/equicordplugins/fileUpload/utils/upload.ts +++ b/src/equicordplugins/fileUpload/utils/upload.ts @@ -6,7 +6,7 @@ import { normalizeCorsProxyUrl, toProxiedUrl } from "@equicordplugins/fileUpload/constants"; import { settings } from "@equicordplugins/fileUpload/settings"; -import { serviceLabels, ServiceType, ShareXUploaderConfig, UploadResponse } from "@equicordplugins/fileUpload/types"; +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"; @@ -77,6 +77,18 @@ function isUploadCancelledError(error: unknown): boolean { 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(); @@ -363,6 +375,8 @@ export function isConfigured(): boolean { case ServiceType.TEMPSH: case ServiceType.FILEBIN: return true; + case ServiceType.PIXELVAULT: + return Boolean((settings.store as { pixelVaultKey?: string; }).pixelVaultKey); case ServiceType.SHAREX: try { parseShareXConfigFromSettings(); @@ -647,6 +661,59 @@ async function uploadToFilebin(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 fetchWithTimeout("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 uploadToService(serviceType: ServiceType, fileBlob: Blob, filename: string): Promise { switch (serviceType) { case ServiceType.ZIPLINE: @@ -675,22 +742,13 @@ async function uploadToService(serviceType: ServiceType, fileBlob: Blob, filenam return uploadToTempSh(fileBlob, filename); case ServiceType.FILEBIN: return uploadToFilebin(fileBlob, filename); + case ServiceType.PIXELVAULT: + return uploadToPixelVault(fileBlob, filename); default: throw new Error("Unknown service type"); } } -const FALLBACK_SERVICES: ServiceType[] = [ - ServiceType.CATBOX, - ServiceType.LITTERBOX, - ServiceType.ZEROX0, - ServiceType.TMPFILES, - ServiceType.GOFILE, - ServiceType.BUZZHEAVIER, - ServiceType.TEMPSH, - ServiceType.FILEBIN -]; - const EXE_BLOCKED_SERVICES = new Set([ ServiceType.CATBOX, ServiceType.LITTERBOX, @@ -768,12 +826,12 @@ function buildUploadOrder(primary: ServiceType, fileName: string): ServiceType[] const disableFallbacks = Boolean((settings.store as { disableFallbacks?: boolean; }).disableFallbacks); const effectivePrimary = normalizePrimaryService(primary, fileName); - if (disableFallbacks || effectivePrimary === ServiceType.SHAREX || effectivePrimary === ServiceType.S3 || effectivePrimary === ServiceType.ZIPLINE || effectivePrimary === ServiceType.NEST || effectivePrimary === ServiceType.EZHOST) { - return [effectivePrimary]; + const order: ServiceType[] = [effectivePrimary]; + if (disableFallbacks) { + return order; } - const order: ServiceType[] = [effectivePrimary]; - for (const fallback of FALLBACK_SERVICES) { + for (const fallback of getFallbackServices()) { if (fallback !== effectivePrimary && canServiceHandleFile(fallback, fileName)) { order.push(fallback); }