fix(guild): import all template channel types, fetch on desktop (#3473)

This commit is contained in:
Hampus authored and GitHub committed 2026-10-14 18:39:09 +02:00
1 parent 00249f9b4d
commit 99be186fa1
50 files changed
+2008 -111

No files matched your search

+2
View File
@@ -59,6 +59,7 @@ import type {DesktopCapabilityManifest} from '@fluxer/desktop_ipc/src/Capability
import type {NativeGatewayTransportAPI} from '@fluxer/desktop_ipc/src/GatewayTransportContract';
import type {DesktopKnownInstanceStorageAPI} from '@fluxer/desktop_ipc/src/KnownInstanceContract';
import type {DesktopModuleAPI, DesktopUpdateAPI} from '@fluxer/desktop_ipc/src/ModuleContract';
import type {OtherPlatformTemplateAPI} from '@fluxer/desktop_ipc/src/OtherPlatformTemplateContract';
import type {DesktopStorageAPI} from '@fluxer/desktop_ipc/src/StorageContract';
import type {VoiceEngineV2BridgeHardwareEncoderApi} from '@fluxer/voice_engine_v2/bridge';
import type {
@@ -384,6 +385,7 @@ export interface ElectronAPI {
desktopKnownInstances: DesktopKnownInstanceStorageAPI;
desktopHandoff: DesktopHandoffAPI;
nativeGatewayTransport: NativeGatewayTransportAPI;
otherPlatformTemplate: OtherPlatformTemplateAPI;
}
export interface GlobalShortcutsPortalStatus {
+6
View File
@@ -49,6 +49,10 @@ import {
} from '@electron/main/NativeGatewayTransport';
import {registerNotificationIpcHandlers} from '@electron/main/NotificationsIpc';
import {openExternalDeduped} from '@electron/main/OpenExternal';
import {
cleanupOtherPlatformTemplateHandlers,
registerOtherPlatformTemplateHandlers,
} from '@electron/main/OtherPlatformTemplateIpc';
import {registerPasskeyHandlers} from '@electron/main/Passkeys';
import {getAppMetricsSnapshot, getDesktopInfo, getGpuInfo} from '@electron/main/PlatformInfo';
import {
@@ -487,6 +491,7 @@ export function registerIpcHandlers(): void {
registerPasskeyHandlers();
registerLinuxAppearanceHandlers();
registerBrowserHandoffHandlers();
registerOtherPlatformTemplateHandlers();
}
let linuxAppearanceSubscription: LinuxAppearanceSubscription | null = null;
@@ -570,6 +575,7 @@ export function cleanupIpcHandlers(_options: {quitting?: boolean} = {}): void {
browserHandoff?.cleanup();
browserHandoff = null;
setHandoffReturnLinkSink(null);
cleanupOtherPlatformTemplateHandlers();
if (linuxAppearanceSubscription) {
try {
linuxAppearanceSubscription.close();
@@ -0,0 +1,206 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import assert from 'node:assert/strict';
import {afterEach, describe, test} from 'node:test';
import {installElectronStub, installWindowStub} from './LocalAppTestSupport.test.mjs';
installElectronStub();
installWindowStub(null);
const {DESKTOP_OTHER_PLATFORM_TEMPLATE_CHANNELS, OtherPlatformTemplateFetchFailure, isOtherPlatformTemplateCode} =
await import('../../../packages/desktop_ipc/src/OtherPlatformTemplateContract.ts');
const {
MAX_TEMPLATE_RESPONSE_BYTES,
cleanupOtherPlatformTemplateHandlers,
fetchOtherPlatformTemplate,
registerOtherPlatformTemplateHandlers,
} = await import('./OtherPlatformTemplateIpc.ts');
const {createOtherPlatformTemplatePreloadAPI} = await import('../preload/OtherPlatformTemplatePreload.ts');
const TEMPLATE_BODY = {code: 'hgM48av5Q69A', name: 'Starter', serialized_source_guild: {name: 'Starter'}};
function installFetch(respond) {
const calls = [];
const handlers = new Map();
installElectronStub({
net: {
fetch: async (url, init) => {
calls.push({url, init});
return respond(url, init);
},
},
ipcMain: {
handle: (channel, listener) => handlers.set(channel, listener),
removeHandler: (channel) => handlers.delete(channel),
},
});
return {calls, handlers};
}
function jsonResponse(body, init = {}) {
return new Response(JSON.stringify(body), {
status: 200,
headers: {'content-type': 'application/json'},
...init,
});
}
function topLevelSender() {
const frame = {detached: false, parent: null, url: 'fluxer-app://app/'};
return {sender: {}, senderFrame: frame};
}
function subframeSender() {
const parent = {detached: false, parent: null, url: 'fluxer-app://app/'};
return {sender: {}, senderFrame: {detached: false, parent, url: 'fluxer-app://app/'}};
}
afterEach(() => {
cleanupOtherPlatformTemplateHandlers();
});
describe('template code validation', () => {
test('accepts alphanumeric codes and rejects anything that could reshape the URL', () => {
assert.equal(isOtherPlatformTemplateCode('hgM48av5Q69A'), true);
assert.equal(isOtherPlatformTemplateCode('a'.repeat(64)), true);
for (const value of [
'',
'a'.repeat(65),
'abc/def',
'../users/@me',
'abc?x=1',
'abc#frag',
'abc def',
'abc%2F',
'https://discord.new/abc',
42,
null,
undefined,
]) {
assert.equal(isOtherPlatformTemplateCode(value), false, String(value));
}
});
test('rejects an invalid code before any request is made', async () => {
const {calls} = installFetch(() => jsonResponse(TEMPLATE_BODY));
for (const code of ['abc/../x', '', 'a'.repeat(65), {code: 'abc'}]) {
const result = await fetchOtherPlatformTemplate(code);
assert.deepEqual(result, {
ok: false,
failure: OtherPlatformTemplateFetchFailure.INVALID_CODE,
});
}
assert.equal(calls.length, 0);
});
});
describe('template fetch', () => {
test('requests only the fixed template endpoint with a generic browser user agent and no credentials', async () => {
const {calls} = installFetch(() => jsonResponse(TEMPLATE_BODY));
const result = await fetchOtherPlatformTemplate('hgM48av5Q69A');
assert.deepEqual(result, {ok: true, template: TEMPLATE_BODY});
assert.equal(calls.length, 1);
const [{url, init}] = calls;
assert.equal(url, 'https://discord.com/api/v10/guilds/templates/hgM48av5Q69A');
assert.equal(init.method, 'GET');
assert.equal(init.redirect, 'error');
assert.equal(init.credentials, 'omit');
const userAgent = init.headers['User-Agent'];
assert.match(userAgent, /^Mozilla\/5\.0 \(/);
assert.doesNotMatch(userAgent, /fluxer|electron/i);
assert.equal(init.headers.Accept, 'application/json');
assert.ok(init.signal instanceof AbortSignal);
});
test('maps a 404 to not found', async () => {
installFetch(() => jsonResponse({message: 'Unknown Guild Template', code: 10057}, {status: 404}));
assert.deepEqual(await fetchOtherPlatformTemplate('missing'), {
ok: false,
failure: OtherPlatformTemplateFetchFailure.NOT_FOUND,
});
});
test('maps a 429 to rate limited', async () => {
installFetch(() =>
jsonResponse({message: 'You are being rate limited.'}, {status: 429, headers: {'retry-after': '2.4'}}),
);
assert.deepEqual(await fetchOtherPlatformTemplate('busy'), {
ok: false,
failure: OtherPlatformTemplateFetchFailure.RATE_LIMITED,
});
});
test('maps server errors and transport failures to network', async () => {
installFetch(() => new Response('oops', {status: 503}));
assert.equal((await fetchOtherPlatformTemplate('abc')).failure, OtherPlatformTemplateFetchFailure.NETWORK);
installFetch(() => {
throw new TypeError('net::ERR_INTERNET_DISCONNECTED');
});
assert.equal((await fetchOtherPlatformTemplate('abc')).failure, OtherPlatformTemplateFetchFailure.NETWORK);
installFetch(() => {
throw new TypeError('redirect mode is set to error');
});
assert.equal((await fetchOtherPlatformTemplate('abc')).failure, OtherPlatformTemplateFetchFailure.NETWORK);
});
test('maps unreadable, non-object and oversized bodies to invalid response', async () => {
installFetch(() => new Response('<html>not json</html>', {status: 200}));
assert.equal((await fetchOtherPlatformTemplate('abc')).failure, OtherPlatformTemplateFetchFailure.INVALID_RESPONSE);
installFetch(() => jsonResponse([TEMPLATE_BODY]));
assert.equal((await fetchOtherPlatformTemplate('abc')).failure, OtherPlatformTemplateFetchFailure.INVALID_RESPONSE);
installFetch(
() => new Response('x', {status: 200, headers: {'content-length': String(MAX_TEMPLATE_RESPONSE_BYTES + 1)}}),
);
assert.equal((await fetchOtherPlatformTemplate('abc')).failure, OtherPlatformTemplateFetchFailure.INVALID_RESPONSE);
const chunk = new Uint8Array(1024 * 1024).fill(0x20);
let sent = 0;
const stream = new ReadableStream({
pull(controller) {
if (sent > MAX_TEMPLATE_RESPONSE_BYTES) {
controller.close();
return;
}
sent += chunk.byteLength;
controller.enqueue(chunk);
},
});
installFetch(() => new Response(stream, {status: 200}));
assert.equal((await fetchOtherPlatformTemplate('abc')).failure, OtherPlatformTemplateFetchFailure.INVALID_RESPONSE);
assert.ok(sent <= MAX_TEMPLATE_RESPONSE_BYTES + chunk.byteLength);
});
});
describe('template IPC', () => {
test('the preload call reaches the registered handler for a trusted top-level document', async () => {
const {handlers} = installFetch(() => jsonResponse(TEMPLATE_BODY));
registerOtherPlatformTemplateHandlers();
const handler = handlers.get(DESKTOP_OTHER_PLATFORM_TEMPLATE_CHANNELS.fetch);
assert.equal(typeof handler, 'function');
const api = createOtherPlatformTemplatePreloadAPI({
invoke: (channel, ...args) => handlers.get(channel)(topLevelSender(), ...args),
});
assert.deepEqual(await api.otherPlatformTemplate.fetch('hgM48av5Q69A'), {ok: true, template: TEMPLATE_BODY});
});
test('refuses subframes and detached frames without fetching', async () => {
const {calls, handlers} = installFetch(() => jsonResponse(TEMPLATE_BODY));
registerOtherPlatformTemplateHandlers();
const handler = handlers.get(DESKTOP_OTHER_PLATFORM_TEMPLATE_CHANNELS.fetch);
assert.throws(() => handler(subframeSender(), 'hgM48av5Q69A'), /trusted top-level renderer document/);
const detached = topLevelSender();
detached.senderFrame.detached = true;
assert.throws(() => handler(detached, 'hgM48av5Q69A'), /trusted top-level renderer document/);
assert.throws(
() => handler({sender: {}, senderFrame: null}, 'hgM48av5Q69A'),
/trusted top-level renderer document/,
);
assert.equal(calls.length, 0);
});
test('cleanup removes the handler', () => {
const {handlers} = installFetch(() => jsonResponse(TEMPLATE_BODY));
registerOtherPlatformTemplateHandlers();
cleanupOtherPlatformTemplateHandlers();
assert.equal(handlers.has(DESKTOP_OTHER_PLATFORM_TEMPLATE_CHANNELS.fetch), false);
});
});
@@ -0,0 +1,145 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {createChildLogger} from '@electron/common/Logger';
import {requirePrivilegedRendererDocumentSender} from '@electron/main/PrivilegedRendererDocuments';
import {
DESKTOP_OTHER_PLATFORM_TEMPLATE_CHANNELS,
isOtherPlatformTemplateCode,
type OtherPlatformTemplateFetchError,
OtherPlatformTemplateFetchFailure,
type OtherPlatformTemplateFetchResult,
} from '@fluxer/desktop_ipc/src/OtherPlatformTemplateContract';
import {isRecord} from '@fluxer/record_utils/src/RecordGuards';
import {ipcMain, net} from 'electron';
const logger = createChildLogger('OtherPlatformTemplate');
const TEMPLATE_ENDPOINT_ORIGIN = 'https://discord.com';
const TEMPLATE_ENDPOINT_PATH = '/api/v10/guilds/templates/';
const FETCH_TIMEOUT_MS = 15_000;
export const MAX_TEMPLATE_RESPONSE_BYTES = 4 * 1024 * 1024;
let handlersRegistered = false;
class ResponseTooLargeError extends Error {
public constructor() {
super('Template response exceeded the size cap');
this.name = 'ResponseTooLargeError';
}
}
function browserPlatformToken(): string {
switch (process.platform) {
case 'darwin':
return 'Macintosh; Intel Mac OS X 10_15_7';
case 'win32':
return 'Windows NT 10.0; Win64; x64';
default:
return 'X11; Linux x86_64';
}
}
export function genericBrowserUserAgent(): string {
const chromeMajor = Number.parseInt(process.versions.chrome ?? '', 10);
const version = Number.isFinite(chromeMajor) && chromeMajor > 0 ? chromeMajor : 140;
return `Mozilla/5.0 (${browserPlatformToken()}) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/${version}.0.0.0 Safari/537.36`;
}
export function otherPlatformTemplateUrl(code: string): string {
return `${TEMPLATE_ENDPOINT_ORIGIN}${TEMPLATE_ENDPOINT_PATH}${code}`;
}
function failure(kind: OtherPlatformTemplateFetchError['failure']) {
return {ok: false, failure: kind} satisfies OtherPlatformTemplateFetchError;
}
async function readBoundedText(response: Response): Promise<string> {
const declaredLength = Number.parseInt(response.headers.get('content-length') ?? '', 10);
if (Number.isFinite(declaredLength) && declaredLength > MAX_TEMPLATE_RESPONSE_BYTES) {
throw new ResponseTooLargeError();
}
if (response.body === null) return '';
const reader = response.body.getReader();
const chunks: Array<Uint8Array> = [];
let received = 0;
try {
for (;;) {
const {done, value} = await reader.read();
if (done) break;
received += value.byteLength;
if (received > MAX_TEMPLATE_RESPONSE_BYTES) {
throw new ResponseTooLargeError();
}
chunks.push(value);
}
} finally {
reader.cancel().catch(() => undefined);
}
return Buffer.concat(chunks).toString('utf8');
}
export async function fetchOtherPlatformTemplate(code: unknown): Promise<OtherPlatformTemplateFetchResult> {
if (!isOtherPlatformTemplateCode(code)) {
return failure(OtherPlatformTemplateFetchFailure.INVALID_CODE);
}
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
try {
const response = await net.fetch(otherPlatformTemplateUrl(code), {
method: 'GET',
headers: {
Accept: 'application/json',
'User-Agent': genericBrowserUserAgent(),
},
cache: 'no-store',
credentials: 'omit',
redirect: 'error',
signal: controller.signal,
bypassCustomProtocolHandlers: true,
});
if (response.status === 404) {
return failure(OtherPlatformTemplateFetchFailure.NOT_FOUND);
}
if (response.status === 429) {
return failure(OtherPlatformTemplateFetchFailure.RATE_LIMITED);
}
if (!response.ok) {
logger.warn('Template endpoint answered with an unexpected status', {status: response.status});
return failure(OtherPlatformTemplateFetchFailure.NETWORK);
}
let template: unknown;
try {
template = JSON.parse(await readBoundedText(response));
} catch (error) {
if (controller.signal.aborted) {
throw error;
}
logger.warn('Template endpoint returned an unreadable body', error);
return failure(OtherPlatformTemplateFetchFailure.INVALID_RESPONSE);
}
if (!isRecord(template)) {
return failure(OtherPlatformTemplateFetchFailure.INVALID_RESPONSE);
}
return {ok: true, template};
} catch (error) {
logger.warn('Template fetch failed', error);
return failure(OtherPlatformTemplateFetchFailure.NETWORK);
} finally {
clearTimeout(timeout);
}
}
export function registerOtherPlatformTemplateHandlers(): void {
if (handlersRegistered) return;
handlersRegistered = true;
ipcMain.handle(DESKTOP_OTHER_PLATFORM_TEMPLATE_CHANNELS.fetch, (event, code: unknown) => {
requirePrivilegedRendererDocumentSender(event, DESKTOP_OTHER_PLATFORM_TEMPLATE_CHANNELS.fetch);
return fetchOtherPlatformTemplate(code);
});
}
export function cleanupOtherPlatformTemplateHandlers(): void {
if (!handlersRegistered) return;
handlersRegistered = false;
ipcMain.removeHandler(DESKTOP_OTHER_PLATFORM_TEMPLATE_CHANNELS.fetch);
}
@@ -0,0 +1,29 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import type {
OtherPlatformTemplateAPI,
OtherPlatformTemplateFetchResult,
} from '@fluxer/desktop_ipc/src/OtherPlatformTemplateContract';
import {DESKTOP_OTHER_PLATFORM_TEMPLATE_CHANNELS} from '@fluxer/desktop_ipc/src/OtherPlatformTemplateContract';
interface OtherPlatformTemplateInvoker {
invoke: (channel: string, ...args: Array<unknown>) => Promise<unknown>;
}
interface OtherPlatformTemplatePreloadAPI {
readonly otherPlatformTemplate: OtherPlatformTemplateAPI;
}
export function createOtherPlatformTemplatePreloadAPI(
renderer: OtherPlatformTemplateInvoker,
): OtherPlatformTemplatePreloadAPI {
return Object.freeze({
otherPlatformTemplate: Object.freeze<OtherPlatformTemplateAPI>({
fetch: (code: string) =>
renderer.invoke(
DESKTOP_OTHER_PLATFORM_TEMPLATE_CHANNELS.fetch,
code,
) as Promise<OtherPlatformTemplateFetchResult>,
}),
});
}
+2
View File
@@ -65,6 +65,7 @@ import {createFirstContentPaintSignal} from '@electron/preload/FirstContentPaint
import {applyLegacyRawLocalStorage} from '@electron/preload/LegacyRawStorageSeed';
import {createLocalAppPreloadAPI} from '@electron/preload/LocalAppPreload';
import {createNativeGatewayPreloadAPI} from '@electron/preload/NativeGatewayPreload';
import {createOtherPlatformTemplatePreloadAPI} from '@electron/preload/OtherPlatformTemplatePreload';
import type {DesktopCapabilityManifest} from '@fluxer/desktop_ipc/src/CapabilityManifest';
import {DESKTOP_CAPABILITY_MANIFEST_CHANNEL} from '@fluxer/desktop_ipc/src/CapabilityManifest';
import {DESKTOP_LAST_ROUTE_CHANNEL} from '@fluxer/desktop_ipc/src/LastRouteContract';
@@ -403,6 +404,7 @@ const api: ElectronAPI = {
...createLocalAppPreloadAPI(ipcRenderer),
...createBrowserHandoffPreloadAPI(ipcRenderer),
...createNativeGatewayPreloadAPI(ipcRenderer),
...createOtherPlatformTemplatePreloadAPI(ipcRenderer),
reportLastRoute: (routePath: string): void => {
ipcRenderer.send(DESKTOP_LAST_ROUTE_CHANNEL, routePath);
},