diff --git a/l10n/bundle.l10n.json b/l10n/bundle.l10n.json index d31cccf5..d98648f9 100644 --- a/l10n/bundle.l10n.json +++ b/l10n/bundle.l10n.json @@ -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", diff --git a/src/api/base.ts b/src/api/base.ts index 55470237..73519dd6 100644 --- a/src/api/base.ts +++ b/src/api/base.ts @@ -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; @@ -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[]; @@ -201,6 +217,18 @@ 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 { const res = await fetch(this.url+'login', { method: 'GET', redirect: 'manual', agent: this.agent, @@ -208,7 +236,10 @@ export class BaseAPI { const body = await res.text(); const match = body.match(//); 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]; @@ -216,7 +247,9 @@ export class BaseAPI { } } - 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: { @@ -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 }; } } @@ -253,19 +290,25 @@ export class BaseAPI { } async passportLogin(email:string, password:string): Promise { - 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]; @@ -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 { - 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 { @@ -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, }; } } diff --git a/src/core/projectManagerProvider.ts b/src/core/projectManagerProvider.ts index 5f8688a3..70aa19ab 100644 --- a/src/core/projectManagerProvider.ts +++ b/src/core/projectManagerProvider.ts @@ -177,24 +177,38 @@ export class ProjectManagerProvider implements vscode.TreeDataProvider } } - 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) { @@ -224,12 +238,9 @@ export class ProjectManagerProvider implements vscode.TreeDataProvider 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': () => { @@ -242,12 +253,9 @@ export class ProjectManagerProvider implements vscode.TreeDataProvider 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 }, }; diff --git a/src/utils/globalStateManager.ts b/src/utils/globalStateManager.ts index 5af1d427..8e9935cb 100644 --- a/src/utils/globalStateManager.ts +++ b/src/utils/globalStateManager.ts @@ -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'; @@ -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(keyServerPersists, {}); if ( persists[name]===undefined ) { @@ -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, @@ -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 {