From a607ed3f05b999cdacf1f7f6983b5202eb735284 Mon Sep 17 00:00:00 2001 From: Maxime Moreillon Date: Sun, 15 Nov 2026 07:39:50 +0900 Subject: [PATCH] fix(sync): read response headers case-insensitively Header names are case-insensitive (RFC 9110), but ETag, Last-Modified and X-Notes-API-Versions were looked up with their exact casing. Behind proxies that lowercase header names, such as Cloudflare or any HTTP/2 hop, the ETag and modified time were never stored, so every sync used pruneBefore=0 and downloaded every note in full. Refs #2531 AI-assistant: Claude Code 2.1.325 (Claude Opus 5.5) Signed-off-by: Maxime Moreillon --- .../notes/persistence/CapabilitiesClient.java | 3 +- .../notes/persistence/RemoteNotesMerger.kt | 7 +++-- .../util/extensions/HeaderExtensions.kt | 12 +++++++ .../util/extensions/HeaderExtensionsTest.kt | 31 +++++++++++++++++++ 4 files changed, 49 insertions(+), 4 deletions(-) create mode 100644 app/src/main/java/it/niedermann/owncloud/notes/shared/util/extensions/HeaderExtensions.kt create mode 100644 app/src/test/java/it/niedermann/owncloud/notes/shared/util/extensions/HeaderExtensionsTest.kt diff --git a/app/src/main/java/it/niedermann/owncloud/notes/persistence/CapabilitiesClient.java b/app/src/main/java/it/niedermann/owncloud/notes/persistence/CapabilitiesClient.java index bd3d66027..23e71f4d8 100644 --- a/app/src/main/java/it/niedermann/owncloud/notes/persistence/CapabilitiesClient.java +++ b/app/src/main/java/it/niedermann/owncloud/notes/persistence/CapabilitiesClient.java @@ -22,6 +22,7 @@ import it.niedermann.owncloud.notes.shared.model.Capabilities; import it.niedermann.owncloud.notes.shared.model.OcsResponse; import it.niedermann.owncloud.notes.shared.model.OcsUser; +import it.niedermann.owncloud.notes.shared.util.extensions.HeaderExtensions; import retrofit2.Response; @WorkerThread @@ -39,7 +40,7 @@ public static Capabilities getCapabilities(@NonNull Context context, @NonNull Si final var capabilities = response.getResponse().ocs.data; final var headers = response.getHeaders(); if (headers != null) { - capabilities.setETag(headers.get(HEADER_KEY_ETAG)); + capabilities.setETag(HeaderExtensions.getHeader(headers, HEADER_KEY_ETAG)); } else { Log.w(TAG, "Response headers of capabilities are null"); } diff --git a/app/src/main/java/it/niedermann/owncloud/notes/persistence/RemoteNotesMerger.kt b/app/src/main/java/it/niedermann/owncloud/notes/persistence/RemoteNotesMerger.kt index 6642b8a7b..9b77f4973 100644 --- a/app/src/main/java/it/niedermann/owncloud/notes/persistence/RemoteNotesMerger.kt +++ b/app/src/main/java/it/niedermann/owncloud/notes/persistence/RemoteNotesMerger.kt @@ -13,6 +13,7 @@ import it.niedermann.owncloud.notes.persistence.entity.Note import it.niedermann.owncloud.notes.shared.model.DBStatus import it.niedermann.owncloud.notes.shared.util.ApiVersionUtil import it.niedermann.owncloud.notes.shared.util.NoteUtil.generateNoteExcerpt +import it.niedermann.owncloud.notes.shared.util.extensions.getHeader import java.util.Calendar import java.util.Date @@ -82,15 +83,15 @@ class RemoteNotesMerger(private val repo: NotesRepository, private val localAcco } private fun updateSyncState(headers: Map) { - localAccount.eTag = headers[HEADER_KEY_ETAG] + localAccount.eTag = headers.getHeader(HEADER_KEY_ETAG) - val lastModified = parseLastModified(headers[HEADER_KEY_LAST_MODIFIED]) + val lastModified = parseLastModified(headers.getHeader(HEADER_KEY_LAST_MODIFIED)) localAccount.modified = lastModified repo.updateETag(localAccount.id, localAccount.eTag) repo.updateModified(localAccount.id, lastModified.timeInMillis) - val newApiVersion = ApiVersionUtil.sanitize(headers[HEADER_KEY_X_NOTES_API_VERSIONS]) + val newApiVersion = ApiVersionUtil.sanitize(headers.getHeader(HEADER_KEY_X_NOTES_API_VERSIONS)) localAccount.apiVersion = newApiVersion repo.updateApiVersion(localAccount.id, newApiVersion) Log.d(TAG, "Pull: saved ETag ${localAccount.eTag}, last modified ${lastModified.time}, API $newApiVersion") diff --git a/app/src/main/java/it/niedermann/owncloud/notes/shared/util/extensions/HeaderExtensions.kt b/app/src/main/java/it/niedermann/owncloud/notes/shared/util/extensions/HeaderExtensions.kt new file mode 100644 index 000000000..b04a0c5e9 --- /dev/null +++ b/app/src/main/java/it/niedermann/owncloud/notes/shared/util/extensions/HeaderExtensions.kt @@ -0,0 +1,12 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +@file:JvmName("HeaderExtensions") + +package it.niedermann.owncloud.notes.shared.util.extensions + +fun Map.getHeader(name: String): String? = + entries.firstOrNull { it.key.equals(name, ignoreCase = true) }?.value diff --git a/app/src/test/java/it/niedermann/owncloud/notes/shared/util/extensions/HeaderExtensionsTest.kt b/app/src/test/java/it/niedermann/owncloud/notes/shared/util/extensions/HeaderExtensionsTest.kt new file mode 100644 index 000000000..c92581ff8 --- /dev/null +++ b/app/src/test/java/it/niedermann/owncloud/notes/shared/util/extensions/HeaderExtensionsTest.kt @@ -0,0 +1,31 @@ +/* + * Nextcloud - Android Client + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +package it.niedermann.owncloud.notes.shared.util.extensions + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +class HeaderExtensionsTest { + + @Test + fun getHeaderMatchesExactCase() { + assertEquals("\"abc\"", mapOf("ETag" to "\"abc\"").getHeader("ETag")) + } + + @Test + fun getHeaderMatchesLowercaseNameFromProxy() { + val headers = mapOf("etag" to "\"abc\"", "last-modified" to "Mon, 30 Dec 2024 20:52:52 GMT") + assertEquals("\"abc\"", headers.getHeader("ETag")) + assertEquals("Mon, 30 Dec 2024 20:52:52 GMT", headers.getHeader("Last-Modified")) + } + + @Test + fun getHeaderReturnsNullWhenMissing() { + assertNull(mapOf("Server" to "Apache").getHeader("ETag")) + } +}