keeweb/app/scripts/storage/storage-base.js

517 lines
17 KiB
JavaScript

import { Events } from 'framework/events';
import { Links } from 'const/links';
import { AppSettingsModel } from 'models/app-settings-model';
import { RuntimeDataModel } from 'models/runtime-data-model';
import { Logger } from 'util/logger';
import { StorageOAuthListener } from 'storage/storage-oauth-listener';
import { UrlFormat } from 'util/formatting/url-format';
import { Launcher } from 'comp/launcher';
import { omitEmpty } from 'util/fn';
import { Timeouts } from 'const/timeouts';
import { Features } from 'util/features';
import { createOAuthSession } from 'storage/pkce';
const MaxRequestRetries = 3;
class StorageBase {
name = null;
icon = null;
iconSvg = null;
enabled = false;
system = false;
uipos = null;
logger = null;
appSettings = AppSettingsModel;
runtimeData = RuntimeDataModel;
init() {
if (!this.name) {
throw 'Failed to init provider: no name';
}
if (!this.system) {
const enabled = this.appSettings[this.name];
if (typeof enabled === 'boolean') {
this.enabled = enabled;
}
}
this.logger = new Logger('storage-' + this.name);
return this;
}
setEnabled(enabled) {
if (!enabled) {
this.logout();
}
this.enabled = enabled;
}
get loggedIn() {
return !!this.runtimeData[this.name + 'OAuthToken'];
}
logout() {}
_xhr(config) {
this.logger.info('HTTP request', config.method || 'GET', config.url);
if (config.data) {
if (!config.dataType) {
config.dataType = 'application/octet-stream';
}
config.headers = {
...config.headers,
'Content-Type': config.dataType
};
}
if (this._oauthToken && !config.skipAuth) {
config.headers = {
...config.headers,
'Authorization': 'Bearer ' + this._oauthToken.accessToken
};
}
this._httpRequest(config, (response) => {
this.logger.info('HTTP response', response.status);
const statuses = config.statuses || [200];
if (statuses.indexOf(response.status) >= 0) {
return config.success && config.success(response.response, response);
}
if (response.status === 401 && this._oauthToken) {
this._oauthGetNewToken((err) => {
if (err) {
return config.error && config.error('unauthorized', response);
} else {
config.tryNum = (config.tryNum || 0) + 1;
if (config.tryNum >= MaxRequestRetries) {
this.logger.info(
'Too many authorize attempts, fail request',
config.url
);
return config.error && config.error('unauthorized', response);
}
this.logger.info('Repeat request, try #' + config.tryNum, config.url);
this._xhr(config);
}
});
} else {
return config.error && config.error('http status ' + response.status, response);
}
});
}
_httpRequest(config, onLoad) {
const httpRequest = Features.isDesktop ? this._httpRequestLauncher : this._httpRequestWeb;
httpRequest.call(this, config, onLoad);
}
_httpRequestWeb(config, onLoad) {
const xhr = new XMLHttpRequest();
if (config.responseType) {
xhr.responseType = config.responseType;
}
xhr.addEventListener('load', () => {
onLoad({
status: xhr.status,
response: xhr.response,
getResponseHeader: (name) => xhr.getResponseHeader(name)
});
});
xhr.addEventListener('error', () => {
return config.error && config.error('network error', xhr);
});
xhr.addEventListener('timeout', () => {
return config.error && config.error('timeout', xhr);
});
xhr.open(config.method || 'GET', config.url);
if (config.headers) {
for (const [key, value] of Object.entries(config.headers)) {
xhr.setRequestHeader(key, value);
}
}
let data = config.data;
if (data) {
if (!config.dataIsMultipart) {
data = [data];
}
data = new Blob(data, { type: config.dataType });
}
xhr.send(data);
}
_httpRequestLauncher(config, onLoad) {
const net = Launcher.remReq('electron').net;
const opts = Launcher.req('url').parse(config.url);
opts.method = config.method || 'GET';
opts.headers = {
'User-Agent': navigator.userAgent,
...config.headers
};
opts.timeout = Timeouts.DefaultHttpRequest;
let data;
if (config.data) {
if (config.dataIsMultipart) {
data = Buffer.concat(config.data.map((chunk) => Buffer.from(chunk)));
} else {
data = Buffer.from(config.data);
}
// Electron's API doesn't like that, while node.js needs it
// opts.headers['Content-Length'] = data.byteLength;
}
const req = net.request(opts);
let closed = false;
req.on('close', () => {
closed = true;
});
req.on('response', (res) => {
const chunks = [];
const onClose = () => {
this.logger.debug(
'HTTP response',
opts.method,
config.url,
res.statusCode,
res.headers
);
let response = Buffer.concat(chunks);
if (config.responseType === 'json') {
try {
response = JSON.parse(response.toString('utf8'));
} catch (e) {
return config.error && config.error('json parse error');
}
} else {
response = response.buffer.slice(
response.byteOffset,
response.byteOffset + response.length
);
}
onLoad({
status: res.statusCode,
response,
getResponseHeader: (name) => res.headers[name.toLowerCase()]
});
};
res.on('data', (chunk) => {
chunks.push(chunk);
if (closed && !res.readable) {
// sometimes 'close' event arrives faster in Electron
onClose();
}
});
// in Electron it's not res.on('end'), like in node.js, which is a bit weird
req.on('close', onClose);
});
req.on('error', (e) => {
this.logger.error('HTTP error', opts.method, config.url, e);
return config.error && config.error('network error', {});
});
req.on('timeout', () => {
req.abort();
return config.error && config.error('timeout', {});
});
if (data) {
req.write(data);
}
req.end();
}
_openPopup(url, title, width, height, extras) {
const dualScreenLeft = window.screenLeft !== undefined ? window.screenLeft : screen.left;
const dualScreenTop = window.screenTop !== undefined ? window.screenTop : screen.top;
const winWidth = window.innerWidth
? window.innerWidth
: document.documentElement.clientWidth
? document.documentElement.clientWidth
: screen.width;
const winHeight = window.innerHeight
? window.innerHeight
: document.documentElement.clientHeight
? document.documentElement.clientHeight
: screen.height;
const left = winWidth / 2 - width / 2 + dualScreenLeft;
const top = winHeight / 2 - height / 2 + dualScreenTop;
let settings = {
width,
height,
left,
top,
dialog: 'yes',
dependent: 'yes',
scrollbars: 'yes',
location: 'yes'
};
settings = Object.keys(settings)
.map((key) => key + '=' + settings[key])
.join(',');
return window.open(url, title, settings, extras);
}
_getOauthRedirectUrl() {
let redirectUrl = window.location.href;
if (redirectUrl.lastIndexOf('file:', 0) === 0) {
redirectUrl = Links.WebApp;
}
return new URL(`oauth-result/${this.name}.html`, redirectUrl).href;
}
_oauthAuthorize(callback) {
if (this._tokenIsValid(this._oauthToken)) {
return callback();
}
const opts = this._getOAuthConfig();
const oldToken = this.runtimeData[this.name + 'OAuthToken'];
if (this._tokenIsValid(oldToken)) {
this._oauthToken = oldToken;
return callback();
}
if (oldToken && oldToken.refreshToken) {
return this._oauthExchangeRefreshToken(callback);
}
const session = createOAuthSession();
let listener;
if (Features.isDesktop) {
listener = StorageOAuthListener.listen(this.name);
session.redirectUri = listener.redirectUri;
} else {
session.redirectUri = this._getOauthRedirectUrl();
}
const pkceParams = opts.pkce
? {
'code_challenge': session.codeChallenge,
'code_challenge_method': 'S256'
}
: undefined;
const url = UrlFormat.makeUrl(opts.url, {
'client_id': opts.clientId,
'scope': opts.scope,
'state': session.state,
'redirect_uri': session.redirectUri,
'response_type': 'code',
...pkceParams
});
if (listener) {
listener.on('ready', () => {
Launcher.openLink(url);
callback('browser-auth-started');
});
listener.on('error', (err) => callback(err));
listener.on('result', (result) => this._oauthCodeReceived(result, session));
return;
}
const popupWindow = this._openPopup(url, 'OAuth', opts.width, opts.height);
if (!popupWindow) {
return callback('OAuth: cannot open popup');
}
this.logger.debug('OAuth: popup opened');
const popupClosed = () => {
Events.off('popup-closed', popupClosed);
window.removeEventListener('message', windowMessage);
this.logger.error('OAuth error', 'popup closed');
callback('OAuth: popup closed');
};
const windowMessage = (e) => {
if (e.origin !== location.origin) {
return;
}
if (!e.data || !e.data.storage || !e.data.search) {
this.logger.debug('Skipped empty OAuth message', e.data);
return;
}
if (e.data.storage !== this.name) {
this.logger.debug('Skipped OAuth message for another storage', e.data.storage);
return;
}
const data = {};
for (const [key, value] of new URLSearchParams(e.data.search).entries()) {
data[key] = value;
}
if (data.error) {
this.logger.error('OAuth error', data.error, data.error_description);
callback('OAuth: ' + data.error);
} else if (data.code) {
Events.off('popup-closed', popupClosed);
window.removeEventListener('message', windowMessage);
this._oauthCodeReceived(data, session, callback);
} else {
this.logger.debug('Skipped OAuth message', data);
}
};
Events.on('popup-closed', popupClosed);
window.addEventListener('message', windowMessage);
}
_oauthProcessReturn(message) {
const token = this._oauthMsgToToken(message);
if (token && !token.error) {
this._oauthToken = token;
this.runtimeData[this.name + 'OAuthToken'] = token;
this.logger.debug('OAuth token received');
}
return token;
}
_oauthMsgToToken(data) {
if (!data.token_type) {
if (data.error) {
return { error: data.error, errorDescription: data.error_description };
} else {
return undefined;
}
}
return omitEmpty({
dt: Date.now() - 60 * 1000,
tokenType: data.token_type,
accessToken: data.access_token,
refreshToken: data.refresh_token,
authenticationToken: data.authentication_token,
expiresIn: +data.expires_in,
scope: data.scope,
userId: data.user_id
});
}
_oauthGetNewToken(callback) {
this._oauthToken.expired = true;
this.runtimeData[this.name + 'OAuthToken'] = this._oauthToken;
if (this._oauthToken.refreshToken) {
this._oauthExchangeRefreshToken(callback);
} else {
this._oauthAuthorize(callback);
}
}
_oauthRevokeToken(url, requestOptions) {
const token = this.runtimeData[this.name + 'OAuthToken'];
if (token) {
if (url) {
this._xhr({
url: url.replace('{token}', token.accessToken),
statuses: [200, 401],
...requestOptions
});
}
delete this.runtimeData[this.name + 'OAuthToken'];
this._oauthToken = null;
}
}
_tokenIsValid(token) {
if (!token || token.expired) {
return false;
}
if (token.dt && token.expiresIn && token.dt + token.expiresIn * 1000 < Date.now()) {
return false;
}
return true;
}
_oauthCodeReceived(result, session, callback) {
if (!result.state) {
this.logger.info('OAuth result has no state');
return callback && callback('OAuth result has no state');
}
if (result.state !== session.state) {
this.logger.info('OAuth result has bad state');
return callback && callback('OAuth result has bad state');
}
if (!result.code) {
this.logger.info('OAuth result has no code');
return callback && callback('OAuth result has no code');
}
this.logger.debug('OAuth code received');
if (Features.isDesktop) {
Launcher.showMainWindow();
}
const config = this._getOAuthConfig();
const pkceParams = config.pkce ? { 'code_verifier': session.codeVerifier } : undefined;
this._xhr({
url: config.tokenUrl,
method: 'POST',
responseType: 'json',
skipAuth: true,
data: UrlFormat.buildFormData({
'client_id': config.clientId,
'client_secret': config.clientSecret,
'grant_type': 'authorization_code',
'code': result.code,
'redirect_uri': session.redirectUri,
...pkceParams
}),
dataType: 'application/x-www-form-urlencoded',
success: (response) => {
this.logger.debug('OAuth code exchanged', response);
const token = this._oauthProcessReturn(response);
if (token && token.error) {
return callback && callback('OAuth code exchange error: ' + token.error);
}
callback?.();
},
error: (err) => {
this.logger.error('Error exchanging OAuth code', err);
callback?.('OAuth code exchange error: ' + err);
}
});
}
_oauthExchangeRefreshToken(callback) {
this.logger.debug('Exchanging refresh token');
const { refreshToken } = this.runtimeData[this.name + 'OAuthToken'];
const config = this._getOAuthConfig();
this._xhr({
url: config.tokenUrl,
method: 'POST',
responseType: 'json',
skipAuth: true,
data: UrlFormat.buildFormData({
'client_id': config.clientId,
'client_secret': config.clientSecret,
'grant_type': 'refresh_token',
'refresh_token': refreshToken
}),
dataType: 'application/x-www-form-urlencoded',
success: (response) => {
this.logger.debug('Refresh token exchanged');
this._oauthProcessReturn({
'refresh_token': refreshToken,
...response
});
callback();
},
error: (err, xhr) => {
if (xhr.status === 400) {
delete this.runtimeData[this.name + 'OAuthToken'];
this._oauthToken = null;
}
this.logger.error('Error exchanging refresh token', err);
callback?.('Error exchanging refresh token');
}
});
}
}
export { StorageBase };