Equicord/src/equicordplugins/fileUpload/settings.tsx
Key 3d1ea790a5
FileUpload: Add WebDAV support and QOL features (#1162)
* FileUpload: Add WebDAV support and QOL features
- Now supports WebDAV providers such as OwnCloud and NextCloud
- Added a context menu option to upload specific attachments
  rather than having to upload all of them at once.
- File type filtering

* greptile fixes
2026-06-10 10:15:05 -04:00

996 lines
40 KiB
TypeScript

/*
* Vencord, a Discord client mod
* Copyright (c) 2024 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
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 { 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 },
{ label: "Nest", value: ServiceType.NEST },
{ label: "Encrypting.host", value: ServiceType.ENCRYPTINGHOST },
{ 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: "GoFile", value: ServiceType.GOFILE },
{ label: "tmpfiles.org", value: ServiceType.TMPFILES },
{ label: "buzzheavier.com", value: ServiceType.BUZZHEAVIER },
{ label: "temp.sh", value: ServiceType.TEMPSH },
{ label: "filebin.net", value: ServiceType.FILEBIN },
{ label: "PixelVault", value: ServiceType.PIXELVAULT },
{ label: "PixelDrain", value: ServiceType.PIXELDRAIN },
{ label: "ShareX Custom Uploader", value: ServiceType.SHAREX },
{ label: "WebDAV (Nextcloud/Owncloud)", value: ServiceType.WEBDAV }
];
const litterboxOptions = [
{ label: "1 hour", value: "1h" },
{ label: "12 hours", value: "12h" },
{ label: "24 hours", value: "24h", default: true },
{ label: "72 hours", value: "72h" }
];
const embedProxyOptions = [
{ label: "CORS Proxy", value: "cors", default: true },
{ label: "discord.nfp.is", value: "nfp" }
];
const encryptingHostUrlStyleOptions = [
{ label: "Query", value: "query", default: true },
{ label: "Param", value: "param" },
{ label: "Fake Link", value: "fakelink" },
{ label: "Embed", value: "embed" }
];
export const settings = definePluginSettings({
serviceType: {
type: OptionType.SELECT,
description: "Selected uploader service",
options: serviceOptions,
hidden: true
},
serviceUrl: {
type: OptionType.STRING,
description: "Zipline service URL",
default: "",
hidden: true
},
ziplineToken: {
type: OptionType.STRING,
description: "Zipline auth token",
default: "",
hidden: true
},
folderId: {
type: OptionType.STRING,
description: "Optional Zipline folder ID",
default: "",
hidden: true
},
ezHostKey: {
type: OptionType.STRING,
description: "E-Z Host API key",
default: "",
hidden: true
},
nestToken: {
type: OptionType.STRING,
description: "Nest API token",
default: "",
hidden: true
},
encryptingHostKey: {
type: OptionType.STRING,
description: "Encrypting.host API key",
default: "",
hidden: true
},
encryptingHostUrlStyle: {
type: OptionType.SELECT,
description: "Encrypting.host URL style",
options: encryptingHostUrlStyleOptions,
default: "query",
hidden: true
},
encryptingHostDomains: {
type: OptionType.STRING,
description: "Encrypting.host domains JSON list",
default: "[\"offensive\"]",
hidden: true
},
encryptingHostTitle: {
type: OptionType.STRING,
description: "Optional Encrypting.host embed title",
default: "",
hidden: true
},
encryptingHostColor: {
type: OptionType.STRING,
description: "Optional Encrypting.host embed color",
default: "",
hidden: true
},
encryptingHostFakelink: {
type: OptionType.STRING,
description: "Optional Encrypting.host fake link",
default: "",
hidden: true
},
s3Endpoint: {
type: OptionType.STRING,
description: "S3-compatible endpoint URL",
default: "",
hidden: true
},
s3Bucket: {
type: OptionType.STRING,
description: "S3 bucket name",
default: "",
hidden: true
},
s3Region: {
type: OptionType.STRING,
description: "S3 region (use auto for R2)",
default: "auto",
hidden: true
},
s3AccessKeyId: {
type: OptionType.STRING,
description: "S3 access key ID",
default: "",
hidden: true
},
s3SecretAccessKey: {
type: OptionType.STRING,
description: "S3 secret access key",
default: "",
hidden: true
},
s3SessionToken: {
type: OptionType.STRING,
description: "Optional S3 session token",
default: "",
hidden: true
},
s3PublicUrl: {
type: OptionType.STRING,
description: "Optional public base URL",
default: "",
hidden: true
},
s3Prefix: {
type: OptionType.STRING,
description: "Optional S3 object key prefix",
default: "",
hidden: true
},
s3ForcePathStyle: {
type: OptionType.BOOLEAN,
description: "Use path-style S3 URLs",
default: true,
hidden: true
},
litterboxExpiry: {
type: OptionType.SELECT,
description: "Litterbox retention window",
options: litterboxOptions,
default: "24h",
hidden: true
},
catboxUserhash: {
type: OptionType.STRING,
description: "Catbox userhash for account binding",
default: "",
hidden: true
},
sharexConfig: {
type: OptionType.STRING,
description: "ShareX custom uploader JSON",
default: "",
hidden: true
},
disableFallbacks: {
type: OptionType.BOOLEAN,
description: "Disable fallback upload services",
default: false,
hidden: true
},
autoSend: {
type: OptionType.BOOLEAN,
description: "Insert uploaded URL in chat input",
default: false,
hidden: true
},
autoFormat: {
type: OptionType.BOOLEAN,
description: "Wrap inserted URL in angle brackets",
default: false,
hidden: true
},
bypassDiscordUpload: {
type: OptionType.BOOLEAN,
description: "Bypass Discord uploads and use FileUpload instead.",
default: true,
hidden: true
},
bypassDiscordUploadOnlyOverLimit: {
type: OptionType.BOOLEAN,
description: "Only use FileUpload if the file(s) exceed the file size limit.",
default: true,
hidden: true
},
gofileToken: {
type: OptionType.STRING,
description: "Optional GoFile API token",
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
},
pixelDrainKey: {
type: OptionType.STRING,
description: "Optional PixelDrain API key",
default: "",
hidden: true
},
uploadTimeoutMs: {
type: OptionType.NUMBER,
description: "Upload timeout in milliseconds",
default: 300000,
hidden: true
},
stripQueryParams: {
type: OptionType.BOOLEAN,
description: "Strip query params from uploaded URLs",
default: false,
hidden: true
},
embedProxyEnabled: {
type: OptionType.BOOLEAN,
description: "Proxy uploaded video links through an embed helper service.",
default: false,
hidden: true
},
embedProxyService: {
type: OptionType.SELECT,
description: "Embed helper service to wrap uploaded video links.",
options: embedProxyOptions,
default: "cors",
hidden: true
},
corsProxyUrl: {
type: OptionType.STRING,
description: "CORS proxy URL used for browser uploads",
default: CORS_PROXY,
hidden: true
},
apngToGif: {
type: OptionType.BOOLEAN,
description: "Convert APNG uploads to GIF",
default: false,
hidden: true
},
preserveOriginalFilename: {
type: OptionType.BOOLEAN,
description: "Preserve the original filename when uploading.",
default: true,
hidden: true
},
autoCopy: {
type: OptionType.BOOLEAN,
description: "Auto copy upload URL",
default: true,
hidden: true
},
autoUploadPastedFiles: {
type: OptionType.BOOLEAN,
description: "Automatically upload files from clipboard to image host when pasting in chat input.",
default: false
},
webdavUrl: {
type: OptionType.STRING,
description: "WebDAV server URL",
default: "",
hidden: true
},
webdavUsername: {
type: OptionType.STRING,
description: "WebDAV username",
default: "",
hidden: true
},
webdavPassword: {
type: OptionType.STRING,
description: "WebDAV password or app token",
default: "",
hidden: true
},
webdavDirectory: {
type: OptionType.STRING,
description: "Optional subdirectory on the WebDAV server",
default: "",
hidden: true
},
uploadAllowedFileTypes: {
type: OptionType.STRING,
description: "Comma-separated list of allowed file extensions (e.g. png,jpg,gif,mp4). Leave empty to allow all files.",
default: "",
hidden: true
},
settingsComponent: {
type: OptionType.COMPONENT,
description: "Settings",
component: SettingsComponent
}
});
function SettingTextInput(props: {
description: string;
name: string;
onChange: (value: string) => void;
placeholder: string;
value: string;
}) {
const { description, name, onChange, placeholder, value } = props;
return (
<SettingsSection name={name} description={description ?? ""}>
<TextInput
value={value}
onChange={onChange}
placeholder={placeholder}
/>
</SettingsSection>
);
}
function SettingGroup(props: {
children: React.ReactNode;
description?: string;
name: string;
}) {
const { children, description, name } = props;
return (
<SettingsSection name={name} description={description ?? ""}>
<div className={cl("group")}>
{children}
</div>
</SettingsSection>
);
}
function SettingSwitch(props: {
checked: boolean;
description: string;
name: string;
onChange: (value: boolean) => void;
}) {
const { checked, description, name, onChange } = props;
return (
<SettingsSection tag="label" name={name} description={description} inlineSetting>
<Switch checked={checked} onChange={onChange} />
</SettingsSection>
);
}
function ServicePicker(props: {
onChange: (service: ServiceType) => void;
value: ServiceType;
}) {
const { onChange, value } = props;
return (
<div className={cl("service-grid")}>
{serviceOptions.map(option => (
<button
key={option.value}
type="button"
className={cl("service-option")}
data-selected={option.value === value}
onClick={() => onChange(option.value)}
>
<span className={cl("service-option-label")}>{option.label}</span>
</button>
))}
</div>
);
}
function FallbackOrderSettings() {
const update = useForceUpdater();
const { store } = settings;
const [dragIndex, setDragIndex] = React.useState<number | null>(null);
const [order, setOrder] = React.useState<ServiceType[]>(() => {
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 (
<SettingsSection name="Fallback Order" description="Drag hosts to reorder fallback attempts. The selected host is tried first, then this order is used.">
<div className={cl("fallback-order-list")}>
{order.map((service, index) => (
<div
key={service}
className={cl("fallback-order-item")}
draggable
onDragStart={event => {
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}
>
<span className={cl("fallback-order-label")}>{serviceLabels[service]}</span>
<span className={cl("fallback-order-handle")}>Drag</span>
</div>
))}
</div>
<div className={cl("fallback-order-actions")}>
<Button size="small" onClick={() => commitOrder(fallbackServiceOrder)}>
Reset to default
</Button>
</div>
</SettingsSection>
);
}
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 isEncryptingHost = store.serviceType === ServiceType.ENCRYPTINGHOST;
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 isGofile = store.serviceType === ServiceType.GOFILE;
const isPixelVault = store.serviceType === ServiceType.PIXELVAULT;
const isPixelDrain = store.serviceType === ServiceType.PIXELDRAIN;
const isShareX = store.serviceType === ServiceType.SHAREX;
const isWebdav = store.serviceType === ServiceType.WEBDAV;
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 (
<>
<SettingsSection name="Upload Service" description="Choose where FileUpload sends new files.">
<ServicePicker
value={store.serviceType as ServiceType}
onChange={service => {
store.serviceType = service;
update();
}}
/>
</SettingsSection>
{isZipline && (
<SettingGroup name="Zipline" description="Connection details for your Zipline instance.">
<SettingTextInput
name="Service URL"
description="The URL of your Zipline instance"
value={store.serviceUrl}
onChange={v => store.serviceUrl = v}
placeholder="https://your-zipline-instance.com"
/>
<SettingTextInput
name="Zipline Token"
description="Your Zipline API authorization token"
value={store.ziplineToken}
onChange={v => store.ziplineToken = v}
placeholder="Your Zipline API token"
/>
<SettingTextInput
name="Folder ID"
description="Folder ID for uploads (leave empty for no folder)"
value={store.folderId}
onChange={v => store.folderId = v}
placeholder="Leave empty for no folder"
/>
</SettingGroup>
)}
{isEzHost && (
<SettingGroup name="E-Z Host" description="Connection details for E-Z Host uploads.">
<SettingTextInput
name="E-Z Host API Key"
description="Your E-Z Host API key"
value={store.ezHostKey}
onChange={v => store.ezHostKey = v}
placeholder="Your E-Z Host API key"
/>
</SettingGroup>
)}
{isNest && (
<SettingGroup name="Nest" description="Connection details for Nest uploads.">
<SettingTextInput
name="Nest Token"
description="Your Nest API authorization token"
value={store.nestToken}
onChange={v => store.nestToken = v}
placeholder="Your Nest API token"
/>
</SettingGroup>
)}
{isEncryptingHost && (
<SettingGroup name="Encrypting.host" description="Connection details for Encrypting.host uploads.">
<SettingTextInput
name="Encrypting.host API Key"
description="Your Encrypting.host API key"
value={(store as { encryptingHostKey?: string; }).encryptingHostKey || ""}
onChange={v => (store as { encryptingHostKey?: string; }).encryptingHostKey = v}
placeholder="Your Encrypting.host API key"
/>
<SettingsSection name="URL Style" description="How Encrypting.host should format returned links.">
<Select
options={encryptingHostUrlStyleOptions}
isSelected={v => v === (store as { encryptingHostUrlStyle?: string; }).encryptingHostUrlStyle}
select={v => {
(store as { encryptingHostUrlStyle?: string; }).encryptingHostUrlStyle = v;
update();
}}
serialize={v => v}
placeholder="Select URL style"
/>
</SettingsSection>
<SettingsSection name="Domains JSON" description={"JSON array of domains to use, for example [\"offensive\"]."}>
<TextArea
value={(store as { encryptingHostDomains?: string; }).encryptingHostDomains || ""}
rows={3}
placeholder='["offensive"]'
onChange={v => (store as { encryptingHostDomains?: string; }).encryptingHostDomains = v}
/>
</SettingsSection>
<SettingTextInput
name="Embed Title"
description="Optional title for embed style responses."
value={(store as { encryptingHostTitle?: string; }).encryptingHostTitle || ""}
onChange={v => (store as { encryptingHostTitle?: string; }).encryptingHostTitle = v}
placeholder="Optional title"
/>
<SettingTextInput
name="Embed Color"
description="Optional color for embed style responses."
value={(store as { encryptingHostColor?: string; }).encryptingHostColor || ""}
onChange={v => (store as { encryptingHostColor?: string; }).encryptingHostColor = v}
placeholder="Optional color"
/>
<SettingTextInput
name="Fake Link"
description="Optional fake link value for fakelink style responses."
value={(store as { encryptingHostFakelink?: string; }).encryptingHostFakelink || ""}
onChange={v => (store as { encryptingHostFakelink?: string; }).encryptingHostFakelink = v}
placeholder="Optional fake link"
/>
</SettingGroup>
)}
{isS3 && (
<SettingGroup name="S3-Compatible Storage" description="Connection details and object naming for your bucket.">
<SettingTextInput
name="S3 Endpoint URL"
description="S3-compatible endpoint (e.g. https://<accountid>.r2.cloudflarestorage.com)"
value={store.s3Endpoint}
onChange={v => store.s3Endpoint = v}
placeholder="https://your-endpoint.example.com"
/>
<SettingTextInput
name="Bucket Name"
description="Bucket to upload into"
value={store.s3Bucket}
onChange={v => store.s3Bucket = v}
placeholder="my-bucket"
/>
<SettingTextInput
name="Region"
description="AWS region or auto for Cloudflare R2"
value={store.s3Region}
onChange={v => store.s3Region = v}
placeholder="auto"
/>
<SettingTextInput
name="Access Key ID"
description="S3-compatible access key"
value={store.s3AccessKeyId}
onChange={v => store.s3AccessKeyId = v}
placeholder="Your access key ID"
/>
<SettingTextInput
name="Secret Access Key"
description="S3-compatible secret key"
value={store.s3SecretAccessKey}
onChange={v => store.s3SecretAccessKey = v}
placeholder="Your secret access key"
/>
<SettingTextInput
name="Session Token"
description="Optional temporary credential token"
value={store.s3SessionToken}
onChange={v => store.s3SessionToken = v}
placeholder="Optional session token"
/>
<SettingTextInput
name="Public Base URL"
description="Optional public URL base to use for returned links"
value={store.s3PublicUrl}
onChange={v => store.s3PublicUrl = v}
placeholder="https://cdn.example.com"
/>
<SettingTextInput
name="Object Key Prefix"
description="Optional folder/prefix inside the bucket"
value={store.s3Prefix}
onChange={v => store.s3Prefix = v}
placeholder="uploads/discord"
/>
<SettingSwitch
name="Use Path-Style Endpoint"
description="Use endpoint/bucket/key format, recommended for R2."
checked={store.s3ForcePathStyle}
onChange={v => store.s3ForcePathStyle = v}
/>
</SettingGroup>
)}
{isCatbox && (
<SettingGroup name="Catbox" description="Optional account binding for Catbox uploads.">
<SettingTextInput
name="Catbox Userhash"
description="Your Catbox userhash for account binding, leave empty for anonymous uploads."
value={store.catboxUserhash}
onChange={v => store.catboxUserhash = v}
placeholder="Your Catbox userhash"
/>
</SettingGroup>
)}
{isLitterbox && (
<SettingsSection name="Litterbox Expiry" description="How long uploads are retained">
<Select
options={litterboxOptions}
isSelected={v => v === store.litterboxExpiry}
select={v => {
store.litterboxExpiry = v;
update();
}}
serialize={v => v}
placeholder="Select expiry"
/>
</SettingsSection>
)}
{isGofile && (
<SettingGroup name="GoFile" description="Optional account binding for GoFile uploads.">
<SettingTextInput
name="GoFile Token"
description="Optional GoFile token to upload into your account."
value={store.gofileToken}
onChange={v => store.gofileToken = v}
placeholder="Optional GoFile token"
/>
</SettingGroup>
)}
{isPixelVault && (
<SettingGroup name="PixelVault" description="Connection details for PixelVault uploads.">
<SettingTextInput
name="PixelVault Upload Key"
description="Your PixelVault authorization key."
value={store.pixelVaultKey}
onChange={v => store.pixelVaultKey = v}
placeholder="Your PixelVault upload key"
/>
</SettingGroup>
)}
{isPixelDrain && (
<SettingGroup name="PixelDrain" description="Optional account binding for PixelDrain uploads.">
<SettingTextInput
name="PixelDrain API Key"
description="Optional PixelDrain API key for authenticated uploads. Leave empty for anonymous uploads."
value={store.pixelDrainKey}
onChange={v => store.pixelDrainKey = v}
placeholder="Your PixelDrain API key"
/>
</SettingGroup>
)}
{isShareX && (
<SettingGroup name="ShareX Custom Uploader" description="Paste, import, or validate a ShareX custom uploader config.">
<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 className={cl("actions")}>
<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>
</SettingGroup>
)}
{isWebdav && (
<SettingGroup name="WebDAV" description="Connection details for WebDAV servers (Nextcloud, Owncloud, etc.).">
<SettingTextInput
name="Server URL"
description="Base WebDAV URL (e.g. https://nextcloud.example.com/remote.php/dav/files/username)"
value={store.webdavUrl}
onChange={v => store.webdavUrl = v}
placeholder="https://nextcloud.example.com/remote.php/dav/files/username"
/>
<SettingTextInput
name="Username"
description="WebDAV username"
value={store.webdavUsername}
onChange={v => store.webdavUsername = v}
placeholder="username"
/>
<SettingTextInput
name="Password or App Token"
description="WebDAV password or app token"
value={store.webdavPassword}
onChange={v => store.webdavPassword = v}
placeholder="password or app token"
/>
<SettingTextInput
name="Upload Directory"
description="Optional subdirectory on the server to upload into (e.g. uploads)"
value={store.webdavDirectory}
onChange={v => store.webdavDirectory = v}
placeholder="Leave empty for root directory"
/>
</SettingGroup>
)}
<SettingGroup name="Upload Behavior" description="Control what FileUpload does after a host returns a URL.">
<SettingSwitch
name="Strip Query Parameters"
description="Strip query parameters from the uploaded file URL."
checked={store.stripQueryParams}
onChange={v => store.stripQueryParams = v}
/>
<SettingSwitch
name="Use Embed Proxy"
description="Wrap uploaded video links with an embed proxy service for better Discord previews."
checked={store.embedProxyEnabled}
onChange={v => {
store.embedProxyEnabled = v;
update();
}}
/>
{store.embedProxyEnabled && (
<SettingsSection name="Embed Proxy Service" description="Choose which embed proxy service to use for uploaded video links">
<Select
options={embedProxyOptions}
isSelected={v => v === store.embedProxyService}
select={v => {
store.embedProxyService = v;
update();
}}
serialize={v => v}
placeholder="Select an embed proxy service"
/>
</SettingsSection>
)}
<SettingSwitch
name="Convert APNG to GIF"
description="Convert APNG files to GIF format."
checked={store.apngToGif}
onChange={v => store.apngToGif = v}
/>
<SettingSwitch
name="Preserve Original Filename"
description="Use the original filename instead of naming uploads as upload.ext."
checked={Boolean((store as { preserveOriginalFilename?: boolean; }).preserveOriginalFilename)}
onChange={v => (store as { preserveOriginalFilename?: boolean; }).preserveOriginalFilename = v}
/>
<SettingSwitch
name="Auto Copy URL"
description="Automatically copy the uploaded file URL to clipboard."
checked={store.autoCopy}
onChange={v => store.autoCopy = v}
/>
<SettingSwitch
name="Disable Fallback Uploaders"
description="Only use the selected uploader without trying fallback hosts."
checked={store.disableFallbacks}
onChange={v => store.disableFallbacks = v}
/>
<SettingSwitch
name="Insert URL into Chat Input"
description="After upload, insert the resulting URL into the current chat input."
checked={store.autoSend}
onChange={v => store.autoSend = v}
/>
<SettingSwitch
name="Format Inserted URL"
description="Wrap inserted URLs in angle brackets to avoid Discord preview embedding."
checked={store.autoFormat}
onChange={v => store.autoFormat = v}
/>
</SettingGroup>
<SettingGroup name="Discord Integration" description="Choose when FileUpload takes over Discord file handling.">
<SettingSwitch
name="Bypass Discord Upload Button"
description="Use FileUpload when uploading through Discord's file picker."
checked={Boolean((store as { bypassDiscordUpload?: boolean; }).bypassDiscordUpload)}
onChange={v => (store as { bypassDiscordUpload?: boolean; }).bypassDiscordUpload = v}
/>
<SettingSwitch
name="Auto Upload Pasted Files"
description="Automatically upload files from clipboard to image host when pasting in chat input."
checked={store.autoUploadPastedFiles}
onChange={v => store.autoUploadPastedFiles = v}
/>
<SettingSwitch
name="Respect Discord File Size Limit"
description="Use FileUpload only for files larger than your current Discord upload limit."
checked={store.bypassDiscordUploadOnlyOverLimit}
onChange={v => store.bypassDiscordUploadOnlyOverLimit = v}
/>
<SettingTextInput
name="Allowed File Types"
description="Comma-separated list of extensions (e.g. png,jpg,gif). Leave empty to allow all."
value={store.uploadAllowedFileTypes}
onChange={v => store.uploadAllowedFileTypes = v}
placeholder="png,jpg,gif,mp4,webp"
/>
</SettingGroup>
<SettingGroup name="Network" description="Configure browser upload proxying and timeouts.">
<SettingTextInput
name="CORS Proxy URL"
description="CORS proxy used for web uploads. Leave empty to use the default proxy."
value={store.corsProxyUrl || ""}
onChange={v => store.corsProxyUrl = v}
placeholder="https://your-cors-proxy.example.com"
/>
<SettingsSection name="Default CORS Proxy Source" description="Source code for the default CORS proxy">
<a href="https://codeberg.org/key/corsproxy" target="_blank" rel="noreferrer">codeberg.org/key/corsproxy</a>
</SettingsSection>
<SettingsSection name="Upload Timeout" description="Maximum time to wait per upload attempt before switching to fallback">
<Select
options={[
{ label: "30 seconds", value: 30000 },
{ label: "1 minute", value: 60000 },
{ label: "2 minutes", value: 120000 },
{ label: "5 minutes", value: 300000, default: true },
{ label: "10 minutes", value: 600000 }
]}
isSelected={v => v === (store.uploadTimeoutMs || 300000)}
select={v => {
store.uploadTimeoutMs = v;
update();
}}
serialize={v => v}
placeholder="Select timeout"
/>
</SettingsSection>
</SettingGroup>
<FallbackOrderSettings />
</>
);
}