From c388037aa411c863992f650184f6f66338bc5aa4 Mon Sep 17 00:00:00 2001 From: Marcel Klehr Date: Thu, 14 Aug 2025 08:43:06 +0200 Subject: [PATCH 1/8] feat(NextcloudBookmarks): Use capabilities for feature detection Signed-off-by: Marcel Klehr --- src/lib/adapters/NextcloudBookmarks.ts | 77 +++++++++++++++++++------- 1 file changed, 56 insertions(+), 21 deletions(-) diff --git a/src/lib/adapters/NextcloudBookmarks.ts b/src/lib/adapters/NextcloudBookmarks.ts index 0554b3065f..eb1c2a2ba4 100644 --- a/src/lib/adapters/NextcloudBookmarks.ts +++ b/src/lib/adapters/NextcloudBookmarks.ts @@ -13,7 +13,7 @@ import { BulkImportResource, ClickCountResource, ICapabilities, IHashSettings, LoadFolderChildrenResource, - OrderFolderResource + OrderFolderResource, THashFunction } from '../interfaces/Resource' import Ordering from '../interfaces/Ordering' import { @@ -65,7 +65,6 @@ export default class NextcloudBookmarksAdapter implements Adapter, BulkImportRes private server: NextcloudBookmarksConfig private fetchQueue: PQueue<{ concurrency: 12 }> private bookmarkLock: AsyncLock - public hasFeatureBulkImport:boolean = null private list: Bookmark[] private tree: Folder private abortController: AbortController @@ -78,6 +77,7 @@ export default class NextcloudBookmarksAdapter implements Adapter, BulkImportRes private locked = false private hasFeatureJavascriptLinks: boolean = null private hashSettings: IHashSettings + private capabilities: any constructor(server: NextcloudBookmarksConfig) { this.server = server @@ -151,6 +151,7 @@ export default class NextcloudBookmarksAdapter implements Adapter, BulkImportRes this.canceled = false this.ended = false + this.capabilities = await this.getNextcloudCapabilities() await this.checkFeatureJavascriptLinks() this.abortController = new AbortController() @@ -431,9 +432,6 @@ export default class NextcloudBookmarksAdapter implements Adapter, BulkImportRes } async bulkImportFolder(parentId:string|number, folder:Folder):Promise> { - if (this.hasFeatureBulkImport === false) { - throw new Error('Current server does not support bulk import') - } if (folder.count() > 75) { throw new Error('Refusing to bulk import more than 75 bookmarks') } @@ -456,18 +454,12 @@ export default class NextcloudBookmarksAdapter implements Adapter, BulkImportRes const body = new FormData() body.append('bm_import', blob, 'upload.html') - let json - try { - json = await this.sendRequest( - 'POST', - `index.php/apps/bookmarks/public/rest/v2/folder/${parentId}/import`, - 'multipart/form-data', - body - ) - } catch (e) { - this.hasFeatureBulkImport = false - throw e - } + const json = await this.sendRequest( + 'POST', + `index.php/apps/bookmarks/public/rest/v2/folder/${parentId}/import`, + 'multipart/form-data', + body + ) const recurseChildren = (children, id, title, parentId) => { return new Folder({ @@ -771,7 +763,18 @@ export default class NextcloudBookmarksAdapter implements Adapter, BulkImportRes }) } + async getNextcloudCapabilities(): Promise { + const data = await this.sendOCSRequest( + 'GET', + `/ocs/v2.php/cloud/capabilities?format=json`, + ) + return data.capabilities + } + async checkFeatureJavascriptLinks(): Promise { + if (this.capabilities && this.capabilities.bookmarks && typeof this.capabilities.bookmarks['javascript-bookmarks'] !== 'undefined') { + return this.capabilities.bookmarks['javascript-bookmarks'] + } try { const json = await this.sendRequest( 'GET', @@ -794,7 +797,29 @@ export default class NextcloudBookmarksAdapter implements Adapter, BulkImportRes } } - async sendRequest(verb:string, relUrl:string, type:string = null, body:any = null, returnRawResponse = false):Promise { + async sendOCSRequest(verb: string, relUrl: string, type: string = null, body: any = null) { + const res = await this.sendRequest(verb, relUrl, type, body, true, { + 'OCS-APIRequest': 'true', + }) + + if (res.status === 401 || res.status === 403) { + throw new AuthenticationError() + } + if (res.status === 503 || res.status >= 400) { + const url = this.normalizeServerURL(this.server.url) + relUrl + Logger.log(`${verb} ${url}: Server responded with ${res.status}: ` + (await res.text()).substring(0, 250)) + throw new HttpError(res.status, verb) + } + let json + try { + json = await res.json() + } catch (e) { + throw new ParseResponseError(e.message) + } + return json.ocs.data + } + + async sendRequest(verb:string, relUrl:string, type:string = null, body:any = null, returnRawResponse = false, headers = {}):Promise { const url = this.normalizeServerURL(this.server.url) + relUrl let res let timedOut = false @@ -812,7 +837,7 @@ export default class NextcloudBookmarksAdapter implements Adapter, BulkImportRes Logger.log(`QUEUING ${verb} ${url}`) if (Capacitor.getPlatform() !== 'web') { - return this.sendRequestNative(verb, url, type, body, returnRawResponse) + return this.sendRequestNative(verb, url, type, body, returnRawResponse, headers) } const authString = Base64.encode( @@ -829,6 +854,7 @@ export default class NextcloudBookmarksAdapter implements Adapter, BulkImportRes headers: { ...(type && type !== 'multipart/form-data' && { 'Content-type': type }), Authorization: 'Basic ' + authString, + ...headers }, signal: this.abortSignal, ...(body && !['get', 'head'].includes(verb.toLowerCase()) && { body }), @@ -918,7 +944,7 @@ export default class NextcloudBookmarksAdapter implements Adapter, BulkImportRes return res.status === 200 } - private async sendRequestNative(verb: string, url: string, type: string, body: any, returnRawResponse: boolean) { + private async sendRequestNative(verb: string, url: string, type: string, body: any, returnRawResponse: boolean, headers = {}) { let res let timedOut = false const authString = Base64.encode( @@ -935,6 +961,7 @@ export default class NextcloudBookmarksAdapter implements Adapter, BulkImportRes headers: { ...(type && type !== 'multipart/form-data' && { 'Content-type': type }), Authorization: 'Basic ' + authString, + ...headers, }, responseType: 'json', ...(body && !['get', 'head'].includes(verb.toLowerCase()) && { data: body }), @@ -982,9 +1009,17 @@ export default class NextcloudBookmarksAdapter implements Adapter, BulkImportRes } async getCapabilities(): Promise { + let hashFn : THashFunction[] = ['sha256'] + if (this.capabilities && this.capabilities.bookmarks && typeof this.capabilities.bookmarks['hash-functions'] !== 'undefined') { + hashFn = this.capabilities.bookmarks['hash-functions'].map(hashFn => ({ + 'sha256': 'sha256', + 'xxh32': 'xxhash3', + 'murmur3a': 'murmur3', + }[hashFn])) + } return { preserveOrder: true, - hashFn: ['sha256'], + hashFn, } } From 29332aa3097d9effc7144ed64cb5fd4ca8bfb281 Mon Sep 17 00:00:00 2001 From: Marcel Klehr Date: Thu, 14 Aug 2025 08:48:20 +0200 Subject: [PATCH 2/8] feat(NextcloudBookmarks): Use negotiated hash function from hashSettings Signed-off-by: Marcel Klehr --- src/lib/adapters/NextcloudBookmarks.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/lib/adapters/NextcloudBookmarks.ts b/src/lib/adapters/NextcloudBookmarks.ts index eb1c2a2ba4..499fcd25b2 100644 --- a/src/lib/adapters/NextcloudBookmarks.ts +++ b/src/lib/adapters/NextcloudBookmarks.ts @@ -318,12 +318,13 @@ export default class NextcloudBookmarksAdapter implements Adapter, BulkImportRes } async _getFolderHash(folderId:string|number):Promise { - if (this.hashSettings.hashFn !== 'sha256') { - throw new Error('Unsupported hash function: ' + this.hashSettings.hashFn + ' - Nextcloud Bookmarks only supports sha256') + const hashFn = {'sha256': 'sha256', 'murmur3': 'murmur3a', 'xxhash3': 'xxh32'}[this.hashSettings.hashFn] + if (this.capabilities && this.capabilities.bookmarks && this.capabilities.bookmarks['hash-function'] && !this.capabilities.bookmarks['hash-function'].includes[hashFn]) { + throw new Error('Selected hash function is not supported by server') } return this.sendRequest( 'GET', - `index.php/apps/bookmarks/public/rest/v2/folder/${folderId}/hash` + `index.php/apps/bookmarks/public/rest/v2/folder/${folderId}/hash?hashFn=${hashFn}` ) .catch(() => { return { data: '0' } // fallback From 0093a029f00e0a7825cb540acc62f91388a43b3f Mon Sep 17 00:00:00 2001 From: Marcel Klehr Date: Thu, 14 Aug 2025 09:01:19 +0200 Subject: [PATCH 3/8] fix(NextcloudBookmarks): Fix checkFeatureJavascriptLinks Signed-off-by: Marcel Klehr --- src/lib/adapters/NextcloudBookmarks.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/lib/adapters/NextcloudBookmarks.ts b/src/lib/adapters/NextcloudBookmarks.ts index 499fcd25b2..ecd978e3d9 100644 --- a/src/lib/adapters/NextcloudBookmarks.ts +++ b/src/lib/adapters/NextcloudBookmarks.ts @@ -774,7 +774,8 @@ export default class NextcloudBookmarksAdapter implements Adapter, BulkImportRes async checkFeatureJavascriptLinks(): Promise { if (this.capabilities && this.capabilities.bookmarks && typeof this.capabilities.bookmarks['javascript-bookmarks'] !== 'undefined') { - return this.capabilities.bookmarks['javascript-bookmarks'] + this.hasFeatureJavascriptLinks = this.capabilities.bookmarks['javascript-bookmarks'] + return } try { const json = await this.sendRequest( From 7317a8edbf116c9cd0432ac4d35d014e2a242fec Mon Sep 17 00:00:00 2001 From: Marcel Klehr Date: Thu, 14 Aug 2025 12:47:20 +0200 Subject: [PATCH 4/8] fix(build): Increase available RAM for build Signed-off-by: Marcel Klehr --- package.json | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/package.json b/package.json index e265430de0..0abe54889c 100644 --- a/package.json +++ b/package.json @@ -3,12 +3,12 @@ "version": "5.6.0", "description": "Sync your bookmarks privately across browsers and devices", "scripts": { - "build": "NODE_OPTIONS=--max-old-space-size=6000 gulp", - "build-win": "SET NODE_OPTIONS=--max-old-space-size=6000 & gulp", - "build-release": "NODE_OPTIONS=--max-old-space-size=6000 gulp release", - "build-release-win": "SET NODE_OPTIONS=--max-old-space-size=6000 & gulp release", - "watch": "NODE_OPTIONS=--max-old-space-size=6000 gulp watch", - "watch-win": "SET NODE_OPTIONS=--max-old-space-size=6000 & gulp watch", + "build": "NODE_OPTIONS=--max-old-space-size=8000 gulp", + "build-win": "SET NODE_OPTIONS=--max-old-space-size=8000 & gulp", + "build-release": "NODE_OPTIONS=--max-old-space-size=8000 gulp release", + "build-release-win": "SET NODE_OPTIONS=--max-old-space-size=8000 & gulp release", + "watch": "NODE_OPTIONS=--max-old-space-size=8000 gulp watch", + "watch-win": "SET NODE_OPTIONS=--max-old-space-size=8000 & gulp watch", "test": "node --unhandled-rejections=strict test/selenium-runner.js", "lint": "eslint --ext .js,.vue src", "lint:fix": "eslint --ext .js,.vue src --fix" From 1304f2062307bc40df096e5dba922a33a5d29a51 Mon Sep 17 00:00:00 2001 From: Marcel Klehr Date: Thu, 14 Aug 2025 12:47:51 +0200 Subject: [PATCH 5/8] perf(NextcloudBookmarks): Support ticket authentication Signed-off-by: Marcel Klehr --- src/lib/adapters/NextcloudBookmarks.ts | 40 ++++++++++++++++++++------ 1 file changed, 32 insertions(+), 8 deletions(-) diff --git a/src/lib/adapters/NextcloudBookmarks.ts b/src/lib/adapters/NextcloudBookmarks.ts index ecd978e3d9..49b070b8ac 100644 --- a/src/lib/adapters/NextcloudBookmarks.ts +++ b/src/lib/adapters/NextcloudBookmarks.ts @@ -78,6 +78,8 @@ export default class NextcloudBookmarksAdapter implements Adapter, BulkImportRes private hasFeatureJavascriptLinks: boolean = null private hashSettings: IHashSettings private capabilities: any + private ticket: string + private ticketTimestamp: number constructor(server: NextcloudBookmarksConfig) { this.server = server @@ -102,6 +104,8 @@ export default class NextcloudBookmarksAdapter implements Adapter, BulkImportRes setData(data:NextcloudBookmarksConfig):void { this.server = { ...data } + this.ticket = null + this.ticketTimestamp = 0 } getData():NextcloudBookmarksConfig { @@ -842,9 +846,9 @@ export default class NextcloudBookmarksAdapter implements Adapter, BulkImportRes return this.sendRequestNative(verb, url, type, body, returnRawResponse, headers) } - const authString = Base64.encode( - this.server.username + ':' + this.server.password - ) + const authString = !this.ticket || this.ticketTimestamp + 60 * 60 * 1000 < Date.now() + ? 'Basic ' + Base64.encode(this.server.username + ':' + this.server.password) + : 'Bearer ' + this.ticket try { res = await this.fetchQueue.add(() => { @@ -855,7 +859,7 @@ export default class NextcloudBookmarksAdapter implements Adapter, BulkImportRes credentials: this.server.includeCredentials ? 'include' : 'omit', headers: { ...(type && type !== 'multipart/form-data' && { 'Content-type': type }), - Authorization: 'Basic ' + authString, + Authorization: authString, ...headers }, signal: this.abortSignal, @@ -887,6 +891,11 @@ export default class NextcloudBookmarksAdapter implements Adapter, BulkImportRes } if (res.status === 401 || res.status === 403) { + if (authString.startsWith('Bearer')) { + this.ticket = null + this.ticketTimestamp = 0 + return this.sendRequest(verb, url, type, body, returnRawResponse, headers) + } throw new AuthenticationError() } if (res.status === 503 || res.status >= 400) { @@ -903,6 +912,11 @@ export default class NextcloudBookmarksAdapter implements Adapter, BulkImportRes throw new Error('Nextcloud API error for request ' + verb + ' ' + relUrl + ' : \n' + JSON.stringify(json)) } + if (json.ticket) { + this.ticket = json.ticket + this.ticketTimestamp = Date.now() + } + return json } @@ -949,9 +963,9 @@ export default class NextcloudBookmarksAdapter implements Adapter, BulkImportRes private async sendRequestNative(verb: string, url: string, type: string, body: any, returnRawResponse: boolean, headers = {}) { let res let timedOut = false - const authString = Base64.encode( - this.server.username + ':' + this.server.password - ) + const authString = !this.ticket || this.ticketTimestamp + 60 * 60 * 1000 < Date.now() + ? 'Basic ' + Base64.encode(this.server.username + ':' + this.server.password) + : 'Bearer ' + this.ticket try { res = await this.fetchQueue.add(() => { Logger.log(`FETCHING ${verb} ${url}`) @@ -962,7 +976,7 @@ export default class NextcloudBookmarksAdapter implements Adapter, BulkImportRes disableRedirects: !this.server.allowRedirects, headers: { ...(type && type !== 'multipart/form-data' && { 'Content-type': type }), - Authorization: 'Basic ' + authString, + Authorization: authString, ...headers, }, responseType: 'json', @@ -993,6 +1007,11 @@ export default class NextcloudBookmarksAdapter implements Adapter, BulkImportRes } if (res.status === 401 || res.status === 403) { + if (authString.startsWith('Bearer')) { + this.ticket = null + this.ticketTimestamp = 0 + return this.sendRequestNative(verb, url, type, body, returnRawResponse, headers) + } throw new AuthenticationError() } if (res.status === 503 || res.status >= 400) { @@ -1003,6 +1022,11 @@ export default class NextcloudBookmarksAdapter implements Adapter, BulkImportRes throw new Error('Nextcloud API error for request ' + verb + ' ' + url + ' : \n' + JSON.stringify(json)) } + if (json.ticket) { + this.ticket = json.ticket + this.ticketTimestamp = Date.now() + } + return json } From 6ebc907385cc647e2016387f0b5d3d14794d26c3 Mon Sep 17 00:00:00 2001 From: Marcel Klehr Date: Thu, 14 Aug 2025 13:26:28 +0200 Subject: [PATCH 6/8] fix(NextcloudBookmarks): Improve ticket failure retry mechanism Signed-off-by: Marcel Klehr --- src/lib/adapters/NextcloudBookmarks.ts | 22 ++++++++++++---------- 1 file changed, 12 insertions(+), 10 deletions(-) diff --git a/src/lib/adapters/NextcloudBookmarks.ts b/src/lib/adapters/NextcloudBookmarks.ts index 49b070b8ac..30e6844c4e 100644 --- a/src/lib/adapters/NextcloudBookmarks.ts +++ b/src/lib/adapters/NextcloudBookmarks.ts @@ -886,16 +886,17 @@ export default class NextcloudBookmarksAdapter implements Adapter, BulkImportRes throw new RedirectError() } + if (res.status > 400 && res.status !== 423 && authString.startsWith('Bearer')) { + this.ticket = null + this.ticketTimestamp = 0 + return this.sendRequest(verb, url, type, body, returnRawResponse, headers) + } + if (returnRawResponse) { return res } if (res.status === 401 || res.status === 403) { - if (authString.startsWith('Bearer')) { - this.ticket = null - this.ticketTimestamp = 0 - return this.sendRequest(verb, url, type, body, returnRawResponse, headers) - } throw new AuthenticationError() } if (res.status === 503 || res.status >= 400) { @@ -1002,16 +1003,17 @@ export default class NextcloudBookmarksAdapter implements Adapter, BulkImportRes throw new RedirectError() } + if (res.status > 400 && res.status !== 423 && authString.startsWith('Bearer')) { + this.ticket = null + this.ticketTimestamp = 0 + return this.sendRequestNative(verb, url, type, body, returnRawResponse, headers) + } + if (returnRawResponse) { return res } if (res.status === 401 || res.status === 403) { - if (authString.startsWith('Bearer')) { - this.ticket = null - this.ticketTimestamp = 0 - return this.sendRequestNative(verb, url, type, body, returnRawResponse, headers) - } throw new AuthenticationError() } if (res.status === 503 || res.status >= 400) { From c57ef4d76f52aaec8bf88d43f3a809fb11e1b6f2 Mon Sep 17 00:00:00 2001 From: Marcel Klehr Date: Thu, 14 Aug 2025 15:43:37 +0200 Subject: [PATCH 7/8] fix(NextcloudBookmarks): Fix ticket failure retry mechanism Signed-off-by: Marcel Klehr --- src/lib/adapters/NextcloudBookmarks.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/lib/adapters/NextcloudBookmarks.ts b/src/lib/adapters/NextcloudBookmarks.ts index 30e6844c4e..c4a6c74cc0 100644 --- a/src/lib/adapters/NextcloudBookmarks.ts +++ b/src/lib/adapters/NextcloudBookmarks.ts @@ -889,7 +889,7 @@ export default class NextcloudBookmarksAdapter implements Adapter, BulkImportRes if (res.status > 400 && res.status !== 423 && authString.startsWith('Bearer')) { this.ticket = null this.ticketTimestamp = 0 - return this.sendRequest(verb, url, type, body, returnRawResponse, headers) + return this.sendRequest(verb, relUrl, type, body, returnRawResponse, headers) } if (returnRawResponse) { From 2e3554e3ffe914c04f7839760e710353ac1a7276 Mon Sep 17 00:00:00 2001 From: Marcel Klehr Date: Thu, 14 Aug 2025 16:44:22 +0200 Subject: [PATCH 8/8] fix(NextcloudBookmarks): Fix ticket failure retry mechanism Signed-off-by: Marcel Klehr --- src/lib/adapters/NextcloudBookmarks.ts | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/src/lib/adapters/NextcloudBookmarks.ts b/src/lib/adapters/NextcloudBookmarks.ts index c4a6c74cc0..14608bcd71 100644 --- a/src/lib/adapters/NextcloudBookmarks.ts +++ b/src/lib/adapters/NextcloudBookmarks.ts @@ -825,11 +825,12 @@ export default class NextcloudBookmarksAdapter implements Adapter, BulkImportRes return json.ocs.data } - async sendRequest(verb:string, relUrl:string, type:string = null, body:any = null, returnRawResponse = false, headers = {}):Promise { + async sendRequest(verb:string, relUrl:string, type:string = null, originalBody:any = null, returnRawResponse = false, headers = {}):Promise { const url = this.normalizeServerURL(this.server.url) + relUrl let res let timedOut = false + let body = originalBody if (type && type.includes('application/json')) { body = JSON.stringify(body) } else if (type && type.includes('application/x-www-form-urlencoded')) { @@ -886,10 +887,10 @@ export default class NextcloudBookmarksAdapter implements Adapter, BulkImportRes throw new RedirectError() } - if (res.status > 400 && res.status !== 423 && authString.startsWith('Bearer')) { + if ((res.status === 401 || res.status === 403 || res.status === 404) && authString.startsWith('Bearer')) { this.ticket = null this.ticketTimestamp = 0 - return this.sendRequest(verb, relUrl, type, body, returnRawResponse, headers) + return this.sendRequest(verb, relUrl, type, originalBody, returnRawResponse, headers) } if (returnRawResponse) { @@ -1003,7 +1004,7 @@ export default class NextcloudBookmarksAdapter implements Adapter, BulkImportRes throw new RedirectError() } - if (res.status > 400 && res.status !== 423 && authString.startsWith('Bearer')) { + if ((res.status === 401 || res.status === 403 || res.status === 404) && authString.startsWith('Bearer')) { this.ticket = null this.ticketTimestamp = 0 return this.sendRequestNative(verb, url, type, body, returnRawResponse, headers)