Skip to content
Open
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
8 changes: 8 additions & 0 deletions l10n/bundle.l10n.json
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,14 @@
"Overleaf server address, e.g. \"https://www.overleaf.com\"": "Overleaf server address, e.g. \"https://www.overleaf.com\"",
"Invalid protocol.": "Invalid protocol.",
"Invalid server address.": "Invalid server address.",
"Checking server \"{url}\"...": "Checking server \"{url}\"...",
"Could not reach a server at \"{url}\". It may be offline or the address may be wrong.": "Could not reach a server at \"{url}\". It may be offline or the address may be wrong.",
"\"{url}\" responded but does not look like an Overleaf/ShareLaTeX server.": "\"{url}\" responded but does not look like an Overleaf/ShareLaTeX server.",
"Add anyway": "Add anyway",
"Cancel": "Cancel",
"Could not reach the server. It may be offline or the address may be wrong.": "Could not reach the server. It may be offline or the address may be wrong.",
"The server responded but does not look like an Overleaf/ShareLaTeX server.": "The server responded but does not look like an Overleaf/ShareLaTeX server.",
"Incorrect email/password, or your session has expired.": "Incorrect email/password, or your session has expired.",
"Remove server \"{name}\" ?": "Remove server \"{name}\" ?",
"Email": "Email",
"Password": "Password",
Expand Down
104 changes: 76 additions & 28 deletions src/api/base.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,19 @@ import { v4 as uuidv4 } from 'uuid';
import fetch from 'node-fetch';
import { FileEntity, FileType, FolderEntity, OutputFileEntity } from '../core/remoteFileSystemProvider';

// network failure (DNS/refused/timeout/TLS), not a parseable HTTP response
function isNetworkError(e: any): boolean {
return e instanceof Error && !(e as any).response && (
/ENOTFOUND|ECONNREFUSED|ECONNRESET|ETIMEDOUT|EAI_AGAIN|ENETUNREACH|EHOSTUNREACH|CERT_|certificate/i.test((e as any).code || '') ||
/network|timeout|aborted|request to .* failed|ENOTFOUND|ECONNREFUSED|ETIMEDOUT|certificate/i.test(e.message)
);
}

// loose on purpose: a renamed tag in a newer Overleaf version must not flag a working server
function looksLikeOverleaf(body: string): boolean {
return /_csrf|ol-csrfToken|ol-user_id|sharelatex|overleaf/i.test(body);
}

