Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
133 changes: 86 additions & 47 deletions src/core/projectManagerProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { ProjectTagsResponseSchema } from '../api/base';
import { GlobalStateManager } from '../utils/globalStateManager';
import { VirtualFileSystem, parseUri } from './remoteFileSystemProvider';
import { LocalReplicaSCMProvider } from '../scm/localReplicaSCM';
import { PhantomWebview } from '../utils/phantomWebview';

class DataItem extends vscode.TreeItem {
constructor(
Expand Down Expand Up @@ -199,60 +200,98 @@ export class ProjectManagerProvider implements vscode.TreeDataProvider<DataItem>
}

loginServer(server: ServerItem) {
const loginMethods:Record<string, ()=>void> = {
// eslint-disable-next-line @typescript-eslint/naming-convention
'Login with Password': () => {
vscode.window.showInputBox({'placeHolder': vscode.l10n.t('Email')})
.then(email => email ? Promise.resolve(email) : Promise.reject())
.then(email =>
vscode.window.showInputBox({'placeHolder': vscode.l10n.t('Password'), 'password': true})
.then(password => {
return password ? Promise.resolve([email,password]) : Promise.reject();
})
)
.then(([email,password]) =>
GlobalStateManager.loginServer(this.context, server.api, server.name, {email, password})
)
.then(success => {
if (success) {
this.refresh();
} else {
vscode.window.showErrorMessage( vscode.l10n.t('Login failed.') );
}
});
const loginMethods = [
// login with webview
{
id: 'webview',
label: vscode.l10n.t('Login with Webview'),
disabled: false,
details: vscode.l10n.t(''),
callback: () => {
const webview = new PhantomWebview(server.api.url);
webview.onCookieUpdated((cookies) => {
// 'overleaf_session2', 'sharelatex.sid'
if (cookies['overleaf_session2'] || cookies['sharelatex.sid']) {
const cookie = Object.entries(cookies).map(([key,value]) => `${key}=${value}`).join('; ');
GlobalStateManager.loginServer(this.context, server.api, server.name, {cookies:cookie})
.then(success => {
if (success) {
this.refresh();
webview.dispose();
} else {
vscode.window.showErrorMessage( vscode.l10n.t('Login failed.') );
}
});
}
});
},
},
// eslint-disable-next-line @typescript-eslint/naming-convention
'Login with Cookies': () => {
vscode.window.showInputBox({
'placeHolder': vscode.l10n.t('Cookies, e.g., "sharelatex.sid=..." or "overleaf_session2=..."'),
'prompt': vscode.l10n.t('README: [How to Login with Cookies](https://github.com/iamhyc/overleaf-workshop#how-to-login-with-cookies)'),
})
.then(cookies => cookies ? Promise.resolve(cookies) : Promise.reject())
.then(cookies =>
GlobalStateManager.loginServer(this.context, server.api, server.name, {cookies})
)
.then(success => {
if (success) {
this.refresh();
} else {
vscode.window.showErrorMessage( vscode.l10n.t('Login failed.') );
}
});
// login with password
{
id: 'password',
label: vscode.l10n.t('Login with Email/Password'),
disabled: false,
details: vscode.l10n.t(''),
callback: () => {
vscode.window.showInputBox({'placeHolder': vscode.l10n.t('Email')})
.then(email => email ? Promise.resolve(email) : Promise.reject())
.then(email =>
vscode.window.showInputBox({'placeHolder': vscode.l10n.t('Password'), 'password': true})
.then(password => {
return password ? Promise.resolve([email,password]) : Promise.reject();
})
)
.then(([email,password]) =>
GlobalStateManager.loginServer(this.context, server.api, server.name, {email, password})
)
.then(success => {
if (success) {
this.refresh();
} else {
vscode.window.showErrorMessage( vscode.l10n.t('Login failed.') );
}
});
},
},
};
{
id: 'cookies',
label: vscode.l10n.t('Login with Cookies'),
disabled: false,
details: vscode.l10n.t(''),
callback: () => {
vscode.window.showInputBox({
'placeHolder': vscode.l10n.t('Cookies, e.g., "sharelatex.sid=..." or "overleaf_session2=..."'),
'prompt': vscode.l10n.t('README: [How to Login with Cookies](https://github.com/iamhyc/overleaf-workshop#how-to-login-with-cookies)'),
})
.then(cookies => cookies ? Promise.resolve(cookies) : Promise.reject())
.then(cookies =>
GlobalStateManager.loginServer(this.context, server.api, server.name, {cookies})
)
.then(success => {
if (success) {
this.refresh();
} else {
vscode.window.showErrorMessage( vscode.l10n.t('Login failed.') );
}
});
},
},
];

//NOTE: temporarily disable password-based login for `www.overleaf.com`
if (server.name==='www.overleaf.com') {
delete loginMethods['Login with Password'];
loginMethods.forEach(x => {
if (x.id==='password') { x.disabled = true; }
});
}

vscode.window.showQuickPick(Object.keys(loginMethods), {
canPickMany:false, placeHolder:vscode.l10n.t('Select the login method below.')})
.then(selection => {
if (selection===undefined) { return Promise.reject(); }
return Promise.resolve( (loginMethods as any)[selection] );
})
.then(method => method());
vscode.window.showQuickPick(
loginMethods.filter(x => !x.disabled),
{ placeHolder:vscode.l10n.t('Select the login method below.') }
).then(selection => {
if (selection===undefined) { return; }
selection.callback();
});
}

logoutServer(server: ServerItem) {
Expand Down
205 changes: 205 additions & 0 deletions src/utils/phantomWebview.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,205 @@
import * as vscode from 'vscode';
import * as http from 'http';
import * as https from 'https';
import * as url from 'url';

interface Cookies {
[key: string]: string;
}
const cookieBroadcast = new vscode.EventEmitter<Cookies>();

class ProxyServer {
private cookies: Cookies = {};
private server: http.Server;

constructor(
private readonly parent: CORSProxy,
readonly targetUrl: url.URL,
private readonly agent: http.Agent | https.Agent,
) {
targetUrl.port = targetUrl.port || (targetUrl.protocol === 'https:' ? '443' : '80');
this.server = http.createServer((req, res) => this.proxyRequest(req, res));
}

get proxyAddress() {
const address = this.server.address() as any;
return `http://${address.address}:${address.port}`;
}

start(callback?:() => void) {
this.server.listen(0, 'localhost', callback);
}

close() {
this.server.close();
}

private proxyRequest(req: http.IncomingMessage, res: http.ServerResponse) {
// update the request headers with the cookies
const cookie = Object.entries(this.cookies).map(([key,value]) => `${key}=${value}`).join('; ');
req.headers.cookie = cookie;
// update the request host with the target host
if (req.headers.host && req.headers.referer) {
req.headers.referer = req.headers.referer.replace(req.headers.host, this.targetUrl.host);
}
req.headers.host = this.targetUrl.host;
req.headers.origin = this.targetUrl.origin;
// remove the `sec-fetch-*` headers
req.headers['sec-fetch-mode'] = 'cors';
req.headers['sec-fetch-site'] = 'same-origin';
req.headers['sec-fetch-dest'] = 'empty';
// proxy the request
const options = {
hostname: this.targetUrl.hostname,
port: this.targetUrl.port,
path: req.url,
method: req.method,
headers: req.headers,
agent: this.agent,
};
const proxy = this.targetUrl.protocol === 'https:' ? https.request(options) : http.request(options);
req.pipe(proxy);

proxy.on('response', async (proxyRes) => {
// Record the cookies
if (proxyRes.headers['set-cookie']) {
proxyRes.headers['set-cookie'].forEach((cookie) => {
const [keyValue, ...rest] = cookie.split(';');
const [_key, _value] = keyValue.split('=');
const [key, value] = [_key.trim(), _value.trim()];
// Notify the cookie update
if ( req.statusCode===200 && req.method==='GET' && req.url?.endsWith('/project') ) {
cookieBroadcast.fire({ [key]: value });
}
this.cookies[key] = value;
});
}
// Remove CORS related restrictions
delete proxyRes.headers['content-security-policy'];
delete proxyRes.headers['cross-origin-opener-policy'];
delete proxyRes.headers['cross-origin-resource-policy'],
delete proxyRes.headers['referrer-policy'];
delete proxyRes.headers['strict-transport-security'];
delete proxyRes.headers['x-content-type-options'];
delete proxyRes.headers['x-download-options'];
delete proxyRes.headers['x-frame-options'];
delete proxyRes.headers['x-permitted-cross-domain-policies'];
delete proxyRes.headers['x-served-by'];
delete proxyRes.headers['x-xss-protection'];
// Notify parent with 302 redirection
if (proxyRes.statusCode === 302 && proxyRes.headers['location']?.startsWith('http')) {
proxyRes = await this.parent.updateProxyServer(proxyRes);
}
// Copy the response headers
res.writeHead(proxyRes.statusCode || 500, proxyRes.headers);
// Pipe the response data
proxyRes.pipe(res);
});

proxy.on('close', () => {
res.end();
});

proxy.on('error', (err) => {
console.error(`Error on proxy request: ${err.message}`);
res.writeHead(500);
res.end();
});
}
}

class CORSProxy {
rootServer: ProxyServer;
private proxyAgent: http.Agent | https.Agent;
private proxyServers: { [key: string]: ProxyServer } = {};

constructor(
private readonly targetUrl: url.URL,
) {
this.proxyAgent = this.targetUrl.protocol === 'https:' ? new https.Agent({ keepAlive: true }) : new http.Agent({ keepAlive: true });
this.rootServer = new ProxyServer(this, this.targetUrl, this.proxyAgent);
this.proxyServers[this.targetUrl.origin] = this.rootServer;
}

async updateProxyServer(proxyRes: http.IncomingMessage) {
const location = proxyRes.headers['location'];
const locationUrl = new url.URL( location! );
if (location) {
// Create a new proxy server for the redirection
if (this.proxyServers[locationUrl.origin]===undefined) {
const proxyServer = new ProxyServer(this, locationUrl, this.proxyAgent);
this.proxyServers[locationUrl.origin] = proxyServer;
await new Promise((resolve) => proxyServer.start(() => resolve(undefined)));
}
// Update the redirection location origin
const proxyServer = this.proxyServers[locationUrl.origin];
proxyRes.headers['location'] = location.replace(locationUrl.origin, proxyServer.proxyAddress);
}
return proxyRes;
}

close() {
Object.values(this.proxyServers).forEach((server) => server.close());
this.proxyAgent.destroy();
}
}

export class PhantomWebview extends vscode.Disposable {
private targetUrl: url.URL;
private proxy: CORSProxy;

private panel?: vscode.WebviewPanel;

constructor(targetUrl: string) {
super(() => this.dispose());
this.targetUrl = new url.URL(targetUrl);
// Create the root proxy server
this.proxy = new CORSProxy(this.targetUrl);
this.proxy.rootServer.start(() => {
this.panel = this.createWebviewPanel();
this.panel.onDidDispose(() => this.dispose());
});
}

dispose() {
// Close the webview panel
this.panel?.dispose();
this.panel = undefined;
// Close the root proxy server
this.proxy.close();
}

onCookieUpdated(listener: (cookies: Cookies) => any, thisArgs?: any, disposables?: vscode.Disposable[]) {
return cookieBroadcast.event(listener, thisArgs, disposables);
}

private createWebviewPanel() {
const proxyUrl = `${this.proxy.rootServer.proxyAddress}/login`;
const panel = vscode.window.createWebviewPanel('phantom', this.targetUrl.hostname, vscode.ViewColumn.One, {
enableScripts: true,
retainContextWhenHidden: false,
});
panel.webview.html = `<!DOCTYPE html>
<html>
<head>
<title>Phantom Webview</title>
<style>
html, body, iframe {
margin: 0;
padding: 0;
width: 100%;
height: 100%;
overflow: hidden;
border: none;
}
</style>
</head>
<body>
<iframe src=${proxyUrl}></iframe>
</body>
</html>
`;
return panel;
}

}