examples/demo-app/src/cloud-providers/dropbox/dropbox-provider.js (328 lines of code) (raw):
// Copyright (c) 2020 Uber Technologies, Inc.
//
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in
// all copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
// THE SOFTWARE.
// DROPBOX
import {Dropbox} from 'dropbox';
import window from 'global/window';
import Console from 'global/console';
import DropboxIcon from './dropbox-icon';
import {MAP_URI} from '../../constants/default-settings';
import {Provider} from 'kepler.gl/cloud-providers';
const NAME = 'dropbox';
const DISPLAY_NAME = 'Dropbox';
const DOMAIN = 'www.dropbox.com';
const KEPLER_DROPBOX_FOLDER_LINK = `//${DOMAIN}/home/Apps`;
const CORS_FREE_DOMAIN = 'dl.dropboxusercontent.com';
const PRIVATE_STORAGE_ENABLED = true;
const SHARING_ENABLED = true;
const MAX_THUMBNAIL_BATCH = 25;
const IMAGE_URL_PREFIX = 'data:image/gif;base64,';
function parseQueryString(query) {
const searchParams = new URLSearchParams(query);
const params = {};
for (const p of searchParams) {
if (p && p.length === 2 && p[0]) params[p[0]] = p[1];
}
return params;
}
function isConfigFile(err) {
const summary = err.error && err.error.error_summary;
return typeof summary === 'string' && Boolean(summary.match(/path\/conflict\/file\//g));
}
export default class DropboxProvider extends Provider {
constructor(clientId, appName) {
super({name: NAME, displayName: DISPLAY_NAME, icon: DropboxIcon});
// All cloud-providers providers must implement the following properties
this.clientId = clientId;
this.appName = appName;
this._folderLink = `${KEPLER_DROPBOX_FOLDER_LINK}/${appName}`;
this._path = `/Apps/${window.decodeURIComponent(this.appName)}`;
// Initialize Dropbox API
this._initializeDropbox();
}
/**
* This method will handle the oauth flow by performing the following steps:
* - Opening a new window
* - Subscribe to message channel
* - Receive the token when ready
* - Close the opened tab
*/
async login(onCloudLoginSuccess) {
const link = this._authLink();
const authWindow = window.open(link, '_blank', 'width=1024,height=716');
const handleToken = async e => {
// TODO: add security step to validate which domain the message is coming from
if (authWindow) {
authWindow.close();
}
window.removeEventListener('message', handleToken);
if (!e.data.token) {
Console.warn('Failed to login to Dropbox');
return;
}
this._dropbox.setAccessToken(e.data.token);
// save user name
const user = await this._getUser();
if (window.localStorage) {
window.localStorage.setItem(
'dropbox',
JSON.stringify({
// dropbox token doesn't expire unless revoked by the user
token: e.data.token,
user,
timestamp: new Date()
})
);
}
if (typeof onCloudLoginSuccess === 'function') {
onCloudLoginSuccess();
}
};
window.addEventListener('message', handleToken);
}
async downloadMap(loadParams) {
const token = this.getAccessToken();
if (!token) {
this.login(() => this.downloadMap(loadParams));
}
const result = await this._dropbox.filesDownload(loadParams);
const json = await this._readFile(result.fileBlob);
const response = {
map: json,
format: 'keplergl'
};
this._loadParam = loadParams;
return response;
}
async listMaps() {
// list files
try {
// https://dropbox.github.io/dropbox-sdk-js/Dropbox.html#filesListFolder__anchor
const response = await this._dropbox.filesListFolder({
path: this._path
});
const {pngs, visualizations} = this._parseEntries(response);
// https://dropbox.github.io/dropbox-sdk-js/Dropbox.html#filesGetThumbnailBatch__anchor
// up to 25 per request
// TODO: implement pagination, so we don't need to get all the thumbs all at once
const thumbnails = await Promise.all(this._getThumbnailRequests(pngs)).then(results =>
results.reduce((accu, r) => [...accu, ...(r.entries || [])], [])
);
// append to visualizations
thumbnails &&
thumbnails.forEach(thb => {
if (thb['.tag'] === 'success' && thb.thumbnail) {
const matchViz = visualizations[pngs[thb.metadata.id] && pngs[thb.metadata.id].name];
if (matchViz) {
matchViz.thumbnail = `${IMAGE_URL_PREFIX}${thb.thumbnail}`;
}
}
});
// dropbox returns
return Object.values(visualizations).reverse();
} catch (error) {
// made the error message human readable for provider updater
throw this._handleDropboxError(error);
}
}
getUserName() {
// load user from
if (window.localStorage) {
const jsonString = window.localStorage.getItem('dropbox');
const user = jsonString && JSON.parse(jsonString).user;
return user;
}
return null;
}
async logout(onCloudLogoutSuccess) {
const token = this._dropbox.getAccessToken();
if (token) {
await this._dropbox.authTokenRevoke();
if (window.localStorage) {
window.localStorage.removeItem('dropbox');
}
// re instantiate dropbox
this._initializeDropbox();
onCloudLogoutSuccess();
}
}
isEnabled() {
return this.clientId !== null;
}
hasPrivateStorage() {
return PRIVATE_STORAGE_ENABLED;
}
hasSharingUrl() {
return SHARING_ENABLED;
}
/**
*
* @param mapData map data and config in one json object {map: {datasets: Array<Object>, config: Object, info: Object}
* @param blob json file blob to upload
* @param fileName if blob doesn't contain a file name, this field is used
* @param isPublic define whether the file will be available publicly once uploaded
* @returns {Promise<DropboxTypes.files.FileMetadata>}
*/
async uploadMap({mapData, options = {}}) {
const {isPublic} = options;
const {map, thumbnail} = mapData;
// generate file name if is not provided
const name = map.info && map.info.title;
const fileName = `${name}.json`;
const fileContent = map;
// FileWriteMode: Selects what to do if the file already exists.
// Always overwrite if sharing
const mode = options.overwrite || isPublic ? 'overwrite' : 'add';
let metadata;
try {
metadata = await this._dropbox.filesUpload({
path: `${this._path}/${fileName}`,
contents: JSON.stringify(fileContent),
mode
});
} catch (err) {
if (isConfigFile(err)) {
throw this.getFileConflictError();
}
}
// save a thumbnail image
thumbnail &&
(await this._dropbox.filesUpload({
path: `${this._path}/${fileName}`.replace(/\.json$/, '.png'),
contents: thumbnail,
mode
}));
// keep on create shareUrl
if (isPublic) {
return await this._shareFile(metadata);
}
// save private map save map url
this._loadParam = {path: metadata.path_lower};
return this._loadParam;
}
/**
* Get the share url of current map, this url can be accessed by anyone
* @param {boolean} fullUrl
*/
getShareUrl(fullUrl = true) {
return fullUrl
? `${window.location.protocol}//${window.location.host}/${MAP_URI}${this._shareUrl}`
: `/${MAP_URI}${this._shareUrl}`;
}
/**
* Get the map url of current map, this url can only be accessed by current logged in user
* @param {boolean} fullUrl
*/
getMapUrl(fullURL = true) {
const {path} = this._loadParam;
const mapLink = `demo/map/dropbox?path=${path}`;
return fullURL
? `${window.location.protocol}//${window.location.host}/${mapLink}`
: `/${mapLink}`;
}
getManagementUrl() {
return this._folderLink;
}
/**
* Provides the current dropbox auth token. If stored in localStorage is set onto dropbox handler and returned
* @returns {any}
*/
getAccessToken() {
let token = this._dropbox.getAccessToken();
if (!token && window.localStorage) {
const jsonString = window.localStorage.getItem('dropbox');
token = jsonString && JSON.parse(jsonString).token;
if (token) {
this._dropbox.setAccessToken(token);
}
}
return (token || '') !== '' ? token : null;
}
/**
* This method will extract the auth token from the third party service callback url.
* @param {object} location the window location provided by react router
* @returns {?string} the token extracted from the oauth 2 callback URL
*/
getAccessTokenFromLocation(location) {
if (!(location && location.hash.length)) {
return null;
}
// dropbox token usually start with # therefore we want to remove the '#'
const query = window.location.hash.substring(1);
return parseQueryString(query).access_token;
}
// PRIVATE
_initializeDropbox() {
this._dropbox = new Dropbox({fetch: window.fetch});
this._dropbox.setClientId(this.clientId);
}
async _getUser() {
let response;
try {
response = await this._dropbox.usersGetCurrentAccount();
} catch (error) {
Console.warn(error);
return null;
}
return this._getUserFromAccount(response);
}
_handleDropboxError(error) {
// dropbox list_folder error
if (error && error.error && error.error.error_summary) {
return `Dropbox Error: ${error.error.error_summary}`;
}
return error;
}
_readFile(fileBlob) {
return new Promise((resolve, reject) => {
const fileReader = new FileReader(fileBlob);
fileReader.onload = ({target: {result}}) => {
try {
const json = JSON.parse(result);
resolve(json);
} catch (err) {
reject(err);
}
};
fileReader.readAsText(fileBlob, 'utf-8');
});
}
// append url after map sharing
_getMapPermalink(mapLink, fullUrl = true) {
return fullUrl
? `${window.location.protocol}//${window.location.host}/${MAP_URI}${mapLink}`
: `/${MAP_URI}${mapLink}`;
}
// append map url after load map from storage, this url is not meant
// to be directly shared with others
_getMapPermalinkFromParams({path}, fullURL = true) {
const mapLink = `demo/map/dropbox?path=${path}`;
return fullURL
? `${window.location.protocol}//${window.location.host}/${mapLink}`
: `/${mapLink}`;
}
/**
* It will set access to file to public
* @param {Object} metadata metadata response from uploading the file
* @returns {Promise<DropboxTypes.sharing.FileLinkMetadataReference | DropboxTypes.sharing.FolderLinkMetadataReference | DropboxTypes.sharing.SharedLinkMetadataReference>}
*/
_shareFile(metadata) {
const shareArgs = {
path: metadata.path_display || metadata.path_lower
};
return this._dropbox
.sharingListSharedLinks(shareArgs)
.then(({links} = {}) => {
if (links && links.length) {
return links[0];
}
return this._dropbox.sharingCreateSharedLinkWithSettings(shareArgs);
})
.then(result => {
// Update URL to avoid CORS issue
// Unfortunately this is not the ideal scenario but it will make sure people
// can share dropbox urls with users without the dropbox account (publish on twitter, facebook)
this._shareUrl = this._overrideUrl(result.url);
return {
// the full url to be displayed
shareUrl: this.getShareUrl(true),
folderLink: this._folderLink
};
});
}
/**
* Generate auth link url to open to be used to handle OAuth2
* @param {string} path
*/
_authLink(path = 'auth') {
return this._dropbox.getAuthenticationUrl(
`${window.location.origin}/${path}`,
btoa(JSON.stringify({handler: 'dropbox', origin: window.location.origin}))
);
}
/**
* Override dropbox cloud-providers url
* https://www.dropbox.com/s/bxwwdb81z0jg7pb/keplergl_2018-11-01T23%3A22%3A43.940Z.json?dl=0
* ->
* https://dl.dropboxusercontent.com/s/bxwwdb81z0jg7pb/keplergl_2018-11-01T23%3A22%3A43.940Z.json
* @param metadata
* @returns {DropboxTypes.sharing.FileLinkMetadataReference}
*/
_overrideUrl(url) {
return url ? url.slice(0, url.indexOf('?')).replace(DOMAIN, CORS_FREE_DOMAIN) : null;
}
_getUserFromAccount(response) {
return response ? (response.name && response.name.abbreviated_name) || response.email : null;
}
_getThumbnailRequests(pngs) {
const batches = Object.values(pngs).reduce((accu, c) => {
const lastBatch = accu.length && accu[accu.length - 1];
if (!lastBatch || lastBatch.length >= MAX_THUMBNAIL_BATCH) {
// add new batch
accu.push([c]);
} else {
lastBatch.push(c);
}
return accu;
}, []);
return batches.map(batch =>
this._dropbox.filesGetThumbnailBatch({
entries: batch.map(img => ({
path: img.path_lower,
format: 'png',
size: 'w128h128'
}))
})
);
}
/**
* Parse fileListFolder result as visualizations to be shown in load storage map modal
* @param {*} response
*/
_parseEntries(response) {
const {entries, cursor, has_more} = response;
if (has_more) {
this._cursor = cursor;
}
const pngs = {};
const visualizations = {};
entries.forEach(entry => {
const {name, path_lower, id, client_modified} = entry;
if (name && name.endsWith('.json')) {
// find json
const title = name.replace(/\.json$/, '');
const viz = {
name,
title,
id,
lastModification: new Date(client_modified).getTime(),
loadParams: {
path: path_lower
}
};
visualizations[title] = viz;
} else if (name && name.endsWith('.png')) {
const title = name.replace(/\.png$/, '');
pngs[id] = {
name: title,
path_lower,
id
};
}
});
return {
visualizations,
pngs
};
}
}