export interface Identity {
csrfToken: string;
cookies: string;
Expand Down Expand Up @@ -167,10 +180,13 @@ export interface ProjectSettingsSchema {
compilers: {code:string, name:string}[],
}

export type ServerErrorType = 'unreachable' | 'not-overleaf' | 'unauthorized' | 'unknown';

export interface ResponseSchema {
type: 'success' | 'error';
raw?: ArrayBuffer;
message?: string;
errorType?: ServerErrorType;
userInfo?: {userId:string, userEmail:string};
identity?: Identity;
projects?: ProjectPersist[];
Expand Down Expand Up @@ -201,22 +217,39 @@ export class BaseAPI {
this.agent = new URL(url).protocol==='http:' ? new http.Agent({keepAlive: true}) : new https.Agent({keepAlive: true});
}

// unauthenticated reachability + Overleaf check before adding a server; never throws, sends no credentials
async probeServer(): Promise<{reachable:boolean, isOverleaf:boolean}> {
try {
const res = await fetch(this.url+'login', {
method: 'GET', redirect: 'manual', agent: this.agent, timeout: 5000,
});
return { reachable: true, isOverleaf: looksLikeOverleaf(await res.text()) };
} catch {
return { reachable: false, isOverleaf: false };
}
}

private async getCsrfToken(): Promise<Identity> {
const res = await fetch(this.url+'login', {
method: 'GET', redirect: 'manual', agent: this.agent,
});
const body = await res.text();
const match = body.match(/<input.*name="_csrf".*value="([^"]*)">/);
if (!match) {
throw new Error('Failed to get CSRF token.');
// Reachable, but no Overleaf login form: wrong URL or unsupported server.
const err: any = new Error('Failed to get CSRF token.');
err.errorType = looksLikeOverleaf(body) ? 'unknown' : 'not-overleaf';
throw err;
} else {
const csrfToken = match[1];
const cookies = res.headers.raw()['set-cookie'][0].split(';')[0];
return { csrfToken, cookies };
}
}

private async getUserId(cookies:string) {
private async getUserId(cookies:string): Promise<
{ok:true, userId:string, userEmail:string, csrfToken:string} | {ok:false, error:ServerErrorType}
> {
const res = await fetch(this.url+'project', {
method: 'GET', redirect:'manual', agent: this.agent,
headers: {
Expand All @@ -233,9 +266,13 @@ export class BaseAPI {
const userId = userIDMatch[1];
const csrfToken = csrfTokenMatch[1];
const userEmail = userEmailMatch ? userEmailMatch[1] : '';
return {userId, userEmail, csrfToken};
return {ok:true, userId, userEmail, csrfToken};
} else {
return undefined;
// not authenticated: Overleaf redirects /project to /login, so a redirect (or an
// Overleaf-looking page) means an invalid/expired session, not a non-Overleaf server
const isRedirect = res.status>=300 && res.status<400;
const error: ServerErrorType = (isRedirect || looksLikeOverleaf(body)) ? 'unauthorized' : 'not-overleaf';
return { ok:false, error };
}
}

Expand All @@ -253,19 +290,25 @@ export class BaseAPI {
}

async passportLogin(email:string, password:string): Promise<ResponseSchema> {
const identity = await this.getCsrfToken();
const res = await fetch(this.url+'login', {
method: 'POST', redirect: 'manual', agent: this.agent,
headers: {
'Accept': '*/*',
'Accept-Encoding': 'gzip, deflate, br',
'Connection': 'keep-alive',
'Content-Type': 'application/json',
'Cookie': identity.cookies,
'X-Csrf-Token': identity.csrfToken,
},
body: JSON.stringify({ _csrf: identity.csrfToken, email: email, password: password })
});
let identity: Identity;
let res;
try {
identity = await this.getCsrfToken();
res = await fetch(this.url+'login', {
method: 'POST', redirect: 'manual', agent: this.agent,
headers: {
'Accept': '*/*',
'Accept-Encoding': 'gzip, deflate, br',
'Connection': 'keep-alive',
'Content-Type': 'application/json',
'Cookie': identity.cookies,
'X-Csrf-Token': identity.csrfToken,
},
body: JSON.stringify({ _csrf: identity.csrfToken, email: email, password: password })
});
} catch (e:any) {
return { type:'error', errorType: isNetworkError(e) ? 'unreachable' : (e?.errorType ?? 'unknown') };
}

if (res.status===302) {
const redirect = ((await res.text()).match(/Found. Redirecting to (.*)/) as any)[1];
Expand All @@ -279,27 +322,31 @@ export class BaseAPI {
};
}
}
else if (res.status===200) {
// rejected login: `{message:{text?}}`; text may be absent (only an i18n key), so the display layer localizes
else if (res.status===400 || res.status===401 || res.status===200) {
const body = await res.json().catch(() => undefined) as any;
return {
type: 'error',
message: (await res.json() as any).message.message
};
} else if (res.status===401) {
return {
type: 'error',
message: (await res.json() as any).message.text
errorType: 'unauthorized',
message: body?.message?.text ?? body?.message?.message
};
} else {
return {
type: 'error',
message: `${res.status}: `+await res.text()
errorType: 'unknown',
message: `${res.status}`
};
}
}

async cookiesLogin(cookies: string): Promise<ResponseSchema> {
const res = await this.getUserId(cookies);
if (res) {
let res;
try {
res = await this.getUserId(cookies);
} catch (e:any) {
return { type:'error', errorType: isNetworkError(e) ? 'unreachable' : 'unknown' };
}
if (res.ok) {
const { userId, userEmail, csrfToken } = res;
const identity: Identity = await this.updateCookies({ cookies, csrfToken });
return {
Expand All @@ -308,9 +355,10 @@ export class BaseAPI {
identity: identity
};
} else {
// no message: let the display layer localize from errorType
return {
type: 'error',
message: 'Failed to get User ID.'
errorType: res.error,
};
}
}
Expand Down
66 changes: 37 additions & 29 deletions src/core/projectManagerProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -177,24 +177,38 @@ export class ProjectManagerProvider implements vscode.TreeDataProvider<DataItem>
}
}

addServer() {
vscode.window.showInputBox({'placeHolder': vscode.l10n.t('Overleaf server address, e.g. "https://www.overleaf.com"')})
.then((url) => {
if (url) {
try {
// check if url is valid
const _url = new URL(url);
if (!(_url.protocol==='http:' || _url.protocol==='https:')) {
throw new Error( vscode.l10n.t('Invalid protocol.') );
}
if (GlobalStateManager.addServer(this.context, _url.host, _url.href)) {
this.refresh();
}
} catch (e) {
vscode.window.showErrorMessage( vscode.l10n.t('Invalid server address.') );
}
async addServer() {
const url = await vscode.window.showInputBox({'placeHolder': vscode.l10n.t('Overleaf server address, e.g. "https://www.overleaf.com"')});
if (!url) { return; }

let _url: URL;
try {
_url = new URL(url);
if (!(_url.protocol==='http:' || _url.protocol==='https:')) {
throw new Error( vscode.l10n.t('Invalid protocol.') );
}
});
} catch (e) {
vscode.window.showErrorMessage( vscode.l10n.t('Invalid server address.') );
return;
}

// Probe the URL so the user is warned about a wrong/unreachable address.
const probe = await vscode.window.withProgress(
{ location: vscode.ProgressLocation.Notification, title: vscode.l10n.t('Checking server "{url}"...', {url:_url.host}) },
() => GlobalStateManager.probeServer(_url.href)
);
if (!probe.reachable || !probe.isOverleaf) {
const reason = !probe.reachable
? vscode.l10n.t('Could not reach a server at "{url}". It may be offline or the address may be wrong.', {url:_url.host})
: vscode.l10n.t('"{url}" responded but does not look like an Overleaf/ShareLaTeX server.', {url:_url.host});
const addAnyway = vscode.l10n.t('Add anyway');
const choice = await vscode.window.showWarningMessage(reason, addAnyway, vscode.l10n.t('Cancel'));
if (choice !== addAnyway) { return; }
}

if (GlobalStateManager.addServer(this.context, _url.host, _url.href)) {
this.refresh();
}
}

removeServer(name:string) {
Expand Down Expand Up @@ -224,12 +238,9 @@ export class ProjectManagerProvider implements vscode.TreeDataProvider<DataItem>
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.') );
}
});
// The specific failure reason is reported by `loginServer`.
if (success) { this.refresh(); }
}, () => {}); // ignore user-cancelled input box
},
// eslint-disable-next-line @typescript-eslint/naming-convention
'Login with Cookies': () => {
Expand All @@ -242,12 +253,9 @@ export class ProjectManagerProvider implements vscode.TreeDataProvider<DataItem>
GlobalStateManager.loginServer(this.context, server.api, server.name, {cookies})
)
.then(success => {
if (success) {
this.refresh();
} else {
vscode.window.showErrorMessage( vscode.l10n.t('Login failed.') );
}
});
// The specific failure reason is reported by `loginServer`.
if (success) { this.refresh(); }
}, () => {}); // ignore user-cancelled input box
},
};

