diff --git a/packages/frontend/apps/electron/scripts/generate-assets.ts b/packages/frontend/apps/electron/scripts/generate-assets.ts index 436114ea2..f548798cc 100755 --- a/packages/frontend/apps/electron/scripts/generate-assets.ts +++ b/packages/frontend/apps/electron/scripts/generate-assets.ts @@ -71,9 +71,9 @@ if (!process.env.SKIP_WEB_BUILD) { const fullpath = path.join(affineWebOutDir, file); let content = await fs.readFile(fullpath, 'utf-8'); // replace # sourceMappingURL=76-6370cd185962bc89.js.map - // to # sourceMappingURL=/{dir}/76-6370cd185962bc89.js.map + // to # sourceMappingURL=assets://./{dir}/76-6370cd185962bc89.js.map content = content.replace(/# sourceMappingURL=(.*)\.map/g, (_, p1) => { - return `# sourceMappingURL=assets:///${dir}/${p1}.map`; + return `# sourceMappingURL=assets://./${dir}/${p1}.map`; }); try { await fs.writeFile(fullpath, content); diff --git a/packages/frontend/apps/electron/src/main/config.ts b/packages/frontend/apps/electron/src/main/config.ts index e920c5629..14ef8a7c9 100644 --- a/packages/frontend/apps/electron/src/main/config.ts +++ b/packages/frontend/apps/electron/src/main/config.ts @@ -30,7 +30,5 @@ const API_URL_MAPPING = { internal: `https://insider.affine.pro`, }; -export const DEV_SERVER_URL = process.env.DEV_SERVER_URL; - export const CLOUD_BASE_URL = process.env.DEV_SERVER_URL || API_URL_MAPPING[buildType]; diff --git a/packages/frontend/apps/electron/src/main/constants.ts b/packages/frontend/apps/electron/src/main/constants.ts index 56b20db61..734db54f5 100644 --- a/packages/frontend/apps/electron/src/main/constants.ts +++ b/packages/frontend/apps/electron/src/main/constants.ts @@ -1,6 +1,4 @@ -import { CLOUD_BASE_URL } from './config'; - -export const mainWindowOrigin = CLOUD_BASE_URL; +export const mainWindowOrigin = process.env.DEV_SERVER_URL || 'file://.'; export const onboardingViewUrl = `${mainWindowOrigin}${mainWindowOrigin.endsWith('/') ? '' : '/'}onboarding`; export const shellViewUrl = `${mainWindowOrigin}${mainWindowOrigin.endsWith('/') ? '' : '/'}shell.html`; export const customThemeViewUrl = `${mainWindowOrigin}${mainWindowOrigin.endsWith('/') ? '' : '/'}theme-editor`; diff --git a/packages/frontend/apps/electron/src/main/protocol.ts b/packages/frontend/apps/electron/src/main/protocol.ts index 34ee9e866..85101a108 100644 --- a/packages/frontend/apps/electron/src/main/protocol.ts +++ b/packages/frontend/apps/electron/src/main/protocol.ts @@ -2,9 +2,37 @@ import { join } from 'node:path'; import { net, protocol, session } from 'electron'; -import { CLOUD_BASE_URL, DEV_SERVER_URL } from './config'; +import { CLOUD_BASE_URL } from './config'; import { logger } from './logger'; import { isOfflineModeEnabled } from './utils'; +import { getCookies } from './windows-manager'; + +protocol.registerSchemesAsPrivileged([ + { + scheme: 'assets', + privileges: { + secure: false, + corsEnabled: true, + supportFetchAPI: true, + standard: true, + bypassCSP: true, + }, + }, +]); + +protocol.registerSchemesAsPrivileged([ + { + scheme: 'file', + privileges: { + secure: false, + corsEnabled: true, + supportFetchAPI: true, + standard: true, + bypassCSP: true, + stream: true, + }, + }, +]); const NETWORK_REQUESTS = ['/api', '/ws', '/socket.io', '/graphql']; const webStaticDir = join(__dirname, '../resources/web-static'); @@ -13,57 +41,49 @@ function isNetworkResource(pathname: string) { return NETWORK_REQUESTS.some(opt => pathname.startsWith(opt)); } -async function fetchLocalResource(request: Request) { - const url = new URL(request.url); - const pathname = url.pathname; - // this will be file types (in the web-static folder) - let filepath = ''; - // if is a file type, load the file in resources - if (pathname.split('/').at(-1)?.includes('.')) { - filepath = join(webStaticDir, decodeURIComponent(pathname)); - } else { - // else, fallback to load the index.html instead - filepath = join(webStaticDir, 'index.html'); - } - return net.fetch('file://' + filepath, request); -} - -async function handleHttpRequest(request: Request) { - const url = new URL(request.url); - const pathname = url.pathname; - const sameSite = url.host === new URL(CLOUD_BASE_URL).host; - - const isStaticResource = sameSite && !isNetworkResource(pathname); - if (isStaticResource) { - return fetchLocalResource(request); - } - return net.fetch(request, { +async function handleFileRequest(request: Request) { + const clonedRequest = Object.assign(request.clone(), { bypassCustomProtocolHandlers: true, }); -} - -// mainly for loading sourcemap -// seems handle for http/https does not work for sourcemaps -async function handleAssetRequest(request: Request) { - return fetchLocalResource(request); + const urlObject = new URL(request.url); + if (isNetworkResource(urlObject.pathname)) { + // just pass through (proxy) + return net.fetch( + CLOUD_BASE_URL + urlObject.pathname + urlObject.search, + clonedRequest + ); + } else { + // this will be file types (in the web-static folder) + let filepath = ''; + // if is a file type, load the file in resources + if (urlObject.pathname.split('/').at(-1)?.includes('.')) { + // Sanitize pathname to prevent path traversal attacks + const decodedPath = decodeURIComponent(urlObject.pathname); + const normalizedPath = join(webStaticDir, decodedPath).normalize(); + if (!normalizedPath.startsWith(webStaticDir)) { + // Attempted path traversal - reject by using empty path + filepath = join(webStaticDir, ''); + } else { + filepath = normalizedPath; + } + } else { + // else, fallback to load the index.html instead + filepath = join(webStaticDir, 'index.html'); + } + return net.fetch('file://' + filepath, clonedRequest); + } } export function registerProtocol() { - const isSecure = CLOUD_BASE_URL.startsWith('https://'); + protocol.handle('file', request => { + return handleFileRequest(request); + }); - // do not proxy request when DEV_SERVER_URL is set (for local dev) - if (!DEV_SERVER_URL) { - protocol.handle(isSecure ? 'https' : 'http', request => { - return handleHttpRequest(request); - }); + protocol.handle('assets', request => { + return handleFileRequest(request); + }); - protocol.handle('assets', request => { - return handleAssetRequest(request); - }); - } - - // hack for CORS - // todo: should use a whitelist + // todo(@pengx17): remove this session.defaultSession.webRequest.onHeadersReceived( (responseDetails, callback) => { const { responseHeaders } = responseDetails; @@ -97,15 +117,21 @@ export function registerProtocol() { const protocol = url.protocol; const origin = url.origin; + const sameSite = + url.host === new URL(CLOUD_BASE_URL).host || protocol === 'file:'; + // offline whitelist - // 1. do not block non-api request for DEV_SERVER_URL + // 1. do not block non-api request for http://localhost || file:// (local dev assets) // 2. do not block devtools // 3. block all other requests const blocked = (() => { if (!isOfflineModeEnabled()) { return false; } - if (origin === DEV_SERVER_URL && !isNetworkResource(pathname)) { + if ( + (protocol === 'file:' || origin.startsWith('http://localhost')) && + !isNetworkResource(pathname) + ) { return false; } if ('devtools:' === protocol) { @@ -122,6 +148,19 @@ export function registerProtocol() { return; } + // session cookies are set to file:// on production + // if sending request to the cloud, attach the session cookie (to affine cloud server) + if (isNetworkResource(pathname) && sameSite) { + const cookie = getCookies(); + if (cookie) { + const cookieString = cookie.map(c => `${c.name}=${c.value}`).join('; '); + details.requestHeaders['cookie'] = cookieString; + } + + // add the referer and origin headers + details.requestHeaders['referer'] ??= CLOUD_BASE_URL; + details.requestHeaders['origin'] ??= CLOUD_BASE_URL; + } callback({ cancel: false, requestHeaders: details.requestHeaders, diff --git a/packages/frontend/apps/electron/src/main/security-restrictions.ts b/packages/frontend/apps/electron/src/main/security-restrictions.ts index b63f43be2..c851c7e5b 100644 --- a/packages/frontend/apps/electron/src/main/security-restrictions.ts +++ b/packages/frontend/apps/electron/src/main/security-restrictions.ts @@ -3,7 +3,10 @@ import { app, shell } from 'electron'; app.on('web-contents-created', (_, contents) => { const isInternalUrl = (url: string) => { return ( - process.env.DEV_SERVER_URL && url.startsWith(process.env.DEV_SERVER_URL) + (process.env.DEV_SERVER_URL && + url.startsWith(process.env.DEV_SERVER_URL)) || + url.startsWith('affine://') || + url.startsWith('file://.') ); }; /** diff --git a/packages/frontend/apps/electron/src/main/windows-manager/tab-views.ts b/packages/frontend/apps/electron/src/main/windows-manager/tab-views.ts index cfb94869c..a5de57854 100644 --- a/packages/frontend/apps/electron/src/main/windows-manager/tab-views.ts +++ b/packages/frontend/apps/electron/src/main/windows-manager/tab-views.ts @@ -842,6 +842,10 @@ export class WebContentViewsManager { }; } +export function getCookies() { + return WebContentViewsManager.instance.cookies; +} + // there is no proper way to listen to webContents resize event // we will rely on window.resize event in renderer instead export async function handleWebContentsResize(webContents?: WebContents) {