From 3d1ea790a5ff0c3d003fff2510c2c2110820cc65 Mon Sep 17 00:00:00 2001 From: Key Date: Wed, 10 Jun 2026 15:15:05 +0100 Subject: [PATCH] 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 --- src/equicordplugins/fileUpload/index.tsx | 98 +++++++++++++++---- src/equicordplugins/fileUpload/native.ts | 37 +++++++ src/equicordplugins/fileUpload/settings.tsx | 75 +++++++++++++- src/equicordplugins/fileUpload/types.ts | 7 +- .../fileUpload/utils/upload.ts | 93 ++++++++++++++++-- 5 files changed, 280 insertions(+), 30 deletions(-) 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";