Fixing <#1513197912660906035> on my own plugin. (#1163)

* Fixing <#1513197912660906035> on my own plugin.

* #1163: Update src/equicordplugins/clipUpload.desktop/ffmpeg.ts

Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>

* #1163: Update src/equicordplugins/clipUpload.desktop/upload.ts

Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>

* #1163: Update src/equicordplugins/clipUpload.desktop/upload.ts

* chore(clipUpload): document unavoidable path exposure

Added documentation to  in the main process and  in the renderer. This clarifies that exposing the absolute filesystem path is necessary because Discord's  is a renderer-only module and requires an absolute path to function.

Co-authored-by: Gemini <198982749+gemini-code-assist@users.noreply.github.com>

* Conflict Fix

* [autofix.ci] apply automated fixes

* Fix Missing upper-bound size check on renderer-supplied bytes

---------

Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
Co-authored-by: Gemini <198982749+gemini-code-assist@users.noreply.github.com>
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
This commit is contained in:
Quadrinex 2026-06-10 13:23:32 +02:00 • committed by GitHub
parent b12df9aa07
commit 4d593cf710
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 259 additions and 44 deletions

File diff suppressed because one or more lines are too long

View file

@ -28,6 +28,7 @@ const pendingTokens = new Map<string, string>();
const tempEntries = new Map<string, TempEntry>();
const CLIP_UPLOAD_DIR = join(DATA_DIR, "clipUpload");
const ALLOWED_EXTENSIONS = new Set([".mp4", ".m4v"]);
const MAX_CLIP_SIZE = 500 * 1024 * 1024; // 500MB
const MIME_TYPES: Record<string, string> = {
".mp4": "video/mp4",
".m4v": "video/mp4",
@ -109,6 +110,36 @@ export async function createTempVideoFile(_: IpcMainInvokeEvent, token: string):
}
}
export async function createTempVideoFileFromBytes(_: IpcMainInvokeEvent, name: string, data: Uint8Array): Promise<string | null> {
if (typeof name !== "string" || !(data instanceof Uint8Array) || data.byteLength === 0 || data.byteLength > MAX_CLIP_SIZE) return null;
const fileName = basename(name);
if (fileName !== name || !ALLOWED_EXTENSIONS.has(extname(fileName).toLowerCase())) return null;
try {
const tmpDir = join(CLIP_UPLOAD_DIR, randomUUID());
const tmpPath = join(tmpDir, fileName);
if (!ensureSafePath(tmpDir, fileName)) return null;
await mkdir(tmpDir, { recursive: true });
await writeFile(tmpPath, data);
const tmpToken = randomUUID();
tempEntries.set(tmpToken, { tmpDir, tmpPath });
return tmpToken;
} catch {
return null;
}
}
// Note: Exposing the absolute path to the renderer is unavoidable here.
// Discord's MediaEngineStore is a renderer-only module, and its
// updateClipMetadata method requires an absolute filesystem path.
export function getTempVideoFilePath(_: IpcMainInvokeEvent, token: string): string | null {
return tempEntries.get(token)?.tmpPath ?? null;
}
export async function readVideoFile(_: IpcMainInvokeEvent, token: string): Promise<Uint8Array | null> {
const entry = tempEntries.get(token);
if (!entry) return null;

View file

@ -4,10 +4,14 @@
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import { Logger } from "@utils/Logger";
import { isObject } from "@utils/misc";
import type { PluginNative } from "@utils/types";
import { User } from "@vencord/discord-types";
import { Constants, RestAPI, showToast, SnowflakeUtils, Toasts } from "@webpack/common";
import type { User } from "@vencord/discord-types";
import { Constants, MediaEngineStore, RestAPI, showToast, SnowflakeUtils, Toasts } from "@webpack/common";
import { convertClipToMp4 } from "./ffmpeg";
const logger = new Logger("ClipUpload");
const Native = VencordNative.pluginHelpers.ClipUpload as PluginNative<typeof import("./native")>;
@ -21,7 +25,7 @@ export interface ClipMetadata {
createdAt?: number | string | Date;
users?: User[];
remoteClipId?: string | number;
eventsTimeline?: any;
eventsTimeline?: unknown;
}
export interface ClipUploadOptions {
@ -52,6 +56,10 @@ interface RestResponse {
text?: string;
}
interface ClipMetadataUpdater {
updateClipMetadata(clipId: string, metadata: string): Promise<void>;
}
export function getString(value: unknown) {
return typeof value === "string" ? value : undefined;
}
@ -107,11 +115,19 @@ function getFileExtension(fileName: string) {
return fileName.match(/\.[a-z0-9]+$/i)?.[0].toLowerCase() ?? ".mp4";
}
function getTempVideoFileName(fileName: string) {
if (compatibleExtensions.includes(getFileExtension(fileName))) return fileName;
return `${getClipTitleFromName(fileName) || defaultFileName}.mp4`;
}
async function readStampedVideoFile(token: string, name: string, type: string) {
const tmpToken = await Native.createTempVideoFile(token);
if (!tmpToken) throw new Error("Couldn't prepare the selected file.");
try {
await updateTempVideoMetadata(tmpToken);
const data = await Native.readVideoFile(tmpToken);
if (!data) throw new Error("Couldn't read the selected file.");
@ -121,6 +137,38 @@ async function readStampedVideoFile(token: string, name: string, type: string) {
}
}
async function updateTempVideoMetadata(tmpToken: string) {
// We need the absolute path here because Discord's MediaEngineStore requires it
// and it is a renderer-only module, so this cannot be done in the main process.
const tmpPath = await Native.getTempVideoFilePath(tmpToken);
if (!tmpPath) throw new Error("Couldn't prepare the selected file.");
const mediaEngine = MediaEngineStore.getMediaEngine();
if (!isClipMetadataUpdater(mediaEngine)) throw new Error("Couldn't update clip metadata.");
await mediaEngine.updateClipMetadata(tmpPath, "{}");
}
async function stampVideoFile(file: File, fileName: string) {
const tmpToken = await Native.createTempVideoFileFromBytes(getTempVideoFileName(fileName), new Uint8Array(await file.arrayBuffer()));
if (!tmpToken) throw new Error("Couldn't prepare the selected file.");
try {
await updateTempVideoMetadata(tmpToken);
const data = await Native.readVideoFile(tmpToken);
if (!data) throw new Error("Couldn't read the selected file.");
return new File([new Uint8Array(data)], fileName, { type: "video/mp4" });
} finally {
await Native.deleteTempVideoFile(tmpToken);
}
}
function isClipMetadataUpdater(value: unknown): value is ClipMetadataUpdater {
return isObject(value) && typeof (value as { updateClipMetadata?: unknown; }).updateClipMetadata === "function";
}
export interface ParsedClipMetadata {
id?: string;
applicationId?: string;
@ -187,19 +235,11 @@ async function reserveClipUpload(options: ClipUploadOptions, file: File) {
return parseAttachmentUploadResponse(response).attachments?.[0];
}
export async function uploadClipFile(file: File, options: ClipUploadOptions) {
let uploadAbortController: AbortController | undefined;
async function uploadReservedClip(attachment: NonNullable<Awaited<ReturnType<typeof reserveClipUpload>>>, uploadFile: File) {
const uploadAbortController = new AbortController();
uploadAbortControllers.add(uploadAbortController);
try {
showToast("Checking clip file.", Toasts.Type.MESSAGE);
const uploadFile = await prepareClipFile(file, options.fileName);
const attachment = await reserveClipUpload(options, uploadFile);
if (!attachment) throw new Error("Discord did not return an upload slot.");
uploadAbortController = new AbortController();
uploadAbortControllers.add(uploadAbortController);
const uploadResponse = await fetch(attachment.upload_url, {
method: "PUT",
body: uploadFile,
@ -211,48 +251,101 @@ export async function uploadClipFile(file: File, options: ClipUploadOptions) {
});
if (!uploadResponse.ok) throw new Error("Upload failed.");
} finally {
uploadAbortControllers.delete(uploadAbortController);
}
}
const messageResponse = await RestAPI.post({
url: Constants.Endpoints.MESSAGES(options.channelId),
body: {
content: options.message,
nonce: SnowflakeUtils.fromTimestamp(Date.now()),
channel_id: options.channelId,
sticker_ids: [],
type: 0,
attachments: [{
id: "0",
filesize: uploadFile.size,
filename: options.fileName,
uploaded_filename: attachment.upload_filename,
is_clip: true,
is_spoiler: options.spoiler,
is_remix: options.remix,
is_thumbnail: options.thumbnail,
title: options.title,
application_id: options.applicationId,
clip_created_at: options.createdAt,
clip_participant_ids: options.participants,
clip_remote_id: options.remoteClipId,
clip_events_timeline: options.eventsTimeline
}]
}
}) as RestResponse;
async function sendClipUpload(uploadFile: File, options: ClipUploadOptions) {
const attachment = await reserveClipUpload(options, uploadFile);
if (!attachment) throw new Error("Discord did not return an upload slot.");
if (messageResponse.ok === false) throw messageResponse;
await uploadReservedClip(attachment, uploadFile);
const messageResponse = await RestAPI.post({
url: Constants.Endpoints.MESSAGES(options.channelId),
body: {
content: options.message,
nonce: SnowflakeUtils.fromTimestamp(Date.now()),
channel_id: options.channelId,
sticker_ids: [],
type: 0,
attachments: [{
id: "0",
filesize: uploadFile.size,
filename: options.fileName,
uploaded_filename: attachment.upload_filename,
is_clip: true,
is_spoiler: options.spoiler,
is_remix: options.remix,
is_thumbnail: options.thumbnail,
title: options.title,
application_id: options.applicationId,
clip_created_at: options.createdAt,
clip_participant_ids: options.participants,
clip_remote_id: options.remoteClipId,
clip_events_timeline: options.eventsTimeline
}]
}
}) as RestResponse;
if (messageResponse.ok === false) throw messageResponse;
}
function getSearchableErrorText(error: unknown): string {
if (error instanceof Error) return error.message;
if (typeof error === "string") return error;
if (!isObject(error)) return "";
const { body, message, text } = error as { body?: unknown; message?: unknown; text?: unknown; };
return [
typeof message === "string" ? message : "",
typeof text === "string" ? text : "",
typeof body === "string" ? body : "",
isObject(body) ? JSON.stringify(body) : ""
].join("\n");
}
function getRestErrorCode(error: unknown) {
if (!isObject(error)) return undefined;
const { body } = error as { body?: unknown; };
if (!isObject(body)) return undefined;
const { code } = body as Record<string, unknown>;
return typeof code === "number" ? code : undefined;
}
function shouldRetryWithFFmpeg(error: unknown) {
const errorText = getSearchableErrorText(error).toLowerCase();
return getRestErrorCode(error) === 50174 || errorText.includes("not a valid clip");
}
export async function uploadClipFile(file: File, options: ClipUploadOptions) {
try {
showToast("Checking clip file.", Toasts.Type.MESSAGE);
const uploadFile = await prepareClipFile(file, options.fileName);
try {
await sendClipUpload(uploadFile, options);
} catch (error) {
if (!shouldRetryWithFFmpeg(error)) throw error;
showToast("Converting clip file.", Toasts.Type.MESSAGE);
await sendClipUpload(await stampVideoFile(await convertClipToMp4(file, options.fileName), options.fileName), options);
}
showToast("Clip uploaded.", Toasts.Type.SUCCESS);
return true;
} catch (error) {
logger.error(error);
showToast(getErrorMessage(error), Toasts.Type.FAILURE);
return false;
} finally {
if (uploadAbortController) uploadAbortControllers.delete(uploadAbortController);
}
}
export function getErrorMessage(error: unknown): string {
console.error(error);
if (error instanceof DOMException && error.name === "AbortError") return "Upload canceled.";
if (error instanceof Error) return error.message;
return "Failed to upload clip.";