Expand Down
29 changes: 24 additions & 5 deletions src/utils/globalStateManager.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import * as vscode from 'vscode';
import { Identity, BaseAPI, ProjectPersist } from '../api/base';
import { Identity, BaseAPI, ProjectPersist, ServerErrorType } from '../api/base';
import { SocketIOAPI } from '../api/socketio';
import { ExtendedBaseAPI } from '../api/extendedBase';

Expand Down Expand Up @@ -52,6 +52,21 @@ export class GlobalStateManager {
}
}

// map an error category to a localized, user-facing message
private static loginErrorMessage(errorType?:ServerErrorType, fallback?:string): string {
switch (errorType) {
case 'unreachable': return vscode.l10n.t('Could not reach the server. It may be offline or the address may be wrong.');
case 'not-overleaf': return vscode.l10n.t('The server responded but does not look like an Overleaf/ShareLaTeX server.');
case 'unauthorized': return fallback || vscode.l10n.t('Incorrect email/password, or your session has expired.');
default: return fallback || vscode.l10n.t('Login failed.');
}
}

// reachability + Overleaf check used before adding a server
static async probeServer(url:string): Promise<{reachable:boolean, isOverleaf:boolean}> {
return new BaseAPI(url).probeServer();
}

static addServer(context:vscode.ExtensionContext, name:string, url:string): boolean {
const persists = context.globalState.get<ServerPersistMap>(keyServerPersists, {});
if ( persists[name]===undefined ) {
Expand Down Expand Up @@ -79,7 +94,13 @@ export class GlobalStateManager {
const server = persists[name];

if (server.login===undefined) {
const res = auth.cookies ? await api.cookiesLogin(auth.cookies) : await api.passportLogin(auth.email, auth.password);
let res;
try {
res = auth.cookies ? await api.cookiesLogin(auth.cookies) : await api.passportLogin(auth.email, auth.password);
} catch (e:any) {
vscode.window.showErrorMessage(this.loginErrorMessage('unknown'));
return false;
}
if (res.type==='success' && res.identity!==undefined && res.userInfo!==undefined) {
server.login = {
userId: res.userInfo.userId,
Expand All @@ -89,9 +110,7 @@ export class GlobalStateManager {
context.globalState.update(keyServerPersists, persists);
return true;
} else {
if (res.message!==undefined) {
vscode.window.showErrorMessage(res.message);
}
vscode.window.showErrorMessage(this.loginErrorMessage(res.errorType, res.message));
return false;
}
} else {
Expand Down