FileUpload: add s3-compatible storage destination (#761)

* FileUpload: add s3-compatible storage destination

* FileUpload: fix settings
This commit is contained in:
Key 2026-02-11 21:22:17 +00:00 • committed by GitHub
parent a686d6bde4
commit 88bb5897b9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 573 additions and 156 deletions

View file

@ -5,97 +5,15 @@
*/
import { findGroupChildrenByChildId, NavContextMenuPatchCallback } from "@api/ContextMenu";
import { definePluginSettings } from "@api/Settings";
import { EquicordDevs } from "@utils/constants";
import definePlugin, { OptionType } from "@utils/types";
import definePlugin from "@utils/types";
import { Menu } from "@webpack/common";
import { SettingsComponent } from "./settings";
import { settings } from "./settings";
import { serviceLabels, ServiceType } from "./types";
import { getMediaUrl } from "./utils/getMediaUrl";
import { uploadFile } from "./utils/upload";
export const settings = definePluginSettings({
serviceType: {
type: OptionType.SELECT,
description: "",
options: [
{ label: "Zipline", value: ServiceType.ZIPLINE, default: true },
{ label: "E-Z Host", value: ServiceType.EZHOST },
{ label: "Nest", value: ServiceType.NEST },
{ label: "Catbox.moe", value: ServiceType.CATBOX },
{ label: "0x0.st", value: ServiceType.ZEROX0 },
{ label: "Litterbox", value: ServiceType.LITTERBOX }
],
hidden: true
},
serviceUrl: {
type: OptionType.STRING,
description: "",
default: "",
hidden: true
},
ezHostKey: {
type: OptionType.STRING,
description: "E-Z Host API key",
default: "",
hidden: true
},
ziplineToken: {
type: OptionType.STRING,
description: "",
default: "",
hidden: true
},
nestToken: {
type: OptionType.STRING,
description: "",
default: "",
hidden: true
},
litterboxExpiry: {
type: OptionType.SELECT,
description: "",
options: [
{ label: "1 hour", value: "1h" },
{ label: "12 hours", value: "12h" },
{ label: "24 hours", value: "24h", default: true },
{ label: "72 hours", value: "72h" }
],
default: "24h",
hidden: true
},
folderId: {
type: OptionType.STRING,
description: "",
default: "",
hidden: true
},
stripQueryParams: {
type: OptionType.BOOLEAN,
description: "",
default: false,
hidden: true
},
apngToGif: {
type: OptionType.BOOLEAN,
description: "",
default: false,
hidden: true
},
autoCopy: {
type: OptionType.BOOLEAN,
description: "",
default: true,
hidden: true
},
settingsComponent: {
type: OptionType.COMPONENT,
description: "Settings",
component: SettingsComponent
}
});
const messageContextMenuPatch: NavContextMenuPatchCallback = (children, props) => {
if (!props) return;

View file

@ -82,6 +82,60 @@ export async function uploadToEzHost(
}
}
export async function uploadTo0x0(
_: IpcMainInvokeEvent,
fileBuffer: ArrayBuffer,
filename: string
): Promise<NativeUploadResult> {
try {
const formData = new FormData();
formData.append("file", new Blob([fileBuffer]), filename);
const response = await fetch("https://0x0.st", {
method: "POST",
body: formData
});
if (!response.ok) {
const errorText = await response.text();
return { success: false, error: `Upload failed: ${response.status} ${errorText}` };
}
const text = (await response.text()).trim();
if (!text) {
return { success: false, error: "No URL returned from upload" };
}
return { success: true, url: text };
} catch (e) {
return { success: false, error: e instanceof Error ? e.message : "Unknown error" };
}
}
export async function uploadToS3(
_: IpcMainInvokeEvent,
fileBuffer: ArrayBuffer,
uploadUrl: string,
headers: Record<string, string>
): Promise<NativeUploadResult> {
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,

View file

@ -4,31 +4,184 @@
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import { definePluginSettings } from "@api/Settings";
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 { settings } from "./index";
import { ServiceType } from "./types";
const serviceOptions = [
{ label: "Zipline", value: ServiceType.ZIPLINE, default: true },
{ label: "E-Z Host", value: ServiceType.EZHOST },
{ label: "Nest", value: ServiceType.NEST },
{ 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 }
];
const litterboxOptions = [
{ label: "1 hour", value: "1h" },
{ label: "12 hours", value: "12h" },
{ label: "24 hours", value: "24h", default: true },
{ label: "72 hours", value: "72h" }
];
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
},
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
},
stripQueryParams: {
type: OptionType.BOOLEAN,
description: "Strip query params from uploaded URLs",
default: false,
hidden: true
},
apngToGif: {
type: OptionType.BOOLEAN,
description: "Convert APNG uploads to GIF",
default: false,
hidden: true
},
autoCopy: {
type: OptionType.BOOLEAN,
description: "Auto copy upload URL",
default: true,
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>
);
}
export function SettingsComponent() {
const update = useForceUpdater();
const { store } = settings;
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 isLitterbox = store.serviceType === ServiceType.LITTERBOX;
const serviceOptions = [
{ label: "Zipline", value: ServiceType.ZIPLINE },
{ label: "E-Z Host", value: ServiceType.EZHOST },
{ label: "Nest", value: ServiceType.NEST },
{ label: "Catbox.moe", value: ServiceType.CATBOX },
{ label: "0x0.st", value: ServiceType.ZEROX0 },
{ label: "Litterbox", value: ServiceType.LITTERBOX }
];
return (
<>
<SettingsSection name="Service Type" description="The upload service to use">
@ -45,64 +198,122 @@ export function SettingsComponent() {
</SettingsSection>
{isZipline && (
<SettingsSection name="Service URL" description="The URL of your Zipline instance">
<TextInput
<>
<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"
/>
</SettingsSection>
)}
{isZipline && (
<SettingsSection name="Zipline Token" description="Your Zipline API authorization token">
<TextInput
<SettingTextInput
name="Zipline Token"
description="Your Zipline API authorization token"
value={store.ziplineToken}
onChange={v => store.ziplineToken = v}
placeholder="Your Zipline API token"
/>
</SettingsSection>
)}
{isEzHost && (
<SettingsSection name="E-Z Host API Key" description="Your E-Z Host API key">
<TextInput
value={(store as { ezHostKey?: string }).ezHostKey || ""}
onChange={v => (store as { ezHostKey?: string }).ezHostKey = v}
placeholder="Your E-Z Host API key"
/>
</SettingsSection>
)}
{isNest && (
<SettingsSection name="Nest Token" description="Your Nest API authorization token">
<TextInput
value={store.nestToken}
onChange={v => store.nestToken = v}
placeholder="Your Nest API token"
/>
</SettingsSection>
)}
{isZipline && (
<SettingsSection name="Folder ID" description="Folder ID for uploads (leave empty for no folder)">
<TextInput
<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"
/>
</SettingsSection>
</>
)}
{isEzHost && (
<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"
/>
)}
{isNest && (
<SettingTextInput
name="Nest Token"
description="Your Nest API authorization token"
value={store.nestToken}
onChange={v => store.nestToken = v}
placeholder="Your Nest API token"
/>
)}
{isS3 && (
<>
<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"
/>
<SettingsSection tag="label" name="Use Path-Style Endpoint" description="Use endpoint/bucket/key format (recommended for R2)" inlineSetting>
<Switch
checked={store.s3ForcePathStyle}
onChange={v => store.s3ForcePathStyle = v}
/>
</SettingsSection>
</>
)}
{isLitterbox && (
<SettingsSection name="Litterbox Expiry" description="How long uploads are retained">
<Select
options={[
{ label: "1 hour", value: "1h" },
{ label: "12 hours", value: "12h" },
{ label: "24 hours", value: "24h" },
{ label: "72 hours", value: "72h" }
]}
options={litterboxOptions}
isSelected={v => v === store.litterboxExpiry}
select={v => {
store.litterboxExpiry = v;

View file

@ -8,6 +8,7 @@ export enum ServiceType {
ZIPLINE = "zipline",
NEST = "nest",
EZHOST = "ezhost",
S3 = "s3",
CATBOX = "catbox",
ZEROX0 = "0x0",
LITTERBOX = "litterbox"
@ -17,6 +18,7 @@ export const serviceLabels: Record<ServiceType, string> = {
[ServiceType.ZIPLINE]: "Zipline",
[ServiceType.NEST]: "Nest",
[ServiceType.EZHOST]: "E-Z Host",
[ServiceType.S3]: "S3-Compatible",
[ServiceType.CATBOX]: "Catbox",
[ServiceType.ZEROX0]: "0x0.st",
[ServiceType.LITTERBOX]: "Litterbox"

View file

@ -0,0 +1,212 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2024 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import { settings } from "@equicordplugins/fileUpload/settings";
import { PluginNative } from "@utils/types";
type S3Store = {
s3Endpoint?: string;
s3Bucket?: string;
s3Region?: string;
s3AccessKeyId?: string;
s3SecretAccessKey?: string;
s3SessionToken?: string;
s3PublicUrl?: string;
s3Prefix?: string;
s3ForcePathStyle?: boolean;
};
const textEncoder = new TextEncoder();
function toArrayBuffer(data: ArrayBuffer | Uint8Array | string): ArrayBuffer {
if (typeof data === "string") {
return textEncoder.encode(data).buffer as ArrayBuffer;
}
if (data instanceof Uint8Array) {
return new Uint8Array(data).buffer;
}
return data;
}
function toHex(buffer: ArrayBuffer): string {
return Array.from(new Uint8Array(buffer), b => b.toString(16).padStart(2, "0")).join("");
}
function encodeRfc3986(value: string): string {
return encodeURIComponent(value).replace(/[!'()*]/g, char => `%${char.charCodeAt(0).toString(16).toUpperCase()}`);
}
function buildS3Path(...parts: string[]): string {
const normalized = parts
.filter(Boolean)
.flatMap(part => part.split("/"))
.filter(Boolean)
.map(encodeRfc3986)
.join("/");
return normalized ? `/${normalized}` : "/";
}
function getTimestampParts(date: Date): { amzDate: string; dateStamp: string; } {
const year = date.getUTCFullYear();
const month = String(date.getUTCMonth() + 1).padStart(2, "0");
const day = String(date.getUTCDate()).padStart(2, "0");
const hour = String(date.getUTCHours()).padStart(2, "0");
const minute = String(date.getUTCMinutes()).padStart(2, "0");
const second = String(date.getUTCSeconds()).padStart(2, "0");
return {
amzDate: `${year}${month}${day}T${hour}${minute}${second}Z`,
dateStamp: `${year}${month}${day}`
};
}
async function sha256Hex(data: ArrayBuffer | Uint8Array | string): Promise<string> {
return toHex(await crypto.subtle.digest("SHA-256", toArrayBuffer(data)));
}
async function hmacSha256(key: ArrayBuffer | Uint8Array | string, value: string): Promise<ArrayBuffer> {
const cryptoKey = await crypto.subtle.importKey("raw", toArrayBuffer(key), { name: "HMAC", hash: "SHA-256" }, false, ["sign"]);
return await crypto.subtle.sign("HMAC", cryptoKey, toArrayBuffer(value));
}
function makeRandomHex(length = 16): string {
const bytes = new Uint8Array(length);
crypto.getRandomValues(bytes);
return Array.from(bytes, b => b.toString(16).padStart(2, "0")).join("");
}
export function isS3Configured(): boolean {
const { s3Endpoint, s3Bucket, s3Region, s3AccessKeyId, s3SecretAccessKey } = settings.store as S3Store;
return Boolean(s3Endpoint && s3Bucket && s3Region && s3AccessKeyId && s3SecretAccessKey);
}
export async function uploadToS3(
fileBlob: Blob,
filename: string,
native: PluginNative<typeof import("../native")> | null
): Promise<string> {
const {
s3Endpoint,
s3Bucket,
s3Region = "auto",
s3AccessKeyId,
s3SecretAccessKey,
s3SessionToken,
s3PublicUrl,
s3Prefix,
s3ForcePathStyle = true
} = settings.store as S3Store;
if (!s3Endpoint || !s3Bucket || !s3Region || !s3AccessKeyId || !s3SecretAccessKey) {
throw new Error("S3 endpoint, bucket, region, access key ID, and secret key are required");
}
const endpoint = new URL(s3Endpoint);
if (!/^https?:$/.test(endpoint.protocol)) {
throw new Error("S3 endpoint must be a valid HTTP(S) URL");
}
const extension = filename.includes(".") ? filename.slice(filename.lastIndexOf(".")) : "";
const prefixSegments = (s3Prefix || "").split("/").filter(Boolean);
const objectName = `${Date.now()}-${makeRandomHex(8)}${extension}`;
const objectKeyRaw = [...prefixSegments, objectName].join("/");
const objectKeyPath = objectKeyRaw.split("/").filter(Boolean).map(encodeRfc3986).join("/");
const host = s3ForcePathStyle ? endpoint.host : `${s3Bucket}.${endpoint.host}`;
const canonicalUri = s3ForcePathStyle
? buildS3Path(endpoint.pathname, s3Bucket, objectKeyRaw)
: buildS3Path(endpoint.pathname, objectKeyRaw);
const uploadUrl = new URL(endpoint.toString());
uploadUrl.host = host;
uploadUrl.pathname = canonicalUri;
uploadUrl.search = "";
const now = new Date();
const { amzDate, dateStamp } = getTimestampParts(now);
const payloadHash = await sha256Hex(await fileBlob.arrayBuffer());
const canonicalHeadersMap: Record<string, string> = {
"content-type": fileBlob.type || "application/octet-stream",
"host": host,
"x-amz-content-sha256": payloadHash,
"x-amz-date": amzDate
};
if (s3SessionToken) {
canonicalHeadersMap["x-amz-security-token"] = s3SessionToken;
}
const signedHeaderEntries = Object.entries(canonicalHeadersMap).sort(([a], [b]) => a.localeCompare(b));
const signedHeaders = signedHeaderEntries.map(([key]) => key).join(";");
const canonicalHeaders = signedHeaderEntries.map(([key, value]) => `${key}:${value.trim()}\n`).join("");
const canonicalRequest = [
"PUT",
canonicalUri,
"",
canonicalHeaders,
signedHeaders,
payloadHash
].join("\n");
const scope = `${dateStamp}/${s3Region}/s3/aws4_request`;
const stringToSign = [
"AWS4-HMAC-SHA256",
amzDate,
scope,
await sha256Hex(canonicalRequest)
].join("\n");
const kDate = await hmacSha256(`AWS4${s3SecretAccessKey}`, dateStamp);
const kRegion = await hmacSha256(kDate, s3Region);
const kService = await hmacSha256(kRegion, "s3");
const kSigning = await hmacSha256(kService, "aws4_request");
const signature = toHex(await hmacSha256(kSigning, stringToSign));
const requestHeaders: Record<string, string> = {
"Authorization": `AWS4-HMAC-SHA256 Credential=${s3AccessKeyId}/${scope}, SignedHeaders=${signedHeaders}, Signature=${signature}`,
"Content-Type": canonicalHeadersMap["content-type"],
"x-amz-content-sha256": payloadHash,
"x-amz-date": amzDate
};
if (s3SessionToken) {
requestHeaders["x-amz-security-token"] = s3SessionToken;
}
if (native) {
const arrayBuffer = await fileBlob.arrayBuffer();
const result = await native.uploadToS3(arrayBuffer, uploadUrl.toString(), requestHeaders);
if (!result.success) {
throw new Error(result.error || "Upload failed");
}
} else {
const response = await fetch(uploadUrl.toString(), {
method: "PUT",
headers: requestHeaders,
body: fileBlob
});
if (!response.ok) {
const text = await response.text();
throw new Error(`Upload failed: ${response.status} ${text}`);
}
}
if (s3PublicUrl) {
const base = s3PublicUrl.replace(/\/+$/, "");
return `${base}/${objectKeyPath}`;
}
const publicBase = s3ForcePathStyle
? `${endpoint.origin}${buildS3Path(endpoint.pathname, s3Bucket)}`
: `${endpoint.protocol}//${host}${buildS3Path(endpoint.pathname)}`;
return `${publicBase.replace(/\/+$/, "")}/${objectKeyPath}`;
}

View file

@ -4,7 +4,7 @@
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import { settings } from "@equicordplugins/fileUpload/index";
import { settings } from "@equicordplugins/fileUpload/settings";
import { ServiceType, UploadResponse } from "@equicordplugins/fileUpload/types";
import { copyToClipboard } from "@utils/clipboard";
import { PluginNative } from "@utils/types";
@ -12,6 +12,7 @@ import { showToast, Toasts } from "@webpack/common";
import { convertApngToGif } from "./apngToGif";
import { getExtensionFromBytes, getExtensionFromMime, getMimeFromExtension, getUrlExtension } from "./getMediaUrl";
import { isS3Configured, uploadToS3 } from "./s3";
const Native = IS_DISCORD_DESKTOP
? VencordNative.pluginHelpers.FileUpload as PluginNative<typeof import("../native")>
@ -116,14 +117,28 @@ async function uploadToNest(fileBlob: Blob, filename: string): Promise<string> {
}
export function isConfigured(): boolean {
const { serviceType, serviceUrl, ziplineToken, nestToken } = settings.store;
const {
serviceType,
serviceUrl,
ziplineToken,
nestToken
} = settings.store as {
serviceType: ServiceType;
serviceUrl?: string;
ziplineToken?: string;
nestToken?: string;
};
switch (serviceType) {
case ServiceType.NEST:
return Boolean(nestToken);
case ServiceType.EZHOST:
return Boolean((settings.store as { ezHostKey?: string }).ezHostKey);
case ServiceType.S3:
return isS3Configured();
case ServiceType.CATBOX:
return true;
case ServiceType.ZEROX0:
return Boolean(Native);
case ServiceType.LITTERBOX:
return true;
case ServiceType.ZIPLINE:
@ -182,8 +197,11 @@ async function uploadToCatbox(fileBlob: Blob, filename: string): Promise<string>
formData.append("reqtype", "fileupload");
formData.append("fileToUpload", fileBlob, filename);
const proxiedUrl = `${CORS_PROXY}?url=${encodeURIComponent("https://catbox.moe/user/api.php")}`;
const response = await fetch(proxiedUrl, {
const uploadUrl = Native
? "https://catbox.moe/user/api.php"
: `${CORS_PROXY}?url=${encodeURIComponent("https://catbox.moe/user/api.php")}`;
const response = await fetch(uploadUrl, {
method: "POST",
body: formData
});
@ -202,26 +220,22 @@ async function uploadToCatbox(fileBlob: Blob, filename: string): Promise<string>
}
async function uploadTo0x0(fileBlob: Blob, filename: string): Promise<string> {
const formData = new FormData();
formData.append("file", fileBlob, filename);
const proxiedUrl = `${CORS_PROXY}?url=${encodeURIComponent("https://0x0.st")}`;
const response = await fetch(proxiedUrl, {
method: "POST",
body: formData
});
if (!response.ok) {
const text = await response.text();
throw new Error(`Upload failed: ${response.status} ${text}`);
if (!Native) {
throw new Error("0x0.st uploads are only supported on the desktop client");
}
const text = (await response.text()).trim();
if (!text) {
const arrayBuffer = await fileBlob.arrayBuffer();
const result = await Native.uploadTo0x0(arrayBuffer, filename);
if (!result.success) {
throw new Error(result.error || "Upload failed");
}
if (!result.url) {
throw new Error("No URL returned from upload");
}
return text;
return result.url;
}
async function uploadToLitterbox(fileBlob: Blob, filename: string): Promise<string> {
@ -230,8 +244,11 @@ async function uploadToLitterbox(fileBlob: Blob, filename: string): Promise<stri
formData.append("time", settings.store.litterboxExpiry || "24h");
formData.append("fileToUpload", fileBlob, filename);
const proxiedUrl = `${CORS_PROXY}?url=${encodeURIComponent("https://litterbox.catbox.moe/resources/internals/api.php")}`;
const response = await fetch(proxiedUrl, {
const uploadUrl = Native
? "https://litterbox.catbox.moe/resources/internals/api.php"
: `${CORS_PROXY}?url=${encodeURIComponent("https://litterbox.catbox.moe/resources/internals/api.php")}`;
const response = await fetch(uploadUrl, {
method: "POST",
body: formData
});
@ -324,6 +341,9 @@ export async function uploadFile(url: string): Promise<void> {
case ServiceType.EZHOST:
uploadedUrl = await uploadToEzHost(typedBlob, filename);
break;
case ServiceType.S3:
uploadedUrl = await uploadToS3(typedBlob, filename, Native);
break;
case ServiceType.CATBOX:
uploadedUrl = await uploadToCatbox(typedBlob, filename);
break;