From 98268f21c07eb65cb1ade333c70e6280ede5547d Mon Sep 17 00:00:00 2001 From: Saulo Alves Date: Sun, 11 Jul 2021 00:56:51 +0200 Subject: [PATCH] added teams storage; added tenant id configuration for onedrive and teams --- app/content/oauth-result/teams.html | 16 + app/scripts/const/default-app-settings.js | 8 +- app/scripts/locales/base.json | 1 + app/scripts/storage/impl/storage-onedrive.js | 14 +- app/scripts/storage/impl/storage-teams.js | 372 +++++++++++++++++++ app/scripts/storage/index.js | 2 + app/styles/base/_icon-font.scss | 1 + 7 files changed, 408 insertions(+), 6 deletions(-) create mode 100644 app/content/oauth-result/teams.html create mode 100644 app/scripts/storage/impl/storage-teams.js diff --git a/app/content/oauth-result/teams.html b/app/content/oauth-result/teams.html new file mode 100644 index 00000000..a07583e8 --- /dev/null +++ b/app/content/oauth-result/teams.html @@ -0,0 +1,16 @@ + + + + + KeeWeb + + + + + diff --git a/app/scripts/const/default-app-settings.js b/app/scripts/const/default-app-settings.js index 63df7bb6..eb0b1d4a 100644 --- a/app/scripts/const/default-app-settings.js +++ b/app/scripts/const/default-app-settings.js @@ -87,7 +87,13 @@ const DefaultAppSettings = { onedrive: true, // enable OneDrive integration onedriveClientId: null, // custom OneDrive client id - onedriveClientSecret: null // custom OneDrive client secret + onedriveClientSecret: null, // custom OneDrive client secret + onedriveTenantId: null, // custom OneDrive tenant id + + teams: true, // enable Teams integration + teamsClientId: null, // custom Teams client id + teamsClientSecret: null, // custom Teams client secret + teamsTenantId: null // custom Teams tenant id }; export { DefaultAppSettings }; diff --git a/app/scripts/locales/base.json b/app/scripts/locales/base.json index da4c2812..608d97e4 100644 --- a/app/scripts/locales/base.json +++ b/app/scripts/locales/base.json @@ -43,6 +43,7 @@ "dropbox": "Dropbox", "gdrive": "Google Drive", "onedrive": "OneDrive", + "teams": "Teams", "menuAllItems": "All Items", "menuColors": "Colors", "menuTrash": "Trash", diff --git a/app/scripts/storage/impl/storage-onedrive.js b/app/scripts/storage/impl/storage-onedrive.js index c96b0baf..46ab9a8d 100644 --- a/app/scripts/storage/impl/storage-onedrive.js +++ b/app/scripts/storage/impl/storage-onedrive.js @@ -221,22 +221,26 @@ class StorageOneDrive extends StorageBase { _getOAuthConfig() { let clientId = this.appSettings.onedriveClientId; let clientSecret = this.appSettings.onedriveClientSecret; + let tenant = this.appSettings.onedriveTenantId; + if (!clientId) { if (Features.isDesktop) { - ({ id: clientId, secret: clientSecret } = OneDriveApps.Desktop); + ({ id: clientId, secret: clientSecret, tenantId: tenant } = OneDriveApps.Desktop); } else if (Features.isLocal) { - ({ id: clientId, secret: clientSecret } = OneDriveApps.Local); + ({ id: clientId, secret: clientSecret, tenantId: tenant } = OneDriveApps.Local); } else { - ({ id: clientId, secret: clientSecret } = OneDriveApps.Production); + ({ id: clientId, secret: clientSecret, tenantId: tenant } = OneDriveApps.Production); } } + tenant = tenant || 'common'; + let scope = 'files.readwrite'; if (!this.appSettings.shortLivedStorageToken) { scope += ' offline_access'; } return { - url: 'https://login.microsoftonline.com/common/oauth2/v2.0/authorize', - tokenUrl: 'https://login.microsoftonline.com/common/oauth2/v2.0/token', + url: `https://login.microsoftonline.com/${tenant}/oauth2/v2.0/authorize`, + tokenUrl: `https://login.microsoftonline.com/${tenant}/oauth2/v2.0/token`, scope, clientId, clientSecret, diff --git a/app/scripts/storage/impl/storage-teams.js b/app/scripts/storage/impl/storage-teams.js new file mode 100644 index 00000000..861486b0 --- /dev/null +++ b/app/scripts/storage/impl/storage-teams.js @@ -0,0 +1,372 @@ +import { StorageBase } from 'storage/storage-base'; +import { TeamsApps } from 'const/cloud-storage-apps'; +import { Features } from 'util/features'; + +// https://docs.microsoft.com/en-us/azure/active-directory/develop/v2-oauth2-auth-code-flow + +// https://graph.microsoft.com/v1.0/me/transitiveMemberOf/microsoft.graph.group?$count=true +// https://graph.microsoft.com/v1.0/groups?$filter=groupTypes/any(c:c+eq+'Unified') +// https://graph.microsoft.com/v1.0/groups?$filter=groupTypes/any(c:c+eq+'Unified')&$orderby=displayName +// https://graph.microsoft.com/v1.0/groups?$orderby=displayName +// /me/joinedTeams +// https://graph.microsoft.com/v1.0/groups/{group id}/drive/root/children + +class StorageTeams extends StorageBase { + name = 'teams'; + enabled = true; + uipos = 50; + icon = 'user-friends'; + + _graphUrl = 'https://graph.microsoft.com/v1.0'; + _groupsUrl = `${this._graphUrl}/me/joinedTeams`; + _baseUrl = `${this._graphUrl}/groups`; + + getPathForName(fileName) { + return '/drive/root:/' + fileName + '.kdbx'; + } + + genUrlAddress(groupId, path) { + if (groupId) { + return this._baseUrl + '/' + groupId + (path ? '/' + path.replace(/^\/+/, '') : ''); + } else { + return this._groupsUrl; + } + } + + genUrl(path) { + // console.warn('genUrl', path); + if (!path) { + const groupId = null; + const dir = null; + const url = this.genUrlAddress(groupId, dir); + return [groupId, dir, url]; + } + + const parts = path.replace(/^\/+/, '').split('/'); + if (parts.length === 0) { + const groupId = null; + const dir = null; + const url = this.genUrlAddress(groupId, dir); + return [groupId, dir, url]; + } else if (parts.length === 1) { + const groupId = parts[0]; + const dir = null; + const url = this.genUrlAddress(groupId, dir); + return [groupId, dir, url]; + } else { + path = path.replace(/\/drive\/root\:/, ''); + const groupId = parts[0]; + const dir = ('/' + parts.slice(1).join('/')).replace(/^\/+/, ''); + const url = this.genUrlAddress(groupId, dir); + return [groupId, dir, url]; + } + } + + load(path, opts, callback) { + this._oauthAuthorize((err) => { + if (err) { + return callback && callback(err); + } + this.logger.debug('Load', path); + const ts = this.logger.ts(); + + const urlParts = this.genUrl(path); + const groupId = urlParts[0]; + path = urlParts[1]; + const url = urlParts[2]; + if (!groupId) { + const err = 'no group id defined'; + return callback && callback(err); + } + + this._xhr({ + url, + responseType: 'json', + success: (response) => { + const downloadUrl = response['@microsoft.graph.downloadUrl']; + let rev = response.eTag; + if (!downloadUrl || !response.eTag) { + this.logger.debug( + 'Load error', + path, + 'no download url', + response, + this.logger.ts(ts) + ); + return callback && callback('no download url'); + } + this._xhr({ + url: downloadUrl, + responseType: 'arraybuffer', + skipAuth: true, + success: (response, xhr) => { + rev = xhr.getResponseHeader('ETag') || rev; + this.logger.debug('Loaded', path, rev, this.logger.ts(ts)); + return callback && callback(null, response, { rev }); + }, + error: (err) => { + this.logger.error('Load error', path, err, this.logger.ts(ts)); + return callback && callback(err); + } + }); + }, + error: (err) => { + this.logger.error('Load error', path, err, this.logger.ts(ts)); + return callback && callback(err); + } + }); + }); + } + + stat(path, opts, callback) { + this._oauthAuthorize((err) => { + if (err) { + return callback && callback(err); + } + this.logger.debug('Stat', path); + const ts = this.logger.ts(); + + const urlParts = this.genUrl(path); + const groupId = urlParts[0]; + path = urlParts[1]; + const url = urlParts[2]; + if (!groupId) { + const err = 'no group id defined'; + return callback && callback(err); + } + + this._xhr({ + url, + responseType: 'json', + success: (response) => { + const rev = response.eTag; + if (!rev) { + this.logger.error('Stat error', path, 'no eTag', this.logger.ts(ts)); + return callback && callback('no eTag'); + } + this.logger.debug('Stated', path, rev, this.logger.ts(ts)); + return callback && callback(null, { rev }); + }, + error: (err, xhr) => { + if (xhr.status === 404) { + this.logger.debug('Stated not found', path, this.logger.ts(ts)); + return callback && callback({ notFound: true }); + } + this.logger.error('Stat error', path, err, this.logger.ts(ts)); + return callback && callback(err); + } + }); + }); + } + + save(path, opts, data, callback, rev) { + this._oauthAuthorize((err) => { + if (err) { + return callback && callback(err); + } + this.logger.debug('Save', path, rev); + const ts = this.logger.ts(); + + const urlParts = this.genUrl(path); + const groupId = urlParts[0]; + path = urlParts[1]; + const url = urlParts[2] + ':/content'; + if (!groupId) { + const err = 'no group id defined'; + return callback && callback(err); + } + + this._xhr({ + url, + method: 'PUT', + responseType: 'json', + headers: rev ? { 'If-Match': rev } : null, + data, + statuses: [200, 201, 412], + success: (response, xhr) => { + rev = response.eTag; + if (!rev) { + this.logger.error('Save error', path, 'no eTag', this.logger.ts(ts)); + return callback && callback('no eTag'); + } + if (xhr.status === 412) { + this.logger.debug('Save conflict', path, rev, this.logger.ts(ts)); + return callback && callback({ revConflict: true }, { rev }); + } + this.logger.debug('Saved', path, rev, this.logger.ts(ts)); + return callback && callback(null, { rev }); + }, + error: (err) => { + this.logger.error('Save error', path, err, this.logger.ts(ts)); + return callback && callback(err); + } + }); + }); + } + + list(dir, callback) { + this._oauthAuthorize((err) => { + if (err) { + return callback && callback(err); + } + this.logger.debug('List', dir); + const ts = this.logger.ts(); + + // console.warn('dir ', dir); + const urlParts = this.genUrl(dir); + const groupId = urlParts[0]; + dir = urlParts[1]; + const urlPath = groupId ? (dir ? ':/children' : '/drive/root/children') : ''; + const url = urlParts[2] + urlPath; + // console.warn('urlParts', urlParts); + // console.warn('groupId ', groupId); + // console.warn('dir ', dir); + // console.warn('urlPath ', urlPath); + // console.warn('url ', url); + + const self = this; + self._groupId = groupId; + + this._xhr({ + url, + responseType: 'json', + success: (response) => { + if (!response || !response.value) { + this.logger.error('List error', this.logger.ts(ts), response); + return callback && callback('list error'); + } + this.logger.debug('Listed', this.logger.ts(ts)); + let fileList; + if (!self._groupId) { + fileList = response.value + .filter((f) => f.displayName) + .map((f) => ({ + name: f.displayName, + path: '/' + f.id, + rev: f.id, + dir: true + })); + } else { + fileList = response.value + .filter((f) => f.name) + .map((f) => ({ + name: f.name, + path: `/${self._groupId}${f.parentReference.path}/${f.name}`, + rev: f.eTag, + dir: !!f.folder + })); + } + return callback && callback(null, fileList); + }, + error: (err) => { + this.logger.error('List error', this.logger.ts(ts), err); + return callback && callback(err); + } + }); + }); + } + + remove(path, callback) { + this.logger.debug('Remove', path); + const ts = this.logger.ts(); + + const urlParts = this.genUrl(path); + const groupId = urlParts[0]; + path = urlParts[1]; + const url = urlParts[2]; + if (!groupId) { + const err = 'no group id defined'; + return callback && callback(err); + } + + this._xhr({ + url, + method: 'DELETE', + responseType: 'json', + statuses: [200, 204], + success: () => { + this.logger.debug('Removed', path, this.logger.ts(ts)); + return callback && callback(); + }, + error: (err) => { + this.logger.error('Remove error', path, err, this.logger.ts(ts)); + return callback && callback(err); + } + }); + } + + mkdir(path, callback) { + this._oauthAuthorize((err) => { + if (err) { + return callback && callback(err); + } + this.logger.debug('Make dir', path); + const ts = this.logger.ts(); + + const urlParts = this.genUrl(path); + const groupId = urlParts[0]; + path = urlParts[1]; + const url = urlParts[2] + '/drive/root/children'; + if (!groupId) { + const err = 'no group id defined'; + return callback && callback(err); + } + + const data = JSON.stringify({ name: path.replace('/drive/root:/', ''), folder: {} }); + this._xhr({ + url, + method: 'POST', + responseType: 'json', + statuses: [200, 204], + data, + dataType: 'application/json', + success: () => { + this.logger.debug('Made dir', path, this.logger.ts(ts)); + return callback && callback(); + }, + error: (err) => { + this.logger.error('Make dir error', path, err, this.logger.ts(ts)); + return callback && callback(err); + } + }); + }); + } + + logout(enabled) { + this._oauthRevokeToken(); + } + + _getOAuthConfig() { + let clientId = this.appSettings.teamsClientId; + let clientSecret = this.appSettings.teamsClientSecret; + let tenant = this.appSettings.teamsTenantId; + + if (!clientId) { + if (Features.isDesktop) { + ({ id: clientId, secret: clientSecret, tenantId: tenant } = TeamsApps.Desktop); + } else if (Features.isLocal) { + ({ id: clientId, secret: clientSecret, tenantId: tenant } = TeamsApps.Local); + } else { + ({ id: clientId, secret: clientSecret, tenantId: tenant } = TeamsApps.Production); + } + } + tenant = tenant || 'common'; + + let scope = 'Sites.ReadWrite.All Team.ReadBasic.All'; + if (!this.appSettings.shortLivedStorageToken) { + scope += ' offline_access'; + } + return { + url: `https://login.microsoftonline.com/${tenant}/oauth2/v2.0/authorize`, + tokenUrl: `https://login.microsoftonline.com/${tenant}/oauth2/v2.0/token`, + scope, + clientId, + clientSecret, + pkce: true, + width: 600, + height: 500 + }; + } +} + +export { StorageTeams }; diff --git a/app/scripts/storage/index.js b/app/scripts/storage/index.js index 3b323174..b6ed5ac8 100644 --- a/app/scripts/storage/index.js +++ b/app/scripts/storage/index.js @@ -5,6 +5,7 @@ import { StorageFile } from 'storage/impl/storage-file'; import { StorageFileCache } from 'storage/impl/storage-file-cache'; import { StorageGDrive } from 'storage/impl/storage-gdrive'; import { StorageOneDrive } from 'storage/impl/storage-onedrive'; +import { StorageTeams } from 'storage/impl/storage-teams'; import { StorageWebDav } from 'storage/impl/storage-webdav'; import { createOAuthSession } from 'storage/pkce'; @@ -17,6 +18,7 @@ const ThirdPartyStorage = { dropbox: new StorageDropbox(), gdrive: new StorageGDrive(), onedrive: new StorageOneDrive(), + teams: new StorageTeams(), webdav: new StorageWebDav() }; diff --git a/app/styles/base/_icon-font.scss b/app/styles/base/_icon-font.scss index ea92578a..2beaa553 100644 --- a/app/styles/base/_icon-font.scss +++ b/app/styles/base/_icon-font.scss @@ -82,6 +82,7 @@ $fa-var-file-image: next-fa-glyph(); $fa-var-file-video: next-fa-glyph(); $fa-var-file-audio: next-fa-glyph(); $fa-var-onedrive: next-fa-glyph(); +$fa-var-user-friends: next-fa-glyph(); $fa-var-question: next-fa-glyph(); $fa-var-sign-out-alt: next-fa-glyph(); $fa-var-sync-alt: next-fa-glyph();