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
This commit is contained in:
Key 2026-06-10 15:15:05 +01:00 • committed by GitHub
parent ec7886beaf
commit 3d1ea790a5
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 280 additions and 30 deletions

View file

@ -12,13 +12,14 @@ import { OpenExternalIcon } from "@components/Icons";
import { Devs, EquicordDevs } from "@utils/constants"; import { Devs, EquicordDevs } from "@utils/constants";
import { classNameFactory } from "@utils/css"; import { classNameFactory } from "@utils/css";
import definePlugin from "@utils/types"; import definePlugin from "@utils/types";
import { CloudUpload } from "@vencord/discord-types";
import { findByPropsLazy } from "@webpack"; import { findByPropsLazy } from "@webpack";
import { DraftType, FluxDispatcher, Menu, PermissionsBits, PermissionStore, React, useEffect, UserStore, useState } from "@webpack/common"; import { DraftType, FluxDispatcher, Menu, PermissionsBits, PermissionStore, React, showToast, Toasts, UploadAttachmentStore, useEffect, UserStore, useState } from "@webpack/common";
import { settings } from "./settings"; import { settings } from "./settings";
import { serviceLabels, ServiceType } from "./types"; import { serviceLabels, ServiceType } from "./types";
import { getMediaUrl } from "./utils/getMediaUrl"; import { getMediaUrl } from "./utils/getMediaUrl";
import { cancelCurrentUpload, getUploadState, isConfigured, subscribeUploadState, uploadFile, uploadPickedFile, uploadProvidedFiles } from "./utils/upload"; import { cancelCurrentUpload, getUploadState, isConfigured, isFileTypeAllowed, logger, subscribeUploadState, uploadFile, uploadPickedFile, uploadProvidedFiles } from "./utils/upload";
const cl = classNameFactory("vc-file-upload-"); const cl = classNameFactory("vc-file-upload-");
const { getUserMaxFileSize } = findByPropsLazy("getUserMaxFileSize"); const { getUserMaxFileSize } = findByPropsLazy("getUserMaxFileSize");
let uploadAddFilesInterceptor: ((event: unknown) => void) | null = null; let uploadAddFilesInterceptor: ((event: unknown) => void) | null = null;
@ -81,7 +82,7 @@ function interceptUploadAddFiles(event: unknown): void {
...extractFilesFromValue(payload.uploads), ...extractFilesFromValue(payload.uploads),
...extractFilesFromValue(payload.items) ...extractFilesFromValue(payload.items)
]; ];
const uniqueFiles = Array.from(new Set(files)); const uniqueFiles = Array.from(new Set(files)).filter(f => isFileTypeAllowed(f));
if (!uniqueFiles.length) return; if (!uniqueFiles.length) return;
if (!shouldInterceptUploadFiles(uniqueFiles, payload)) return; if (!shouldInterceptUploadFiles(uniqueFiles, payload)) return;
@ -98,10 +99,13 @@ function handlePaste(event: ClipboardEvent) {
if (!settings.store.autoUploadPastedFiles || !isConfigured()) return; if (!settings.store.autoUploadPastedFiles || !isConfigured()) return;
const allowed = files.filter(f => isFileTypeAllowed(f));
if (allowed.length === 0) return;
event.preventDefault(); event.preventDefault();
event.stopPropagation(); event.stopPropagation();
void uploadProvidedFiles(files); void uploadProvidedFiles(allowed);
} }
function formatBytes(bytes: number): string { function formatBytes(bytes: number): string {
@ -224,32 +228,88 @@ const imageContextMenuPatch: NavContextMenuPatchCallback = (children, props) =>
); );
}; };
async function handleUploadFileFromDraft(upload: CloudUpload) {
const file = upload.item?.file;
if (!file) return;
if (!isFileTypeAllowed(file)) {
showToast("File type not allowed by current filter", Toasts.Type.FAILURE);
return;
}
if (!isConfigured()) {
showToast("Please configure FileUpload settings first", Toasts.Type.FAILURE);
return;
}
try {
await uploadProvidedFiles([file], true);
upload.removeFromMsgDraft();
} catch (e) {
logger.warn("Draft upload encountered an unexpected error", e);
}
}
const ExternalIcon = () => <OpenExternalIcon height={24} width={24} />; const ExternalIcon = () => <OpenExternalIcon height={24} width={24} />;
const channelAttachMenuPatch: NavContextMenuPatchCallback = (children, props) => { const channelAttachMenuPatch: NavContextMenuPatchCallback = (children, props) => {
const channel = props?.channel; const channel = props?.channel;
if (!channel) return; if (!channel) return;
if (channel.guild_id && !PermissionStore.can(PermissionsBits.SEND_MESSAGES, channel)) return; if (channel.guild_id && !PermissionStore.can(PermissionsBits.SEND_MESSAGES, channel)) return;
if (children.some(child => child?.props?.id === "file-upload-manual")) return; if (children.some(child => child?.props?.id === "file-upload-manual" || child?.props?.id === "file-upload-uploads")) return;
children.splice(1, 0, const uploads = UploadAttachmentStore.getUploads(channel.id, DraftType.ChannelMessage);
<Menu.MenuItem const draftUploads = Array.isArray(uploads) ? uploads.filter((u: CloudUpload) => u.item?.file && isFileTypeAllowed(u.item.file)) : [];
id="file-upload-manual"
key="file-upload-manual" if (draftUploads.length > 0) {
label="Upload to Host" children.splice(1, 0,
iconLeft={ExternalIcon} <Menu.MenuItem
leadingAccessory={{ id="file-upload-uploads"
type: "icon", key="file-upload-uploads"
icon: ExternalIcon label="Upload to Host"
}} iconLeft={ExternalIcon}
action={() => uploadPickedFile()} leadingAccessory={{
/> type: "icon",
); icon: ExternalIcon
}}
>
{draftUploads.map((upload: CloudUpload) => (
<Menu.MenuItem
id={`file-upload-draft-${upload.id}`}
key={upload.id}
label={upload.filename}
action={() => handleUploadFileFromDraft(upload)}
/>
))}
<Menu.MenuSeparator />
<Menu.MenuItem
id="file-upload-manual"
key="file-upload-manual"
label="Choose File..."
action={() => uploadPickedFile()}
/>
</Menu.MenuItem>
);
} else {
children.splice(1, 0,
<Menu.MenuItem
id="file-upload-manual"
key="file-upload-manual"
label="Upload to Host"
iconLeft={ExternalIcon}
leadingAccessory={{
type: "icon",
icon: ExternalIcon
}}
action={() => uploadPickedFile()}
/>
);
}
}; };
export default definePlugin({ export default definePlugin({
name: "FileUpload", name: "FileUpload",
description: "Upload images and videos to file hosting services like Zipline and Nest", description: "Upload files to hosting services like Zipline, Nest, S3, and WebDAV",
tags: ["Media"], tags: ["Media"],
authors: [EquicordDevs.creations, EquicordDevs.keircn, Devs.ScattrdBlade], authors: [EquicordDevs.creations, EquicordDevs.keircn, Devs.ScattrdBlade],
settings, settings,

View file

@ -458,6 +458,43 @@ export async function uploadToPixelDrain(
} }
} }
function isValidHttpsUrl(url: string): boolean {
try {
const parsed = new URL(url);
return parsed.protocol === "https:";
} catch {
return false;
}
}
export async function uploadToWebdav(
_: IpcMainInvokeEvent,
fileBuffer: ArrayBuffer,
uploadUrl: string,
headers: Record<string, string>
): Promise<NativeUploadResult> {
if (!isValidHttpsUrl(uploadUrl)) {
return { success: false, error: "Invalid or non-HTTPS upload URL" };
}
try {
const response = await fetch(uploadUrl, {
method: "PUT",
headers,
body: new Blob([fileBuffer])
});
if (!response.ok) {
const errorText = await response.text();
return { success: false, error: `Upload failed: ${response.status} ${errorText}` };
}
return { success: true, url: uploadUrl };
} catch (e) {
return { success: false, error: e instanceof Error ? e.message : "Unknown error" };
}
}
export async function fetchFile( export async function fetchFile(
_: IpcMainInvokeEvent, _: IpcMainInvokeEvent,

View file

@ -36,7 +36,8 @@ const serviceOptions = [
{ label: "filebin.net", value: ServiceType.FILEBIN }, { label: "filebin.net", value: ServiceType.FILEBIN },
{ label: "PixelVault", value: ServiceType.PIXELVAULT }, { label: "PixelVault", value: ServiceType.PIXELVAULT },
{ label: "PixelDrain", value: ServiceType.PIXELDRAIN }, { label: "PixelDrain", value: ServiceType.PIXELDRAIN },
{ label: "ShareX Custom Uploader", value: ServiceType.SHAREX } { label: "ShareX Custom Uploader", value: ServiceType.SHAREX },
{ label: "WebDAV (Nextcloud/Owncloud)", value: ServiceType.WEBDAV }
]; ];
const litterboxOptions = [ const litterboxOptions = [
@ -313,6 +314,36 @@ export const settings = definePluginSettings({
description: "Automatically upload files from clipboard to image host when pasting in chat input.", description: "Automatically upload files from clipboard to image host when pasting in chat input.",
default: false 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: { settingsComponent: {
type: OptionType.COMPONENT, type: OptionType.COMPONENT,
description: "Settings", description: "Settings",
@ -475,6 +506,7 @@ export function SettingsComponent() {
const isPixelVault = store.serviceType === ServiceType.PIXELVAULT; const isPixelVault = store.serviceType === ServiceType.PIXELVAULT;
const isPixelDrain = store.serviceType === ServiceType.PIXELDRAIN; const isPixelDrain = store.serviceType === ServiceType.PIXELDRAIN;
const isShareX = store.serviceType === ServiceType.SHAREX; const isShareX = store.serviceType === ServiceType.SHAREX;
const isWebdav = store.serviceType === ServiceType.WEBDAV;
const validateShareXConfig = () => { const validateShareXConfig = () => {
try { try {
@ -785,6 +817,39 @@ export function SettingsComponent() {
</SettingGroup> </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."> <SettingGroup name="Upload Behavior" description="Control what FileUpload does after a host returns a URL.">
<SettingSwitch <SettingSwitch
name="Strip Query Parameters" name="Strip Query Parameters"
@ -882,6 +947,14 @@ export function SettingsComponent() {
checked={store.bypassDiscordUploadOnlyOverLimit} checked={store.bypassDiscordUploadOnlyOverLimit}
onChange={v => store.bypassDiscordUploadOnlyOverLimit = v} 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>
<SettingGroup name="Network" description="Configure browser upload proxying and timeouts."> <SettingGroup name="Network" description="Configure browser upload proxying and timeouts.">

View file

@ -20,7 +20,8 @@ export enum ServiceType {
TEMPSH = "tempsh", TEMPSH = "tempsh",
FILEBIN = "filebin", FILEBIN = "filebin",
PIXELVAULT = "pixelvault", PIXELVAULT = "pixelvault",
PIXELDRAIN = "pixeldrain" PIXELDRAIN = "pixeldrain",
WEBDAV = "webdav"
} }
export const serviceLabels: Record<ServiceType, string> = { export const serviceLabels: Record<ServiceType, string> = {
@ -39,7 +40,8 @@ export const serviceLabels: Record<ServiceType, string> = {
[ServiceType.TEMPSH]: "temp.sh", [ServiceType.TEMPSH]: "temp.sh",
[ServiceType.FILEBIN]: "filebin.net", [ServiceType.FILEBIN]: "filebin.net",
[ServiceType.PIXELVAULT]: "PixelVault", [ServiceType.PIXELVAULT]: "PixelVault",
[ServiceType.PIXELDRAIN]: "PixelDrain" [ServiceType.PIXELDRAIN]: "PixelDrain",
[ServiceType.WEBDAV]: "WebDAV"
}; };
export const fallbackServiceOrder: ServiceType[] = [ export const fallbackServiceOrder: ServiceType[] = [
@ -58,6 +60,7 @@ export const fallbackServiceOrder: ServiceType[] = [
ServiceType.FILEBIN, ServiceType.FILEBIN,
ServiceType.PIXELVAULT, ServiceType.PIXELVAULT,
ServiceType.PIXELDRAIN, ServiceType.PIXELDRAIN,
ServiceType.WEBDAV,
ServiceType.SHAREX ServiceType.SHAREX
]; ];

View file

@ -23,7 +23,7 @@ const Native = IS_DISCORD_DESKTOP
? VencordNative.pluginHelpers.FileUpload as PluginNative<typeof import("../native")> ? VencordNative.pluginHelpers.FileUpload as PluginNative<typeof import("../native")>
: null; : null;
const logger = new Logger("FileUpload", "#7cb7ff"); export const logger = new Logger("FileUpload", "#7cb7ff");
function toProxyUrl(url: string): string { function toProxyUrl(url: string): string {
const corsProxyUrl = normalizeCorsProxyUrl((settings.store as { corsProxyUrl?: string; }).corsProxyUrl); const corsProxyUrl = normalizeCorsProxyUrl((settings.store as { corsProxyUrl?: string; }).corsProxyUrl);
@ -594,6 +594,8 @@ export function isConfigured(): boolean {
return true; return true;
case ServiceType.PIXELVAULT: case ServiceType.PIXELVAULT:
return Boolean((settings.store as { pixelVaultKey?: string; }).pixelVaultKey); return Boolean((settings.store as { pixelVaultKey?: string; }).pixelVaultKey);
case ServiceType.WEBDAV:
return Boolean((settings.store as { webdavUrl?: string; }).webdavUrl);
case ServiceType.SHAREX: case ServiceType.SHAREX:
try { try {
parseShareXConfigFromSettings(); parseShareXConfigFromSettings();
@ -972,6 +974,54 @@ async function uploadToPixelDrain(fileBlob: Blob, filename: string): Promise<str
return `https://pixeldrain.com/u/${data.id}`; return `https://pixeldrain.com/u/${data.id}`;
} }
async function uploadToWebdav(fileBlob: Blob, filename: string): Promise<string> {
const { webdavUrl, webdavUsername, webdavPassword, webdavDirectory } = settings.store as {
webdavUrl?: string;
webdavUsername?: string;
webdavPassword?: string;
webdavDirectory?: string;
};
if (!webdavUrl) {
throw new Error("WebDAV server URL is required");
}
const baseUrl = webdavUrl.replace(/\/+$/, "");
const dir = (webdavDirectory || "").replace(/^\/+|\/+$/g, "");
const path = dir ? `/${dir}/${encodeURIComponent(filename)}` : `/${encodeURIComponent(filename)}`;
const uploadUrl = `${baseUrl}${path}`;
const headers: Record<string, string> = {
"Content-Type": fileBlob.type || "application/octet-stream"
};
if (webdavUsername?.trim() && webdavPassword?.trim()) {
headers.Authorization = `Basic ${btoa(`${webdavUsername.trim()}:${webdavPassword.trim()}`)}`;
}
if (Native) {
const arrayBuffer = await fileBlob.arrayBuffer();
const result = await Native.uploadToWebdav(arrayBuffer, uploadUrl, headers);
if (!result.success) {
throw new Error(result.error || "Upload failed");
}
return uploadUrl;
}
const response = await uploadRequestWithTimeout(uploadUrl, {
method: "PUT",
headers,
body: fileBlob
});
if (!response.ok) {
const errorText = await response.text();
throw new Error(`Upload failed: ${response.status} ${errorText}`);
}
return uploadUrl;
}
async function uploadToService(serviceType: ServiceType, fileBlob: Blob, filename: string): Promise<string> { async function uploadToService(serviceType: ServiceType, fileBlob: Blob, filename: string): Promise<string> {
switch (serviceType) { switch (serviceType) {
case ServiceType.ZIPLINE: case ServiceType.ZIPLINE:
@ -1006,6 +1056,8 @@ async function uploadToService(serviceType: ServiceType, fileBlob: Blob, filenam
return uploadToPixelVault(fileBlob, filename); return uploadToPixelVault(fileBlob, filename);
case ServiceType.PIXELDRAIN: case ServiceType.PIXELDRAIN:
return uploadToPixelDrain(fileBlob, filename); return uploadToPixelDrain(fileBlob, filename);
case ServiceType.WEBDAV:
return uploadToWebdav(fileBlob, filename);
default: default:
throw new Error("Unknown service type"); throw new Error("Unknown service type");
} }
@ -1116,6 +1168,25 @@ function finalizeUploadedUrl(url: string): string {
} }
} }
function getAllowedExtensions(): Set<string> | null {
const raw = (settings.store as { uploadAllowedFileTypes?: string; }).uploadAllowedFileTypes?.trim();
if (!raw) return null;
const exts = raw.split(/[\s,;]+/).map(e => e.trim().toLowerCase()).filter(Boolean);
return exts.length > 0 ? new Set(exts) : null;
}
export function isFileTypeAllowed(file: File): boolean {
const allowed = getAllowedExtensions();
if (!allowed) return true;
const dotIndex = file.name.lastIndexOf(".");
if (dotIndex < 1 || dotIndex === file.name.length - 1) return false;
const ext = file.name.slice(dotIndex + 1).toLowerCase();
return allowed.has(ext);
}
function getFilenameExtension(filename: string): string | undefined { function getFilenameExtension(filename: string): string | undefined {
const dotIndex = filename.lastIndexOf("."); const dotIndex = filename.lastIndexOf(".");
if (dotIndex < 1 || dotIndex === filename.length - 1) return undefined; if (dotIndex < 1 || dotIndex === filename.length - 1) return undefined;
@ -1124,7 +1195,7 @@ function getFilenameExtension(filename: string): string | undefined {
return ext.length <= 10 ? ext : undefined; return ext.length <= 10 ? ext : undefined;
} }
async function notifyUploadSuccess(finalUrl: string): Promise<void> { async function notifyUploadSuccess(finalUrl: string, forceSend?: boolean): Promise<void> {
if (settings.store.autoCopy) { if (settings.store.autoCopy) {
if (!finalUrl || !finalUrl.trim()) { if (!finalUrl || !finalUrl.trim()) {
showToast("Upload successful, but no URL was available to copy", Toasts.Type.MESSAGE); showToast("Upload successful, but no URL was available to copy", Toasts.Type.MESSAGE);
@ -1142,7 +1213,7 @@ async function notifyUploadSuccess(finalUrl: string): Promise<void> {
showToast("Upload successful", Toasts.Type.SUCCESS); showToast("Upload successful", Toasts.Type.SUCCESS);
} }
const autoSend = Boolean((settings.store as { autoSend?: boolean; }).autoSend); const autoSend = forceSend || Boolean((settings.store as { autoSend?: boolean; }).autoSend);
const autoFormat = Boolean((settings.store as { autoFormat?: boolean; }).autoFormat); const autoFormat = Boolean((settings.store as { autoFormat?: boolean; }).autoFormat);
if (autoSend) { if (autoSend) {
insertTextIntoChatInputBox(autoFormat ? `<${finalUrl}>` : finalUrl); insertTextIntoChatInputBox(autoFormat ? `<${finalUrl}>` : finalUrl);
@ -1274,13 +1345,14 @@ async function normalizeUploadBlob(blob: Blob, sourceUrl?: string): Promise<{ bl
}; };
} }
async function uploadPreparedBlob(blob: Blob, sourceUrl?: string): Promise<void> { async function uploadPreparedBlob(blob: Blob, sourceUrl?: string, forceSend?: boolean): Promise<string> {
const primary = settings.store.serviceType as ServiceType; const primary = settings.store.serviceType as ServiceType;
const { blob: normalizedBlob, filename } = await normalizeUploadBlob(blob, sourceUrl); const { blob: normalizedBlob, filename } = await normalizeUploadBlob(blob, sourceUrl);
setUploadState({ fileName: filename, status: "File ready, starting upload...", percent: 4 }); setUploadState({ fileName: filename, status: "File ready, starting upload...", percent: 4 });
const uploadedUrl = await uploadWithFallbacks(normalizedBlob, filename, primary); const uploadedUrl = await uploadWithFallbacks(normalizedBlob, filename, primary);
const finalUrl = applyEmbedProxy(finalizeUploadedUrl(uploadedUrl)); const finalUrl = applyEmbedProxy(finalizeUploadedUrl(uploadedUrl));
await notifyUploadSuccess(finalUrl); await notifyUploadSuccess(finalUrl, forceSend);
return finalUrl;
} }
export async function uploadFile(url: string): Promise<void> { export async function uploadFile(url: string): Promise<void> {
@ -1368,10 +1440,15 @@ export async function uploadPickedFile(): Promise<void> {
const file = await chooseFile("*/*"); const file = await chooseFile("*/*");
if (!file) return; if (!file) return;
if (!isFileTypeAllowed(file)) {
showToast("File type not allowed by current filter", Toasts.Type.FAILURE);
return;
}
await uploadProvidedFiles([file]); await uploadProvidedFiles([file]);
} }
export async function uploadProvidedFiles(files: readonly File[]): Promise<void> { export async function uploadProvidedFiles(files: readonly File[], forceSend?: boolean): Promise<void> {
if (isUploading) { if (isUploading) {
showToast("Upload already in progress", Toasts.Type.MESSAGE); showToast("Upload already in progress", Toasts.Type.MESSAGE);
return; return;
@ -1384,7 +1461,7 @@ export async function uploadProvidedFiles(files: readonly File[]): Promise<void>
if (!files.length) return; if (!files.length) return;
const uploadFiles = files.filter(file => Boolean(file)); const uploadFiles = files.filter(file => Boolean(file) && isFileTypeAllowed(file));
if (!uploadFiles.length) return; if (!uploadFiles.length) return;
isUploading = true; isUploading = true;
@ -1408,7 +1485,7 @@ export async function uploadProvidedFiles(files: readonly File[]): Promise<void>
canCancel: true canCancel: true
}); });
await uploadPreparedBlob(file); await uploadPreparedBlob(file, undefined, forceSend);
} }
} catch (error) { } catch (error) {
const message = error instanceof Error ? error.message : "Unknown error"; const message = error instanceof Error ? error.message : "Unknown error";