FileUpload: add sharex file support (#771)

* FileUpload: add sharex file support
Quick note, the settings seem to split pacal case with spaces for some reason,
so `ShareX` renders as `Share X`. This is stupid and im upset and I dont think
theres anything I can do about it

* FileUpload: use Headers object to construct HTTP headers
This commit is contained in:
Key 2026-02-15 14:31:24 +00:00 • committed by GitHub
parent fe007405cf
commit 14a0932c2e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 299 additions and 5 deletions

View file

@ -5,13 +5,15 @@
*/
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 { useForceUpdater } from "@utils/react";
import { OptionType } from "@utils/types";
import { Select, TextInput } from "@webpack/common";
import { React, Select, showToast, TextArea, TextInput, Toasts } from "@webpack/common";
import { ServiceType } from "./types";
import { parseShareXConfig } from "./utils/sharex";
const serviceOptions = [
{ label: "Zipline", value: ServiceType.ZIPLINE, default: true },
@ -20,7 +22,8 @@ const serviceOptions = [
{ label: "S3-Compatible", value: ServiceType.S3 },
{ label: "Catbox.moe", value: ServiceType.CATBOX },
...(IS_DISCORD_DESKTOP ? [{ label: "0x0.st", value: ServiceType.ZEROX0 }] : []),
{ label: "Litterbox", value: ServiceType.LITTERBOX }
{ label: "Litterbox", value: ServiceType.LITTERBOX },
{ label: "ShareX Custom Uploader", value: ServiceType.SHAREX }
];
const litterboxOptions = [
@ -134,6 +137,12 @@ export const settings = definePluginSettings({
default: "",
hidden: true
},
sharexConfig: {
type: OptionType.STRING,
description: "ShareX custom uploader JSON",
default: "",
hidden: true
},
stripQueryParams: {
type: OptionType.BOOLEAN,
description: "Strip query params from uploaded URLs",
@ -182,12 +191,49 @@ function SettingTextInput(props: {
export function SettingsComponent() {
const update = useForceUpdater();
const { store } = settings;
const sharexFileInputRef = React.useRef<HTMLInputElement>(null);
const isNest = store.serviceType === ServiceType.NEST;
const isEzHost = store.serviceType === ServiceType.EZHOST;
const isS3 = store.serviceType === ServiceType.S3;
const isZipline = store.serviceType === ServiceType.ZIPLINE;
const isCatbox = store.serviceType === ServiceType.CATBOX;
const isLitterbox = store.serviceType === ServiceType.LITTERBOX;
const isShareX = store.serviceType === ServiceType.SHAREX;
const validateShareXConfig = () => {
try {
parseShareXConfig(store.sharexConfig || "");
showToast("ShareX config is valid", Toasts.Type.SUCCESS);
} catch (error) {
const message = error instanceof Error ? error.message : "Invalid ShareX config";
showToast(message, Toasts.Type.FAILURE);
}
};
const triggerShareXFileUpload = () => {
sharexFileInputRef.current?.click();
};
const handleShareXFileUpload = (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (!file) return;
const reader = new FileReader();
reader.onload = (e: ProgressEvent<FileReader>) => {
try {
const content = String(e.target?.result || "");
const parsed = parseShareXConfig(content);
store.sharexConfig = JSON.stringify(parsed, null, 2);
update();
showToast("Imported ShareX config", Toasts.Type.SUCCESS);
} catch (error) {
const message = error instanceof Error ? error.message : "Failed to import ShareX config";
showToast(message, Toasts.Type.FAILURE);
}
};
reader.readAsText(file);
event.target.value = "";
};
return (
<>
@ -342,6 +388,35 @@ export function SettingsComponent() {
</SettingsSection>
)}
{isShareX && (
<>
<SettingsSection
name="ShareX Custom Uploader Config"
description="Paste your ShareX custom uploader JSON (.sxcu/.json). DestinationType must include FileUploader or ImageUploader."
>
<TextArea
value={store.sharexConfig}
rows={10}
placeholder='{"RequestMethod":"POST","RequestURL":"https://example.com/api/upload","Body":"MultipartFormData"}'
onChange={v => store.sharexConfig = v}
/>
</SettingsSection>
<SettingsSection name="ShareX Config Actions" description="Import from file or validate pasted config">
<div style={{ display: "flex", gap: 8, flexWrap: "wrap" }}>
<Button size="small" onClick={triggerShareXFileUpload}>Import .sxcu/.json</Button>
<Button size="small" onClick={validateShareXConfig}>Validate</Button>
</div>
<input
ref={sharexFileInputRef}
type="file"
accept=".sxcu,.json,application/json,text/plain"
style={{ display: "none" }}
onChange={handleShareXFileUpload}
/>
</SettingsSection>
</>
)}
<SettingsSection tag="label" name="Strip Query Parameters" description="Strip query parameters from the uploaded file URL" inlineSetting>
<Switch
checked={store.stripQueryParams}

View file

@ -11,7 +11,8 @@ export enum ServiceType {
S3 = "s3",
CATBOX = "catbox",
ZEROX0 = "0x0",
LITTERBOX = "litterbox"
LITTERBOX = "litterbox",
SHAREX = "sharex"
}
export const serviceLabels: Record<ServiceType, string> = {
@ -21,7 +22,8 @@ export const serviceLabels: Record<ServiceType, string> = {
[ServiceType.S3]: "S3-Compatible",
[ServiceType.CATBOX]: "Catbox",
[ServiceType.ZEROX0]: "0x0.st",
[ServiceType.LITTERBOX]: "Litterbox"
[ServiceType.LITTERBOX]: "Litterbox",
[ServiceType.SHAREX]: "ShareX Custom Uploader"
};
export interface UploadResponse {
@ -41,3 +43,17 @@ export interface NativeUploadResult {
url?: string;
error?: string;
}
export interface ShareXUploaderConfig {
Version?: string;
Name?: string;
DestinationType?: string;
RequestMethod?: string;
RequestURL?: string;
Headers?: Record<string, string | number | boolean>;
Body?: string;
FileFormName?: string;
Arguments?: Record<string, string | number | boolean>;
URL?: string;
ErrorMessage?: string;
}

View file

@ -0,0 +1,92 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2025 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import { ShareXUploaderConfig } from "../types";
const JSON_TEMPLATE_REGEX = /\$json:([^$]+)\$|\{json:([^}]+)\}/g;
const RESPONSE_TEMPLATE_REGEX = /\$response\$|\{response\}/g;
function parseDestinationTypes(destinationType?: string): string[] {
if (!destinationType) return [];
return destinationType
.split(",")
.map(type => type.trim())
.filter(Boolean);
}
function getNestedValue(data: unknown, path: string): unknown {
const normalizedPath = path.replace(/\[(\d+)\]/g, ".$1");
const segments = normalizedPath.split(".").filter(Boolean);
let current: unknown = data;
for (const segment of segments) {
if (!current || typeof current !== "object" || !(segment in current)) {
return undefined;
}
current = (current as Record<string, unknown>)[segment];
}
return current;
}
export function parseShareXConfig(configText: string): ShareXUploaderConfig {
let parsed: unknown;
const normalizedConfigText = configText.replace(/^\uFEFF/, "").trim();
try {
parsed = JSON.parse(normalizedConfigText);
} catch {
throw new Error("Invalid ShareX JSON");
}
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
throw new Error("ShareX config must be a JSON object");
}
const config = parsed as ShareXUploaderConfig;
if (!config.RequestURL || typeof config.RequestURL !== "string") {
throw new Error("ShareX RequestURL is required");
}
if (!isSupportedShareXDestination(config)) {
throw new Error("ShareX DestinationType must include FileUploader or ImageUploader");
}
return config;
}
export function isSupportedShareXDestination(config: ShareXUploaderConfig): boolean {
const destinationTypes = parseDestinationTypes(config.DestinationType);
if (destinationTypes.length === 0) {
return true;
}
return destinationTypes.includes("FileUploader") || destinationTypes.includes("ImageUploader");
}
export function resolveShareXTemplate(
template: string | undefined,
responseText: string,
responseJson: unknown
): string | undefined {
if (!template) return undefined;
let output = template.trim();
if (!output) return undefined;
output = output.replace(RESPONSE_TEMPLATE_REGEX, responseText);
output = output.replace(JSON_TEMPLATE_REGEX, (_, pathA: string, pathB: string) => {
const path = pathA || pathB;
const value = getNestedValue(responseJson, path);
return value == null ? "" : String(value);
});
return output;
}

View file

@ -5,7 +5,7 @@
*/
import { settings } from "@equicordplugins/fileUpload/settings";
import { ServiceType, UploadResponse } from "@equicordplugins/fileUpload/types";
import { ServiceType, ShareXUploaderConfig, UploadResponse } from "@equicordplugins/fileUpload/types";
import { copyToClipboard } from "@utils/clipboard";
import { PluginNative } from "@utils/types";
import { showToast, Toasts } from "@webpack/common";
@ -13,6 +13,7 @@ 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")>
@ -22,6 +23,106 @@ const CORS_PROXY = "https://cors.keiran0.workers.dev"; // im hosting this on clo
let isUploading = false;
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"}`);
}
let response: Response;
try {
response = await fetch(requestUrl, { method, headers, body });
} catch (error) {
if (Native) {
throw error;
}
const proxiedUrl = `${CORS_PROXY}?url=${encodeURIComponent(requestUrl)}`;
response = await fetch(proxiedUrl, { 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;
@ -141,6 +242,13 @@ export function isConfigured(): boolean {
return Boolean(Native);
case ServiceType.LITTERBOX:
return true;
case ServiceType.SHAREX:
try {
parseShareXConfigFromSettings();
return true;
} catch {
return false;
}
case ServiceType.ZIPLINE:
default:
return Boolean(serviceUrl && ziplineToken);
@ -348,6 +456,9 @@ export async function uploadFile(url: string): Promise<void> {
case ServiceType.LITTERBOX:
uploadedUrl = await uploadToLitterbox(typedBlob, filename);
break;
case ServiceType.SHAREX:
uploadedUrl = await uploadToShareX(typedBlob, filename);
break;
default:
throw new Error("Unknown service type");
}