diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index c8e21a6692..e42039a35a 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -36,6 +36,7 @@ body: - Linkwarden - WebDAV - Google Drive + - OneDrive - Git validations: required: true diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 9a3ef57d2b..89670cca53 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -100,6 +100,8 @@ jobs: - git-html - google-drive - google-drive-encrypted + - one-drive + - one-drive-encrypted - linkwarden - karakeep test-name: @@ -286,6 +288,7 @@ jobs: FLOCCUS_TEST_SEED: ${{ github.sha }} GIST_TOKEN: ${{ secrets.GIST_TOKEN }} GOOGLE_API_REFRESH_TOKEN: ${{ secrets.GOOGLE_API_REFRESH_TOKEN }} + MICROSOFT_API_REFRESH_TOKEN: ${{ secrets.MICROSOFT_API_REFRESH_TOKEN }} LINKWARDEN_TOKEN: ${{ secrets.LINKWARDEN_TOKEN }} APP_VERSION: ${{ matrix.app-version }} KARAKEEP_TEST_HOST: 172.17.0.1:3000 diff --git a/README.md b/README.md index 32bb43cf6a..526c63f4df 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ [](https://github.com/marcelklehr/floccus/actions?query=workflow%3ATests) - 🔖 Syncs your real, native browser bookmarks directly -- ☸ Sync via [Nextcloud Bookmarks](https://github.com/nextcloud/bookmarks), [Linkwarden](https://linkwarden.app/), [KaraKeep](https://karakeep.app/), Google Drive, any Git server (like GitHub, Gitlab, Gitea, etc.) or [any WebDAV-compatible service](https://community.cryptomator.org/t/webdav-urls-of-common-cloud-storage-services/75) +- ☸ Sync via [Nextcloud Bookmarks](https://github.com/nextcloud/bookmarks), [Linkwarden](https://linkwarden.app/), [KaraKeep](https://karakeep.app/), Google Drive, OneDrive, any Git server (like GitHub, Gitlab, Gitea, etc.) or [any WebDAV-compatible service](https://community.cryptomator.org/t/webdav-urls-of-common-cloud-storage-services/75) - ⚛ Use any browser that supports Web extensions (e.g. Firefox, Chrome, Edge, Opera, Brave, Vivaldi, ...; Safari [not yet](https://github.com/floccusaddon/floccus/issues/23)) - 📲 Install the floccus Android/iOS app to access your bookmarks on your phone (Most mobile browsers do not support floccus, sadly) - 💼 Create as many sync profiles as you need diff --git a/_locales/en/messages.json b/_locales/en/messages.json index 974e4d9602..6ffcc5124b 100644 --- a/_locales/en/messages.json +++ b/_locales/en/messages.json @@ -146,6 +146,15 @@ "Error050": { "message": "E050: Failsafe: The current sync run would delete {0}% of your local links in this profile. Refusing to execute. Disable this failsafe in the profile settings if you want to proceed anyway." }, + "Error051": { + "message": "E051: Could not authenticate with OneDrive. Please connect floccus with your Microsoft account again." + }, + "Error052": { + "message": "E052: OAuth error. Token validation error. Please reconnect your Microsoft Account." + }, + "Error053": { + "message": "E053: Could not search for your file name in your OneDrive" + }, "LabelWebdavurl": { "message": "WebDAV URL" }, @@ -169,6 +178,9 @@ }, "DescriptionBookmarksfilegoogle": { "message": "the file name of the bookmarks file that will reside in your Google Drive. Do not enter the full file path, only the file name. Make sure this name is unique in your Drive. e.g. mybookmarks.xbel" + }, + "DescriptionBookmarksfileonedrive": { + "message": "the file name of the bookmarks file that will reside in your OneDrive. Do not enter the full file path, only the file name. Make sure this name is unique in your Drive. e.g. mybookmarks.xbel" }, "DescriptionBookmarksfilegit": { "message": "a path to the bookmarks file relative to your Git repository root (all folders in the path must already exist). e.g. personal_stuff/bookmarks.xbel" @@ -567,6 +579,21 @@ "DescriptionLoggedingoogle": { "message": "You have connected your Google account to store the bookmark sync file in your Google Drive." }, + "LabelAdapteronedrive": { + "message": "OneDrive" + }, + "DescriptionAdapteronedrive": { + "message": "Sync bookmarks via an (optionally encrypted) file that is stored in your OneDrive. It can sync http, ftp, data, file and javascript bookmarks. You can choose to use end-to-end encryption when using this option." + }, + "LabelLoginonedrive": { + "message": "Login with OneDrive" + }, + "DescriptionLoginmicrosoft": { + "message": "Connect your Microsoft account to store the bookmark sync file in your OneDrive." + }, + "DescriptionLoggedinmicrosoft": { + "message": "You have connected your Microsoft account to store the bookmark sync file in your OneDrive." + }, "LabelPassphrase": { "message": "Passphrase" }, @@ -699,6 +726,9 @@ "LabelGoogledrivesetup": { "message": "Login to Google Drive" }, + "LabelOnedrivesetup": { + "message": "Login to OneDrive" + }, "LabelSyncfoldersetup": { "message": "Which Folders do you want to sync?" }, diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 9d26371e89..a660562964 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -32,6 +32,7 @@ + diff --git a/fastlane/metadata/android/en-US/full_description.txt b/fastlane/metadata/android/en-US/full_description.txt index 303c77ca70..43df597d45 100644 --- a/fastlane/metadata/android/en-US/full_description.txt +++ b/fastlane/metadata/android/en-US/full_description.txt @@ -1,4 +1,4 @@ -Manage and synchronize your bookmarks via Nextcloud, or any WebDAV service, or any Git service, or Google Drive, end-to-end encrypted, if you want. +Manage and synchronize your bookmarks via Nextcloud, or any WebDAV service, or any Git service, or Google Drive, or OneDrive, end-to-end encrypted, if you want. This is the standalone bookmarks manager android app variant of floccus. You can also install floccus on your Desktop browsers to sync bookmarks with them. This App, due to technical reasons, cannot access bookmarks in your mobile browser apps directly, which is why you can only view them in the app or import and export them as a html file. diff --git a/google-api.credentials.json b/google-api.credentials.json index 852bf13182..6c9efbcede 100644 --- a/google-api.credentials.json +++ b/google-api.credentials.json @@ -1,11 +1,28 @@ { - "web":{ - "client_id":"305459871054-4rr6n0jmsdvvprtjqbma5oeksshis2bn.apps.googleusercontent.com","project_id":"floccus-1613668481464","auth_uri":"https://accounts.google.com/o/oauth2/auth","token_uri":"https://oauth2.googleapis.com/token","auth_provider_x509_cert_url":"https://www.googleapis.com/oauth2/v1/certs","client_secret":"-2C9DALj2JYEGhZMZTvzN2ZE","redirect_uris":["https://mbepccofdnoepgicagpchfmafecckdam.chromiumapp.org/","https://76a380c4950986998208e7bb9dbd8fea94c91504.extensions.allizom.org/"] + "web": { + "client_id": "305459871054-4rr6n0jmsdvvprtjqbma5oeksshis2bn.apps.googleusercontent.com", + "project_id": "floccus-1613668481464", + "auth_uri": "https://accounts.google.com/o/oauth2/auth", + "token_uri": "https://oauth2.googleapis.com/token", + "auth_provider_x509_cert_url": "https://www.googleapis.com/oauth2/v1/certs", + "client_secret": "-2C9DALj2JYEGhZMZTvzN2ZE", + "redirect_uris": [ + "https://mbepccofdnoepgicagpchfmafecckdam.chromiumapp.org/", + "https://76a380c4950986998208e7bb9dbd8fea94c91504.extensions.allizom.org/" + ] }, - "android":{ - "client_id":"305459871054-05e7kf9q9kkbeovaf380ldsb248psc2d.apps.googleusercontent.com","project_id":"floccus-1613668481464","auth_uri":"https://accounts.google.com/o/oauth2/auth","token_uri":"https://oauth2.googleapis.com/token","auth_provider_x509_cert_url":"https://www.googleapis.com/oauth2/v1/certs","redirect_uris":["urn:ietf:wg:oauth:2.0:oob","http://localhost"] + "android": { + "client_id": "305459871054-05e7kf9q9kkbeovaf380ldsb248psc2d.apps.googleusercontent.com", + "project_id": "floccus-1613668481464", + "auth_uri": "https://accounts.google.com/o/oauth2/auth", + "token_uri": "https://oauth2.googleapis.com/token", + "auth_provider_x509_cert_url": "https://www.googleapis.com/oauth2/v1/certs", + "redirect_uris": [ + "urn:ietf:wg:oauth:2.0:oob", + "http://localhost" + ] }, "ios": { "client_id": "305459871054-ovvunbhc8jf8g467gtpsbnap5el302gq.apps.googleusercontent.com" } -} +} \ No newline at end of file diff --git a/gulpfile.js b/gulpfile.js index 78f1ca95e9..d35a6c6e82 100644 --- a/gulpfile.js +++ b/gulpfile.js @@ -13,20 +13,13 @@ var path = require('path') try { fs.accessSync('./google-api.credentials.json') } catch (e) { - fs.writeFileSync('./google-api.credentials.json', JSON.stringify({ - 'web': { - 'client_id': 'yourappidhere.apps.googleusercontent.com', - 'project_id': 'YOUR PROJECT ID HERE', - 'auth_uri': 'https://accounts.google.com/o/oauth2/auth', - 'token_uri': 'https://oauth2.googleapis.com/token', - 'auth_provider_x509_cert_url': 'https://www.googleapis.com/oauth2/v1/certs', - 'client_secret': 'YOUR CLIENT SECRET HERE', - 'redirect_uris': [ - 'https://yourappidhere.chromiumapp.org/', - 'https://yourappidhere.extensions.allizom.org/' - ] - } - })) + console.log(`error loading google api credentials: ${e.message}`) +} + +try { + fs.accessSync('./onedrive-api.credentials.json') +} catch (e) { + console.log(`error loading onedrive api credentials: ${e.message}`) } // eslint-disable-next-line @typescript-eslint/no-var-requires diff --git a/manifest.firefox.json b/manifest.firefox.json index 17846c4d1d..46372194e9 100644 --- a/manifest.firefox.json +++ b/manifest.firefox.json @@ -25,13 +25,11 @@ "options_ui": { "page": "dist/html/options.html", - "browser_style": false, - "chrome_style": false + "browser_style": false }, "browser_action": { "browser_style": false, - "chrome_style": false, "default_icon": { "48": "icons/logo.png" }, diff --git a/onedrive-api.credentials.json b/onedrive-api.credentials.json new file mode 100644 index 0000000000..b6e7949abd --- /dev/null +++ b/onedrive-api.credentials.json @@ -0,0 +1,27 @@ +{ + "web": { + "client_id": "635108ad-2d58-480a-a6cf-7955020a76a1", + "project_id": "635108ad-2d58-480a-a6cf-7955020a76a1", + "auth_uri": "https://login.microsoftonline.com/common/oauth2/v2.0/authorize", + "token_uri": "https://login.microsoftonline.com/common/oauth2/v2.0/token", + "client_secret": "", + "redirect_uris": [ + "https://mbepccofdnoepgicagpchfmafecckdam.chromiumapp.org/", + "https://76a380c4950986998208e7bb9dbd8fea94c91504.extensions.allizom.org/", + "https://fnaicdffflnofjppbagibeoednhnbjhg.chromiumapp.org/", + "https://gjkddcofhiifldbllobcamllmanombji.chromiumapp.org/", + "https://djejpebekaoommcjfeaiipdikmdjkblg.chromiumapp.org/" + ] + }, + "android": { + "client_id": "635108ad-2d58-480a-a6cf-7955020a76a1", + "project_id": "635108ad-2d58-480a-a6cf-7955020a76a1", + "auth_uri": "https://login.microsoftonline.com/common/oauth2/v2.0/authorize", + "token_uri": "https://login.microsoftonline.com/common/oauth2/v2.0/token", + "redirect_uri": "org.handmadeideas.floccus://auth" + }, + "ios": { + "client_id": "635108ad-2d58-480a-a6cf-7955020a76a1", + "redirect_uri": "org.handmadeideas.floccus://auth" + } +} \ No newline at end of file diff --git a/src/errors/Error.ts b/src/errors/Error.ts index 0d7fca3a8a..b9b3f11c70 100644 --- a/src/errors/Error.ts +++ b/src/errors/Error.ts @@ -283,11 +283,11 @@ export class GoogleDriveAuthenticationError extends FloccusError { } } -export class OAuthTokenError extends FloccusError { +export class GoogleOAuthTokenError extends FloccusError { public readonly code = 32 constructor() { super('E032: OAuth error. Token validation error. Please reconnect your Google Account.') - Object.setPrototypeOf(this, OAuthTokenError.prototype) + Object.setPrototypeOf(this, GoogleOAuthTokenError.prototype) } } @@ -462,4 +462,28 @@ export class ClientsideDeletionFailsafeError extends FloccusError { this.percent = percent Object.setPrototypeOf(this, ClientsideDeletionFailsafeError.prototype) } -} \ No newline at end of file +} + +export class OneDriveAuthenticationError extends FloccusError { + public readonly code = 51 + constructor() { + super('E051: Could not authenticate with OneDrive. Please connect floccus with your OneDrive account again.') + Object.setPrototypeOf(this, OneDriveAuthenticationError.prototype) + } +} + +export class OneDriveOAuthTokenError extends FloccusError { + public readonly code = 52 + constructor() { + super('E052: OAuth error. Token validation error. Please reconnect your OneDrive Account.') + Object.setPrototypeOf(this, OneDriveOAuthTokenError.prototype) + } +} + +export class OneDriveSearchError extends FloccusError { + public readonly code = 53 + constructor() { + super('E053: Could not search for your file name in your OneDrive') + Object.setPrototypeOf(this, OneDriveSearchError.prototype) + } +} diff --git a/src/lib/Account.ts b/src/lib/Account.ts index 11822aa0c5..7096a360e3 100644 --- a/src/lib/Account.ts +++ b/src/lib/Account.ts @@ -30,7 +30,8 @@ AdapterFactory.register('nextcloud-folders', async() => (await import('./adapter AdapterFactory.register('nextcloud-bookmarks', async() => (await import('./adapters/NextcloudBookmarks')).default) AdapterFactory.register('webdav', async() => (await import('./adapters/WebDav')).default) AdapterFactory.register('git', async() => (await import('./adapters/Git')).default) -AdapterFactory.register('google-drive', async() => (await import('./adapters/GoogleDrive')).default) +AdapterFactory.register('google-drive', async () => (await import('./adapters/GoogleDrive')).default) +AdapterFactory.register('one-drive', async() => (await import('./adapters/OneDrive')).default) AdapterFactory.register('fake', async() => (await import('./adapters/Fake')).default) // 2h diff --git a/src/lib/Crypto.ts b/src/lib/Crypto.ts index 12d554e803..e5cdeab9a0 100644 --- a/src/lib/Crypto.ts +++ b/src/lib/Crypto.ts @@ -101,4 +101,19 @@ export default class Crypto { crypto.getRandomValues(rand) return rand } + + static base64UrlEncode(data: ArrayBuffer | Uint8Array) { + const bytes = data instanceof Uint8Array ? data : new Uint8Array(data) + + return btoa(String.fromCharCode(...bytes)) + .replace(/\+/g, '-') + .replace(/\//g, '_') + .replace(/=+$/, '') + } + + static async generatePKCECodeChallenge(verifier: string) { + const data = new TextEncoder().encode(verifier) + const digest = await crypto.subtle.digest('SHA-256', data) + return this.base64UrlEncode(digest) + } } diff --git a/src/lib/adapters/GoogleDrive.ts b/src/lib/adapters/GoogleDrive.ts index b9634a6029..c24ca2b053 100644 --- a/src/lib/adapters/GoogleDrive.ts +++ b/src/lib/adapters/GoogleDrive.ts @@ -8,7 +8,7 @@ import { DecryptionError, FileUnreadableError, GoogleDriveAuthenticationError, HttpError, CancelledSyncError, MissingPermissionsError, NetworkError, - OAuthTokenError, ResourceLockedError, GoogleDriveSearchError, RequestTimeoutError + GoogleOAuthTokenError, ResourceLockedError, GoogleDriveSearchError, RequestTimeoutError } from '../../errors/Error' import { OAuth2Client } from '@byteowls/capacitor-oauth2' import { Capacitor, CapacitorHttp as Http } from '@capacitor/core' @@ -123,12 +123,12 @@ export default class GoogleDriveAdapter extends CachingAdapter { if (response.status !== 200) { Logger.log('Failed to retrieve refresh token from Google API: ' + await response.text()) - throw new OAuthTokenError() + throw new GoogleOAuthTokenError() } const json = await response.json() if (!json.access_token || !json.refresh_token) { Logger.log('Failed to retrieve refresh token from Google API: ' + JSON.stringify(json)) - throw new OAuthTokenError() + throw new GoogleOAuthTokenError() } const res = await fetch('https://www.googleapis.com/drive/v3/about?fields=user/displayName', { @@ -162,7 +162,7 @@ export default class GoogleDriveAdapter extends CachingAdapter { if (json.access_token) { return json.access_token } else { - throw new OAuthTokenError() + throw new GoogleOAuthTokenError() } } diff --git a/src/lib/adapters/OneDrive.ts b/src/lib/adapters/OneDrive.ts new file mode 100644 index 0000000000..80b981ad8d --- /dev/null +++ b/src/lib/adapters/OneDrive.ts @@ -0,0 +1,908 @@ +import CachingAdapter from './Caching' +import Logger from '../Logger' +import XbelSerializer from '../serializers/Xbel' +import Crypto from '../Crypto' +import Credentials from '../../../onedrive-api.credentials.json' +import { + AuthenticationError, + DecryptionError, FileUnreadableError, + OneDriveAuthenticationError, HttpError, CancelledSyncError, MissingPermissionsError, + NetworkError, + ParseResponseError, + OneDriveOAuthTokenError, ResourceLockedError, OneDriveSearchError, RequestTimeoutError +} from '../../errors/Error' +import { OAuth2Client } from '@byteowls/capacitor-oauth2' +import { Capacitor, CapacitorHttp as Http } from '@capacitor/core' +import { Folder, TItemLocation } from '../Tree' + +declare const IS_BROWSER: boolean + +// Microsoft OneDrive documentation +// https://learn.microsoft.com/en-us/onedrive/developer/?view=odsp-graph-online + +const scopes = [ + 'Files.ReadWrite', // Type: Delegated, Description: Have full access to user files + 'User.Read', // Type: Delegated, Description: Sign in and read user profile + 'offline_access', // Type: Delegated, Description: Maintain access to data you have given it access to + 'openid', // Type: Delegated, Description: Sign users in + 'profile', // Type: Delegated, Description: View users' basic profile +] +const oAuthBaseUrl = 'https://login.microsoftonline.com/common/oauth2/v2.0/' +const apiBaseUrl = 'https://graph.microsoft.com/v1.0/' +const origins = ['https://login.microsoftonline.com/', 'https://graph.microsoft.com/']; + +const OAuthConfig = { + authorizationBaseUrl: oAuthBaseUrl + '/authorize', + accessTokenEndpoint: oAuthBaseUrl + '/token', + scope: scopes.join(' '), + resourceUrl: apiBaseUrl + '/me', + logsEnabled: true, + android: { + appId: Credentials.android.client_id, + responseType: 'code', + redirectUrl: Credentials.android.redirect_uri // setup the same url in redirect urls in azure portal under mobile and desktop + }, + ios: { + appId: Credentials.ios.client_id, + responseType: 'code', + redirectUrl: Credentials.ios.redirect_uri + } +} + +interface CustomResponse { + status: number, + json(): Promise, + text(): Promise, +} + +type OneDriveTokenCache = { + accessToken: string + refreshToken?: string + expiresAt: number + scope?: string +} + +declare const chrome: any + +const LOCK_INTERVAL = 2 * 60 * 1000 // Lock every two minutes while syncing +const LOCK_TIMEOUT = 15 * 60 * 1000 // Override lock 15min after last time it was set +const HTTP_TIMEOUT = 60000 +export default class OneDriveAdapter extends CachingAdapter { + private initialTreeHash: string + private fileId: string + private accessToken: string + private cancelCallback: () => void = null + private alwaysUpload = false + private lockingInterval: any + private locked = false + private lockingPromise: Promise + private tokenCache: OneDriveTokenCache | null = null + + constructor(server) { + super(server) + this.server = server + } + + /** + * User authorizes webapp to make changes on your behalf + * @param {boolean} interactive + * @returns + */ + static async authorize(interactive = true) { + if (!IS_BROWSER) { + const result = await OAuth2Client.authenticate(OAuthConfig) + const refresh_token = result.access_token_response.refresh_token + const username = result.displayName + return { refresh_token, username } + } else { + const browser = (await import('../browser-api')).default + const { isOrion } = await browser.storage.local.get({ 'isOrion': false }) + if (!(await browser.permissions.contains({ origins })) && !isOrion) { + throw new MissingPermissionsError() + } + + const verifier = Crypto.base64UrlEncode(Crypto.getRandomBytes(32)) + const challenge = await Crypto.generatePKCECodeChallenge(verifier) + + const state = Crypto.bufferToHexstr(await Crypto.getRandomBytes(128)).substr(0, 64) + const redirectURL = chrome.identity.getRedirectURL() + let authURL = oAuthBaseUrl + '/authorize' + authURL += `?client_id=${Credentials.web.client_id}` + authURL += `&response_type=code` + authURL += `&redirect_uri=${encodeURIComponent(redirectURL)}` + authURL += `&scope=${encodeURIComponent(scopes.join(' '))}` + authURL += `&code_challenge=${challenge}` + authURL += `&code_challenge_method=S256`; + authURL += `&state=${state}` + authURL += `&prompt=consent`; + + const redirectResult = await browser.identity.launchWebAuthFlow({ + interactive, + url: authURL + }) + + const m = redirectResult.match(/[#?](.*)/) + if (!m || m.length < 1) + return null + const params = new URLSearchParams(m[1].split('#')[0]) + const code = params.get('code') + const resState = params.get('state') + + if (!code) { + throw new Error('Authorization failure') + } + if (resState !== state) { + throw new Error('Authorization failure: State param does not match') + } + + const response = await fetch(oAuthBaseUrl + '/token', { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded' + }, + body: `code=${code}` + + `&client_id=${Credentials.web.client_id}` + + `&redirect_uri=${encodeURIComponent(chrome.identity.getRedirectURL())}` + + `&code_verifier=${verifier}` + + '&grant_type=authorization_code' + }) + + if (response.status !== 200) { + Logger.log('Failed to retrieve refresh token from Microsoft API: ' + await response.text()) + throw new OneDriveOAuthTokenError() + } + + let json: any + try { + json = await response.json() + } catch (e) { + throw new ParseResponseError(e.message) + } + + if (!json.access_token || !json.refresh_token) { + Logger.log('Failed to retrieve refresh token from Microsoft API: ' + JSON.stringify(json)) + throw new OneDriveOAuthTokenError() + } + + const res = await fetch(apiBaseUrl + '/me', { + method: 'GET', + headers: { + Authorization: 'Bearer ' + json.access_token, + 'Content-Type': 'application/json' + }, + }) + + let about: any + try { + about = await res.json() + } catch (e) { + throw new ParseResponseError(e.message) + } + + return { refresh_token: json.refresh_token, username: about.displayName } + } + } + + async getAccessToken(refreshToken: string) { + // Try cache first + const token = await this.loadToken() + + if (token && this.isTokenValid(token)) { + Logger.log('OneDrive: using cached access token') + return token.accessToken + } + + Logger.log('OneDrive: refreshing access token') + + const platform = Capacitor.getPlatform() + + const response = await this.request('POST', oAuthBaseUrl + '/token', + { + refresh_token: refreshToken, + client_id: Credentials[platform].client_id, + grant_type: 'refresh_token', + }, + 'application/x-www-form-urlencoded' + ) + + if (response.status !== 200) { + Logger.log('Failed to retrieve access token from Microsoft API: ' + await response.text()) + throw new OneDriveAuthenticationError() + } + + let json: any + try { + json = await response.json() + } catch (e) { + throw new ParseResponseError(e.message) + } + + if (json.access_token) { + const expiresIn = json.expires_in || 3600 + + const expiresAt = Date.now() + expiresIn * 1000 // Saving future time when it expires + + // Persist token + await this.saveToken(json.access_token, json.refresh_token, expiresAt, json.scope) + + return json.access_token + } else { + throw new OneDriveOAuthTokenError() + } + } + + private isTokenValid(token: OneDriveTokenCache): boolean { + const now = Date.now() + return token.expiresAt > now + (5 * 60 * 1000) // 5 min safety margin + } + + + private async loadToken(): Promise { + // return from memory cache if present + if (this.tokenCache) { + return this.tokenCache + } + + // Browser extension storage + if (IS_BROWSER) { + const browser = (await import('../browser-api')).default + const result = await browser.storage.local.get({ accounts: {} }) + + // Getting token from account data + const token = result.accounts?.[this.server.id]?.onedrive + if (token) { + this.tokenCache = token + return token + } + + return null + } + + // Capacitor mobile/desktop storage + try { + const NativeAccountStorage = (await import('../native/NativeAccountStorage')).default + const storage = new NativeAccountStorage(this.server.id) + const accountData = await storage.getAccountData() + + const token = accountData?.onedrive + if (token) { + this.tokenCache = token + return token + } + } catch {} + + return null + } + + private async saveToken( + accessToken: string, + refreshToken: string | undefined, + expiresAt: number, + scope: string + ) { + const token: OneDriveTokenCache = { + accessToken, + refreshToken, + expiresAt, + scope + } + + // Memory cache + this.tokenCache = token + + // Browser extension + if (IS_BROWSER) { + this.server.onedrive = token + const BrowserAccountStorage = (await import('../browser/BrowserAccountStorage')).default + const storage = new BrowserAccountStorage(this.server.id) + await storage.setAccountData(this.server, null) + return + } + + // Capacitor mobile/desktop + try { + const NativeAccountStorage = (await import('../native/NativeAccountStorage')).default + const storage = new NativeAccountStorage(this.server.id) + const data = (await storage.getAccountData()) || {} + + data.onedrive = token + + await storage.setAccountData(data, null) + } catch {} + } + + getLabel(): string { + return this.server.label || 'OneDrive: ' + this.server.bookmark_file + } + + static getDefaultValues() { + return { + type: 'one-drive', + username: '', + password: '', + refreshToken: null, + bookmark_file: 'bookmarks.xbel', + allowNetwork: false, + } + } + + getUrl(): string { + return apiBaseUrl + } + + getLockFileName(): string { + return `.${this.server.bookmark_file}.lock` + } + + timeout(ms) { + return new Promise((resolve, reject) => { + setTimeout(resolve, ms) + this.cancelCallback = () => reject(new CancelledSyncError()) + }) + } + + async getBookmarksTree(): Promise> { + // setHashSettings is called after onSyncStart only but before getBookmarksTree + // thus we get the hash here again + this.initialTreeHash = await this.bookmarksCache.hash(this.hashSettings) + return super.getBookmarksTree() + } + + /** + * This method defines what should happen when sync starts. + * @param {boolean} needLock If we need lock + * @param {boolean} forceLock If lock needs to be forced + * @returns + */ + async onSyncStart(needLock = true, forceLock = false) { + Logger.log('onSyncStart: begin') + + if (IS_BROWSER) { + const browser = (await import('../browser-api')).default + let hasPermissions, error = false + try { + hasPermissions = await browser.permissions.contains({ origins }) + } catch (e) { + error = true + console.warn(e) + } + const { isOrion } = await browser.storage.local.get({ 'isOrion': false }) + if (!error && !hasPermissions && !isOrion) { + throw new MissingPermissionsError() + } + } + + this.accessToken = await this.getAccessToken(this.server.refreshToken) + + const fileList = await this.listFiles(this.server.bookmark_file, 100) + if (!fileList.value) { + throw new OneDriveSearchError() + } + + // In listFiles we get bookmarks.xbel and .bookmarks.xbel.lock, we need to remove files other than these + const file = fileList.value.filter(file => !file.deleted && file.name !== this.getLockFileName())[0] + + const filesToDelete = fileList.value.filter(file => !file.deleted && file.name !== this.getLockFileName()).slice(1) + for (const fileToDelete of filesToDelete) { + try { + await this.deleteFile(fileToDelete.id) + } catch (e) { + Logger.log('Failed to delete superfluous file: ' + e.message) + } + } + + if (file && file.id) { + this.fileId = file.id + + // Make sure we have .bookmarks.xbel.lock file before proceeding + await this.ensureLockFile() + + if (forceLock) { + this.locked = await this.setLock() + } else if (needLock) { + // Getting lock file data to check if file is locked or not, if locked then since when it is locked + // Data is in string format and we need to parse it to get locked date + const lockFileData = await this.getLock() + const data = JSON.parse(lockFileData) + const lockedDate = data?.locked + if (data !== null && lockedDate !== false && lockedDate !== null) { + if (!Number.isInteger(lockedDate)) { + throw new ResourceLockedError() + } + if (Date.now() - lockedDate < LOCK_TIMEOUT) { + throw new ResourceLockedError() + } + } + this.locked = await this.setLock() + } + + let xmlDocText = await this.downloadFile(this.fileId) + + if (this.server.password) { + try { + try { + const json = JSON.parse(xmlDocText) + xmlDocText = await Crypto.decryptAES(this.server.password, json.ciphertext, json.salt) + } catch (e) { + xmlDocText = await Crypto.decryptAES(this.server.password, xmlDocText, this.server.bookmark_file) + } + } catch (e) { + if (xmlDocText && xmlDocText.includes('')) { + // not encrypted, yet => noop + this.alwaysUpload = true + } else { + throw new DecryptionError() + } + } + } + if (!xmlDocText || !xmlDocText.includes('')) { + throw new FileUnreadableError() + } + + /* let's get the highestId */ + const byNL = xmlDocText.split('\n') + for (const line of byNL) { + if (line.indexOf(' +` + + output += XbelSerializer.serialize(rootFolder) + + output += ` +` + + return output +} \ No newline at end of file diff --git a/src/lib/browser/BrowserAccount.ts b/src/lib/browser/BrowserAccount.ts index 0315e41644..e16b9e41fa 100644 --- a/src/lib/browser/BrowserAccount.ts +++ b/src/lib/browser/BrowserAccount.ts @@ -29,6 +29,7 @@ export default class BrowserAccount extends Account { static async create(data):Promise { const id = '' + Date.now() + Math.random() + data.id = id const adapter = await AdapterFactory.factory(data) const storage = new BrowserAccountStorage(id) diff --git a/src/lib/native/NativeAccount.ts b/src/lib/native/NativeAccount.ts index 4b31880cc1..1c679a6f94 100644 --- a/src/lib/native/NativeAccount.ts +++ b/src/lib/native/NativeAccount.ts @@ -31,6 +31,7 @@ export default class NativeAccount extends Account { static async create(data: IAccountData):Promise { const id = '' + Date.now() + Math.random() + data.id = id const adapter = await AdapterFactory.factory(data) const storage = new NativeAccountStorage(id) diff --git a/src/test/test.js b/src/test/test.js index a0016ca0ff..c564adecd6 100644 --- a/src/test/test.js +++ b/src/test/test.js @@ -165,6 +165,18 @@ describe('Floccus', function() { password: random.float(), refreshToken: CREDENTIALS.password, }, + { + type: 'one-drive', + bookmark_file: Math.random() + '.xbel', + password: '', + refreshToken: CREDENTIALS.password, + }, + { + type: 'one-drive', + bookmark_file: Math.random() + '.xbel', + password: random.float(), + refreshToken: CREDENTIALS.password, + }, { type: 'linkwarden', url: SERVER, @@ -302,6 +314,16 @@ describe('Floccus', function() { throw new Error('Google Drive sync left more than one file behind') } } + if (ACCOUNT_DATA.type === 'one-drive') { + const fileList = await account.server.listFiles(account.server.bookmark_file) + const files = fileList.value + for (const file of files) { + await account.server.deleteFile(file.id) + } + if (files.length > 1) { + throw new Error('One Drive sync left more than one file behind') + } + } await account.delete() }) it('should create local bookmarks on the server', async function() { @@ -3995,6 +4017,16 @@ describe('Floccus', function() { throw new Error('Google Drive sync left more than one file behind') } } + if (ACCOUNT_DATA.type === 'one-drive') { + const fileList = await account1.server.listFiles(account1.server.bookmark_file) + const files = fileList.value + for (const file of files) { + await account1.server.deleteFile(file.id) + } + if (files.length > 1) { + throw new Error('One Drive sync left more than one file behind') + } + } try { await browser.bookmarks.removeTree(account1.getData().localRoot) } catch (e) { @@ -5576,6 +5608,16 @@ describe('Floccus', function() { throw new Error('Google Drive sync left more than one file behind') } } + if (ACCOUNT_DATA.type === 'one-drive') { + const fileList = await account.server.listFiles(account.server.bookmark_file) + const files = fileList.value + for (const file of files) { + await account.server.deleteFile(file.id) + } + if (files.length > 1) { + throw new Error('One Drive sync left more than one file behind') + } + } await account.delete() }) it('should create local tabs on the server', async function() { @@ -5968,6 +6010,16 @@ describe('Floccus', function() { throw new Error('Google Drive sync left more than one file behind') } } + if (ACCOUNT_DATA.type === 'one-drive') { + const fileList = await account.server.listFiles(account.server.bookmark_file) + const files = fileList.value + for (const file of files) { + await account.server.deleteFile(file.id) + } + if (files.length > 1) { + throw new Error('One Drive sync left more than one file behind') + } + } await account.delete() }) @@ -7039,6 +7091,16 @@ describe('Floccus', function() { throw new Error('Google Drive sync left more than one file behind') } } + if (ACCOUNT_DATA.type === 'one-drive') { + const fileList = await account1.server.listFiles(account1.server.bookmark_file) + const files = fileList.value + for (const file of files) { + await account1.server.deleteFile(file.id) + } + if (files.length > 1) { + throw new Error('One Drive sync left more than one file behind') + } + } await browser.bookmarks.removeTree(account1.getData().localRoot) await account1.delete() await browser.bookmarks.removeTree(account2.getData().localRoot) diff --git a/src/ui/components/OptionsOneDrive.vue b/src/ui/components/OptionsOneDrive.vue new file mode 100644 index 0000000000..b0acfc5a2f --- /dev/null +++ b/src/ui/components/OptionsOneDrive.vue @@ -0,0 +1,172 @@ + + + + + + + + mdi-account-box + {{ t('LabelOptionsServerDetails') }} + + + + + {{ username }} + + mdi-check + + + + {{ t('LabelLoginonedrive') }} + + + {{ authorized || refreshToken? t('DescriptionLoggedinmicrosoft') : t('DescriptionLoginmicrosoft') }} + + + + + + + + + + mdi-folder-outline + {{ t('LabelOptionsFolderMapping') }} + + + + + + + + + mdi-cellphone-settings + {{ t('LabelMobilesettings') }} + + + + + + + + + + mdi-sync-circle + {{ t('LabelOptionsSyncBehavior') }} + + + + + + + + + + + + mdi-alert-circle + {{ t('LabelOptionsDangerous') }} + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/ui/components/native/Drawer.vue b/src/ui/components/native/Drawer.vue index f4988b9d3c..4b0829ea95 100644 --- a/src/ui/components/native/Drawer.vue +++ b/src/ui/components/native/Drawer.vue @@ -119,7 +119,8 @@ export default { 'karakeep': 'mdi-bookmark-box', 'webdav': 'mdi-folder-network', 'git': 'mdi-source-repository', - 'google-drive': 'mdi-google-drive' + 'google-drive': 'mdi-google-drive', + 'one-drive': 'mdi-onedrive' } return icons[type] }, diff --git a/src/ui/views/AccountOptions.vue b/src/ui/views/AccountOptions.vue index c7f92e5172..956733d98e 100644 --- a/src/ui/views/AccountOptions.vue +++ b/src/ui/views/AccountOptions.vue @@ -168,6 +168,11 @@ v-bind.sync="data" @reset="onReset" @delete="onDelete" /> + + + + + {{ t('LabelOnedrivesetup') }} + + + + {{ t('LabelLoginonedrive') }} + + + {{ t('DescriptionLoginmicrosoft') }} + + + + + {{ t('LabelBack') }} + + + @@ -412,6 +433,28 @@ :persistent-hint="true" @click:append="showPassphrase = !showPassphrase" /> + + + + {{ t('LabelBookmarksfile') }} + + + + @@ -574,6 +617,11 @@ export default { type: 'google-drive', label: this.t('LabelAdaptergoogledrive'), description: this.t('DescriptionAdaptergoogledrive') + }, + { + type: 'one-drive', + label: this.t('LabelAdapteronedrive'), + description: this.t('DescriptionAdapteronedrive') } ], } @@ -610,11 +658,13 @@ export default { ...(this.adapter === 'linkwarden' && {serverFolder: this.serverFolder}), ...(this.adapter === 'karakeep' && {serverFolder: this.serverFolder}), ...(this.adapter === 'git' && {branch: this.branch}), - ...((this.adapter === 'webdav' || this.adapter === 'google-drive' || this.adapter === 'git') && {bookmark_file: this.bookmark_file}), - ...((this.adapter === 'webdav' || this.adapter === 'google-drive' || this.adapter === 'git') && {bookmark_file_type: this.bookmark_file_type}), + ...((this.adapter === 'webdav' || this.adapter === 'google-drive' || this.adapter === 'one-drive' || this.adapter === 'git') && {bookmark_file: this.bookmark_file}), + ...((this.adapter === 'webdav' || this.adapter === 'google-drive' || this.adapter === 'one-drive' || this.adapter === 'git') && {bookmark_file_type: this.bookmark_file_type}), ...(this.adapter === 'google-drive' && {refreshToken: this.refreshToken}), + ...(this.adapter === 'one-drive' && {refreshToken: this.refreshToken}), ...(this.passphrase && {passphrase: this.passphrase}), - ...(this.adapter === 'google-drive' && this.passphrase && {password: this.passphrase}), + ...(this.adapter === 'google-drive' && this.passphrase && { password: this.passphrase }), + ...(this.adapter === 'one-drive' && this.passphrase && { password: this.passphrase }), ...(this.isBrowser && {localRoot: this.localRoot}), syncInterval: this.syncInterval, strategy: this.strategy, @@ -688,6 +738,19 @@ export default { this.currentStep++ } }, + async loginOneDrive() { + if (this.isBrowser) { + await this.$store.dispatch(actions.REQUEST_NETWORK_PERMISSIONS) + } + const OneDriveAdapter = (await import('../../lib/adapters/OneDrive')).default + const { refresh_token, username } = await OneDriveAdapter.authorize() + if (refresh_token) { + this.authorized = true + this.refreshToken = refresh_token + this.username = username + this.currentStep++ + } + }, async onFlowStart() { this.loginFlowError = null try { @@ -719,6 +782,9 @@ export default { validateBookmarksFileGoogle(path) { return !path.includes('/') }, + validateBookmarksFileOneDrive(path) { + return !path.includes('/') + }, requestHistoryPermissions() { this.$store.dispatch(actions.REQUEST_HISTORY_PERMISSIONS) } diff --git a/src/ui/views/native/AddBookmarkIntent.vue b/src/ui/views/native/AddBookmarkIntent.vue index f0991f15d0..eee1ac1b0f 100644 --- a/src/ui/views/native/AddBookmarkIntent.vue +++ b/src/ui/views/native/AddBookmarkIntent.vue @@ -94,6 +94,7 @@ export default { accountIcon(type) { const icons = { 'google-drive': 'mdi-google-drive', + 'one-drive': 'mdi-onedrive', 'nextcloud-bookmarks': 'mdi-cloud', 'webdav': 'mdi-folder-network', 'git': 'mdi-source-repository', diff --git a/src/ui/views/native/Options.vue b/src/ui/views/native/Options.vue index 48a49a250f..70636cf4db 100644 --- a/src/ui/views/native/Options.vue +++ b/src/ui/views/native/Options.vue @@ -51,6 +51,11 @@ v-bind.sync="data" @reset="onReset" @delete="onDelete" /> +
+ {{ authorized || refreshToken? t('DescriptionLoggedinmicrosoft') : t('DescriptionLoginmicrosoft') }} +
+ {{ t('DescriptionLoginmicrosoft') }} +