Skip to content

Commit

Permalink
make built-in web client work with "onion" api endpoint
Browse files Browse the repository at this point in the history
  • Loading branch information
vladimiry committed Dec 5, 2018
1 parent 2209a15 commit 5bbdb3d
Show file tree
Hide file tree
Showing 6 changed files with 259 additions and 73 deletions.
1 change: 1 addition & 0 deletions src/electron-main/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ if (!app.requestSingleInstanceLock()) {
// needed for desktop notifications properly working on Win 10, details https://www.electron.build/configuration/nsis
app.setAppUserModelId(`com.github.vladimiry.${APP_NAME}`);

// TODO consider sharing "Context" using dependency injection approach
const ctx = initContext();

app.on("ready", async () => {
Expand Down
1 change: 1 addition & 0 deletions src/electron-main/util.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,7 @@ function initLocations(

return items.map(({result}) => result);
})(),
tutanota: [],
};

return {
Expand Down
284 changes: 228 additions & 56 deletions src/electron-main/web-request.ts
Original file line number Diff line number Diff line change
@@ -1,75 +1,258 @@
import {URL} from "url";

import {BuildEnvironment} from "src/shared/model/common";
import {AccountType} from "src/shared/model/account";
import {Context} from "./model";
import {ElectronContextLocations} from "src/shared/model/electron";
import {getDefaultSession} from "./session";

const headerNames = {
origin: "origin",
accessControlAllowOrigin: "access-control-allow-origin",
interface Details {
id: number;
url: string;
method: string;
resourceType?: string;
}

type RequestDetails = Details & { requestHeaders: HeadersMap };

type ResponseDetails = Details & { responseHeaders: HeadersMap<string[]> };

interface HeadersMap<V extends string | string[] = string | string[]> {
[k: string]: V;
}

interface RequestProxy {
accountType: AccountType;
headers: {
origin: Exclude<ReturnType<typeof getHeader>, null>,
accessControlRequestHeaders: ReturnType<typeof getHeader>,
accessControlRequestMethod: ReturnType<typeof getHeader>,
};
}

const HEADERS = {
request: {
origin: "Origin",
accessControlRequestHeaders: "Access-Control-Request-Headers",
accessControlRequestMethod: "Access-Control-Request-Method",
},
response: {
accessControlAllowCredentials: "Access-Control-Allow-Credentials",
accessControlAllowHeaders: "Access-Control-Allow-Headers",
accessControlAllowMethods: "Access-Control-Allow-Methods",
accessControlAllowOrigin: "Access-Control-Allow-Origin",
accessControlExposeHeaders: "Access-Control-Expose-Headers",
},
};
const originsToRestoreMap = new Map<number, string>();
const PROXIES = new Map<number, RequestProxy>();

export function initWebRequestListeners(ctx: Context) {
if ((process.env.NODE_ENV as BuildEnvironment) !== "development") {
return;
}
export function initWebRequestListeners({locations}: Context) {
const resolveProxy: (details: RequestDetails) => RequestProxy | null = (() => {
const origins: { [k in AccountType]: string[] } = {
...resolveLocalWebClientOrigins("protonmail", locations),
...resolveLocalWebClientOrigins("tutanota", locations),
};

const isLocalWebClientOrigin: (origin: string) => boolean = (() => {
const localOrigins = Object
.values(ctx.locations.webClients.protonmail)
.map(({entryUrl}) => buildOrigin(new URL(entryUrl)));
return (origin: string) => localOrigins.some((localOrigin) => origin === localOrigin);
return (details: RequestDetails) => {
const proxies: { [k in AccountType]: ReturnType<typeof resolveRequestProxy> } = {
protonmail: resolveRequestProxy("protonmail", details, origins),
tutanota: resolveRequestProxy("tutanota", details, origins),
};
const [accountType]: Array<AccountType | undefined> = Object.keys(proxies) as any;

return accountType
? proxies[accountType]
: null;
};
})();

// TODO (TS / electron.d.ts) "webRequest.onBeforeSendHeaders" signature is not properly declared
getDefaultSession().webRequest.onBeforeSendHeaders(
{urls: []},
// TODO TS/electron.d.ts: "webRequest.onBeforeSendHeaders" listener signature is not declared properly
(
details: Details & { requestHeaders: HeadersMap; },
callback: (arg: { cancel: boolean; requestHeaders: typeof details.requestHeaders; }) => void,
requestDetails: RequestDetails,
callback: (arg: { cancel: boolean; requestHeaders: typeof requestDetails.requestHeaders; }) => void,
) => {
const headers = details.requestHeaders;
const originHeader = getHeader(headers, headerNames.origin);
const origin = originHeader && buildOrigin(new URL(originHeader.value[0]));

if (
String(details.resourceType).toLowerCase() === "xhr" &&
origin &&
isLocalWebClientOrigin(origin)
) {
const {name} = getHeader(headers, headerNames.origin) || {name: headerNames.origin};
headers[name] = buildOrigin(new URL(details.url));
originsToRestoreMap.set(details.id, origin);
const {requestHeaders} = requestDetails;
const requestProxy = resolveProxy(requestDetails);

if (requestProxy) {
const {name} = getHeader(requestHeaders, HEADERS.request.origin) || {name: HEADERS.request.origin};
requestHeaders[name] = buildOrigin(new URL(requestDetails.url));
PROXIES.set(requestDetails.id, requestProxy);
}

callback({cancel: false, requestHeaders: details.requestHeaders});
callback({cancel: false, requestHeaders});
},
);

// TODO (TS / electron.d.ts) "webRequest.onHeadersReceived" signature is not properly declared
getDefaultSession().webRequest.onHeadersReceived(
// TODO TS/electron.d.ts: "webRequest.onHeadersReceived" listener signature is not declared properly
(
details: Details & { responseHeaders: HeadersMap; },
callback: (arg: { responseHeaders: typeof details.responseHeaders }) => void,
responseDetails: ResponseDetails,
callback: (arg: { responseHeaders: typeof responseDetails.responseHeaders }) => void,
) => {
const headers = details.responseHeaders;
const originToRestore = originsToRestoreMap.get(details.id);
const requestProxy = PROXIES.get(responseDetails.id);
const responseHeaders = requestProxy
? responseHeadersPatchHandlers[requestProxy.accountType]({responseDetails, requestProxy})
: responseDetails.responseHeaders;

if (originToRestore) {
const {name} = getHeader(headers, headerNames.accessControlAllowOrigin) || {name: headerNames.accessControlAllowOrigin};
headers[name] = [originToRestore];
originsToRestoreMap.delete(details.id);
}

callback({responseHeaders: headers});
callback({responseHeaders});
},
);
}

function getHeader(headers: HeadersMap, headerName: string): { name: string, value: string[]; } | null {
function resolveLocalWebClientOrigins<T extends AccountType>(
accountType: T,
{webClients}: ElectronContextLocations,
): { [k in T]: string[]; } {
return {
[accountType]: Object
.values(webClients[accountType])
.map(({entryUrl}) => buildOrigin(new URL(entryUrl))),
};
}

function resolveRequestProxy<T extends AccountType>(
accountType: T,
{requestHeaders, resourceType}: RequestDetails,
origins: Record<AccountType, string[]>,
): RequestProxy | null {
const originHeader = (
String(resourceType).toUpperCase() === "XHR" &&
getHeader(requestHeaders, HEADERS.request.origin)
);
const originValue = (
originHeader &&
buildOrigin(new URL(originHeader.values[0]))
);

if (!originValue) {
return null;
}

return origins[accountType].some((localOrigin) => originValue === localOrigin)
? {
accountType,
headers: {
origin: (() => {
const result = getHeader(requestHeaders, HEADERS.request.origin);
if (!result) {
throw new Error(``);
}
return result;
})(),
accessControlRequestHeaders: getHeader(requestHeaders, HEADERS.request.accessControlRequestHeaders),
accessControlRequestMethod: getHeader(requestHeaders, HEADERS.request.accessControlRequestMethod),
},
}
: null;
}

// TODO consider doing initial preflight/OPTIONS call to https://mail.protonmail.com
// and then pick all the "Access-Control-*" header names as a template instead of hardcoding the default headers
// since over time the server may start giving other headers
const responseHeadersPatchHandlers: {
[k in AccountType]: (
arg: { requestProxy: RequestProxy, responseDetails: ResponseDetails; },
) => ResponseDetails["responseHeaders"];
} = {
protonmail: ({requestProxy, responseDetails}) => {
const {responseHeaders} = responseDetails;

patchResponseHeader(
responseHeaders,
{
name: HEADERS.response.accessControlAllowCredentials,
values: ["true"],
},
{extend: false},
);
patchResponseHeader(
responseHeaders,
{
name: HEADERS.response.accessControlAllowHeaders,
values: [
...(requestProxy.headers.accessControlRequestHeaders || {values: []}).values,
"authorization",
"cache-control",
"content-type",
"Date",
"x-eo-uid",
"x-pm-apiversion",
"x-pm-appversion",
"x-pm-session",
"x-pm-uid",
],
},
);
patchResponseHeader(
responseHeaders,
{
name: HEADERS.response.accessControlAllowMethods,
values: [
...(requestProxy.headers.accessControlRequestMethod || {
values: [
"HEAD",
"OPTIONS",
"POST",
"PUT",
"DELETE",
"GET",
],
}).values,
],
},
);
patchResponseHeader(
responseHeaders,
{
name: HEADERS.response.accessControlAllowOrigin,
values: requestProxy.headers.origin.values,
},
{replace: true},
);
patchResponseHeader(
responseHeaders,
{
name: HEADERS.response.accessControlExposeHeaders,
values: ["Date"],
},
);

return responseHeaders;
},
tutanota: ({responseDetails}) => {
return responseDetails.responseHeaders;
},
};

function patchResponseHeader(
headers: ResponseDetails["responseHeaders"],
patch: ReturnType<typeof getHeader>,
{replace, extend = true, _default = true}: { replace?: boolean; extend?: boolean; _default?: boolean } = {},
): void {
if (!patch) {
return;
}

const header: Exclude<ReturnType<typeof getHeader>, null> =
getHeader(headers, patch.name) || {name: patch.name, values: []};

if (_default && !header.values.length) {
headers[header.name] = patch.values;
return;
}

headers[header.name] = replace
? patch.values
: extend
? [...header.values, ...patch.values]
: header.values;
}

function getHeader(headers: HeadersMap, nameCriteria: string): { name: string, values: string[]; } | null {
const names = Object.keys(headers);
const resolvedIndex = names.findIndex((name) => name.toLowerCase() === headerName);
const resolvedIndex = names.findIndex((name) => name.toLowerCase() === nameCriteria.toLowerCase());
const resolvedName = resolvedIndex !== -1
? names[resolvedIndex]
: null;
Expand All @@ -82,7 +265,7 @@ function getHeader(headers: HeadersMap, headerName: string): { name: string, val

return {
name: resolvedName,
value: Array.isArray(value)
values: Array.isArray(value)
? value
: [value],
};
Expand All @@ -91,14 +274,3 @@ function getHeader(headers: HeadersMap, headerName: string): { name: string, val
function buildOrigin(url: URL): string {
return `${url.protocol}//${url.host}${url.port ? ":" + url.port : ""}`;
}

interface Details {
id: number;
url: string;
method: string;
resourceType?: string;
}

interface HeadersMap {
[k: string]: string | string[];
}
34 changes: 25 additions & 9 deletions src/shared/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,30 +31,46 @@ export const PROVIDER_REPO: Record<Extract<AccountType, "protonmail">, { repo: s
export const LOCAL_WEBCLIENT_PROTOCOL_PREFIX = "webclient";
export const LOCAL_WEBCLIENT_PROTOCOL_RE_PATTERN = `${LOCAL_WEBCLIENT_PROTOCOL_PREFIX}[\\d]+`;

export const ACCOUNTS_CONFIG_ENTRY_URL_SEPARATOR = ":::";
export const ACCOUNTS_CONFIG_ENTRY_URL_LOCAL_PREFIX = "local";
export const ACCOUNTS_CONFIG_ENTRY_URL_LOCAL_PREFIX = "local:::";
export const ACCOUNTS_CONFIG: Record<AccountType, Record<"entryUrl", EntryUrlItem[]>> = {
protonmail: {
entryUrl: [
{
value: "https://app.protonmail.ch",
title: "https://app.protonmail.ch",
},
{
value: "https://mail.protonmail.com",
title: "https://mail.protonmail.com",
},
...((process.env.NODE_ENV as BuildEnvironment) === "development" ? [
{
value: `${ACCOUNTS_CONFIG_ENTRY_URL_LOCAL_PREFIX}${ACCOUNTS_CONFIG_ENTRY_URL_SEPARATOR}https://mail.protonmail.com`,
value: `${ACCOUNTS_CONFIG_ENTRY_URL_LOCAL_PREFIX}https://mail.protonmail.com`,
title: `https://mail.protonmail.com (Built-in WebClient v${PROVIDER_REPO.protonmail.version})`,
},
] : []),
{
value: "https://beta.protonmail.com",
title: "https://beta.protonmail.com",
},
{
value: "https://protonirockerxow.onion",
title: "https://protonirockerxow.onion",
},
...((process.env.NODE_ENV as BuildEnvironment) === "development" ? [
{
value: `${ACCOUNTS_CONFIG_ENTRY_URL_LOCAL_PREFIX}${ACCOUNTS_CONFIG_ENTRY_URL_SEPARATOR}https://protonirockerxow.onion`,
value: `${ACCOUNTS_CONFIG_ENTRY_URL_LOCAL_PREFIX}https://protonirockerxow.onion`,
title: `https://protonirockerxow.onion (Built-in WebClient v${PROVIDER_REPO.protonmail.version})`,
},
] : []),
{value: "https://app.protonmail.ch", title: "https://app.protonmail.ch"},
{value: "https://mail.protonmail.com", title: "https://mail.protonmail.com"},
{value: "https://beta.protonmail.com", title: "https://beta.protonmail.com (Beta)"},
{value: "https://protonirockerxow.onion", title: "https://protonirockerxow.onion (Tor)"},
],
},
tutanota: {
entryUrl: [
{value: "https://mail.tutanota.com", title: "https://mail.tutanota.com"},
{
value: "https://mail.tutanota.com",
title: "https://mail.tutanota.com",
},
],
},
};
Expand Down
Loading

0 comments on commit 5bbdb3d

Please sign in to comment.