diff --git a/src/equicordplugins/fileUpload/index.tsx b/src/equicordplugins/fileUpload/index.tsx
index 724b5e926..6e124f7f2 100644
--- a/src/equicordplugins/fileUpload/index.tsx
+++ b/src/equicordplugins/fileUpload/index.tsx
@@ -12,13 +12,14 @@ import { OpenExternalIcon } from "@components/Icons";
import { Devs, EquicordDevs } from "@utils/constants";
import { classNameFactory } from "@utils/css";
import definePlugin from "@utils/types";
+import { CloudUpload } from "@vencord/discord-types";
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 { serviceLabels, ServiceType } from "./types";
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 { getUserMaxFileSize } = findByPropsLazy("getUserMaxFileSize");
let uploadAddFilesInterceptor: ((event: unknown) => void) | null = null;
@@ -81,7 +82,7 @@ function interceptUploadAddFiles(event: unknown): void {
...extractFilesFromValue(payload.uploads),
...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 (!shouldInterceptUploadFiles(uniqueFiles, payload)) return;
@@ -98,10 +99,13 @@ function handlePaste(event: ClipboardEvent) {
if (!settings.store.autoUploadPastedFiles || !isConfigured()) return;
+ const allowed = files.filter(f => isFileTypeAllowed(f));
+ if (allowed.length === 0) return;
+
event.preventDefault();
event.stopPropagation();
- void uploadProvidedFiles(files);
+ void uploadProvidedFiles(allowed);
}
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 = () => ;
const channelAttachMenuPatch: NavContextMenuPatchCallback = (children, props) => {
const channel = props?.channel;
if (!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,
-
uploadPickedFile()}
- />
- );
+ const uploads = UploadAttachmentStore.getUploads(channel.id, DraftType.ChannelMessage);
+ const draftUploads = Array.isArray(uploads) ? uploads.filter((u: CloudUpload) => u.item?.file && isFileTypeAllowed(u.item.file)) : [];
+
+ if (draftUploads.length > 0) {
+ children.splice(1, 0,
+
+ {draftUploads.map((upload: CloudUpload) => (
+ handleUploadFileFromDraft(upload)}
+ />
+ ))}
+
+ uploadPickedFile()}
+ />
+
+ );
+ } else {
+ children.splice(1, 0,
+ uploadPickedFile()}
+ />
+ );
+ }
};
export default definePlugin({
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"],
authors: [EquicordDevs.creations, EquicordDevs.keircn, Devs.ScattrdBlade],
settings,
diff --git a/src/equicordplugins/fileUpload/native.ts b/src/equicordplugins/fileUpload/native.ts
index ad401217d..c566cab5e 100644
--- a/src/equicordplugins/fileUpload/native.ts
+++ b/src/equicordplugins/fileUpload/native.ts
@@ -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
+): Promise {
+ 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(
_: IpcMainInvokeEvent,
diff --git a/src/equicordplugins/fileUpload/settings.tsx b/src/equicordplugins/fileUpload/settings.tsx
index b580f07c2..d26a35fda 100644
--- a/src/equicordplugins/fileUpload/settings.tsx
+++ b/src/equicordplugins/fileUpload/settings.tsx
@@ -36,7 +36,8 @@ const serviceOptions = [
{ label: "filebin.net", value: ServiceType.FILEBIN },
{ label: "PixelVault", value: ServiceType.PIXELVAULT },
{ 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 = [
@@ -313,6 +314,36 @@ export const settings = definePluginSettings({
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",
@@ -475,6 +506,7 @@ export function SettingsComponent() {
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 {
@@ -785,6 +817,39 @@ export function SettingsComponent() {
)}
+ {isWebdav && (
+
+ store.webdavUrl = v}
+ placeholder="https://nextcloud.example.com/remote.php/dav/files/username"
+ />
+ store.webdavUsername = v}
+ placeholder="username"
+ />
+ store.webdavPassword = v}
+ placeholder="password or app token"
+ />
+ store.webdavDirectory = v}
+ placeholder="Leave empty for root directory"
+ />
+
+ )}
+
store.bypassDiscordUploadOnlyOverLimit = v}
/>
+
+ store.uploadAllowedFileTypes = v}
+ placeholder="png,jpg,gif,mp4,webp"
+ />
diff --git a/src/equicordplugins/fileUpload/types.ts b/src/equicordplugins/fileUpload/types.ts
index 8c40d796e..e24bac65f 100644
--- a/src/equicordplugins/fileUpload/types.ts
+++ b/src/equicordplugins/fileUpload/types.ts
@@ -20,7 +20,8 @@ export enum ServiceType {
TEMPSH = "tempsh",
FILEBIN = "filebin",
PIXELVAULT = "pixelvault",
- PIXELDRAIN = "pixeldrain"
+ PIXELDRAIN = "pixeldrain",
+ WEBDAV = "webdav"
}
export const serviceLabels: Record = {
@@ -39,7 +40,8 @@ export const serviceLabels: Record = {
[ServiceType.TEMPSH]: "temp.sh",
[ServiceType.FILEBIN]: "filebin.net",
[ServiceType.PIXELVAULT]: "PixelVault",
- [ServiceType.PIXELDRAIN]: "PixelDrain"
+ [ServiceType.PIXELDRAIN]: "PixelDrain",
+ [ServiceType.WEBDAV]: "WebDAV"
};
export const fallbackServiceOrder: ServiceType[] = [
@@ -58,6 +60,7 @@ export const fallbackServiceOrder: ServiceType[] = [
ServiceType.FILEBIN,
ServiceType.PIXELVAULT,
ServiceType.PIXELDRAIN,
+ ServiceType.WEBDAV,
ServiceType.SHAREX
];
diff --git a/src/equicordplugins/fileUpload/utils/upload.ts b/src/equicordplugins/fileUpload/utils/upload.ts
index 9fc4350d6..023f7d5da 100644
--- a/src/equicordplugins/fileUpload/utils/upload.ts
+++ b/src/equicordplugins/fileUpload/utils/upload.ts
@@ -23,7 +23,7 @@ const Native = IS_DISCORD_DESKTOP
? VencordNative.pluginHelpers.FileUpload as PluginNative
: null;
-const logger = new Logger("FileUpload", "#7cb7ff");
+export const logger = new Logger("FileUpload", "#7cb7ff");
function toProxyUrl(url: string): string {
const corsProxyUrl = normalizeCorsProxyUrl((settings.store as { corsProxyUrl?: string; }).corsProxyUrl);
@@ -594,6 +594,8 @@ export function isConfigured(): boolean {
return true;
case ServiceType.PIXELVAULT:
return Boolean((settings.store as { pixelVaultKey?: string; }).pixelVaultKey);
+ case ServiceType.WEBDAV:
+ return Boolean((settings.store as { webdavUrl?: string; }).webdavUrl);
case ServiceType.SHAREX:
try {
parseShareXConfigFromSettings();
@@ -972,6 +974,54 @@ async function uploadToPixelDrain(fileBlob: Blob, filename: string): Promise {
+ 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 = {
+ "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 {
switch (serviceType) {
case ServiceType.ZIPLINE:
@@ -1006,6 +1056,8 @@ async function uploadToService(serviceType: ServiceType, fileBlob: Blob, filenam
return uploadToPixelVault(fileBlob, filename);
case ServiceType.PIXELDRAIN:
return uploadToPixelDrain(fileBlob, filename);
+ case ServiceType.WEBDAV:
+ return uploadToWebdav(fileBlob, filename);
default:
throw new Error("Unknown service type");
}
@@ -1116,6 +1168,25 @@ function finalizeUploadedUrl(url: string): string {
}
}
+function getAllowedExtensions(): Set | 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 {
const dotIndex = filename.lastIndexOf(".");
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;
}
-async function notifyUploadSuccess(finalUrl: string): Promise {
+async function notifyUploadSuccess(finalUrl: string, forceSend?: boolean): Promise {
if (settings.store.autoCopy) {
if (!finalUrl || !finalUrl.trim()) {
showToast("Upload successful, but no URL was available to copy", Toasts.Type.MESSAGE);
@@ -1142,7 +1213,7 @@ async function notifyUploadSuccess(finalUrl: string): Promise {
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);
if (autoSend) {
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 {
+async function uploadPreparedBlob(blob: Blob, sourceUrl?: string, forceSend?: boolean): Promise {
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);
+ await notifyUploadSuccess(finalUrl, forceSend);
+ return finalUrl;
}
export async function uploadFile(url: string): Promise {
@@ -1368,10 +1440,15 @@ export async function uploadPickedFile(): Promise {
const file = await chooseFile("*/*");
if (!file) return;
+ if (!isFileTypeAllowed(file)) {
+ showToast("File type not allowed by current filter", Toasts.Type.FAILURE);
+ return;
+ }
+
await uploadProvidedFiles([file]);
}
-export async function uploadProvidedFiles(files: readonly File[]): Promise {
+export async function uploadProvidedFiles(files: readonly File[], forceSend?: boolean): Promise {
if (isUploading) {
showToast("Upload already in progress", Toasts.Type.MESSAGE);
return;
@@ -1384,7 +1461,7 @@ export async function uploadProvidedFiles(files: readonly File[]): Promise
if (!files.length) return;
- const uploadFiles = files.filter(file => Boolean(file));
+ const uploadFiles = files.filter(file => Boolean(file) && isFileTypeAllowed(file));
if (!uploadFiles.length) return;
isUploading = true;
@@ -1408,7 +1485,7 @@ export async function uploadProvidedFiles(files: readonly File[]): Promise
canCancel: true
});
- await uploadPreparedBlob(file);
+ await uploadPreparedBlob(file, undefined, forceSend);
}
} catch (error) {
const message = error instanceof Error ? error.message : "Unknown error";