Feature/cloud sync : add cloud sync support for dropbox and nextcloud with conflict resolution#13341
Draft
ThongvanAlexis wants to merge 14 commits into
Draft
Feature/cloud sync : add cloud sync support for dropbox and nextcloud with conflict resolution#13341ThongvanAlexis wants to merge 14 commits into
ThongvanAlexis wants to merge 14 commits into
Conversation
When the user changes the master key locally, the remote DB still holds the old key. The cloud-sync engine needs the previous key to unlock the remote, merge, then re-upload under the new key. Snapshot is held in memory only -- never persisted -- and is cleared on successful upload, releaseData (lock/close), and destruction. A->B->C without an intervening successful sync keeps the original snapshot A: subsequent setSyncPreviousKey calls are no-ops while a snapshot is pending.
Carrying a machine-readable error kind on the result lets retry and dispatch logic decide based on a structured signal instead of substring- matching tr()'d user-facing strings (which silently break under translation). Default is ErrorKind::Other. RemoteHandler::download/upload inherit the default; sync providers added in later commits set the kind explicitly at the source. Also drop the two `// TODO: For future use` markers above the stdOutput / stdError assignments in download() and upload(). The "future" arrives in this PR: stdOutput is now consumed by the sync engine to carry provider- emitted token-refresh JSON (e.g. DropboxSyncProvider::refreshAuth -> SyncEngine::doAuthenticate -> applyRefreshedTokens). The fields are actively used, not speculative.
Adds a generic getProviderConfig / setProviderConfig pair keyed by (providerType, configKey) so multiple cloud providers can coexist in the database's CustomData without colliding with the existing Script Sync entries. Also adds activeProvider() and hasAnySync() helpers that the dialog uses to dispatch UI state (e.g. reciprocal mutual-exclusivity between Script Sync and Cloud Sync tabs).
…ider
Foundation for cloud sync. Built unconditionally (no Qt5::Network
dependency) so the existing Script Sync ("command") flow keeps working
when WITH_XC_NETWORKING is OFF.
* RemoteSyncProvider: abstract base + factory + override seam for
tests. Re-exports RemoteHandler::ErrorKind for call-site continuity.
* RemoteSyncParams: tagged-union for per-provider params; CommandSyncParams
is the only subclass at this stage.
* CommandSyncProvider: wraps the existing RemoteHandler so the
Script Sync flow drives through the new abstraction.
* SyncEngine: synchronous state machine (refreshAuth -> download ->
merge -> save -> upload) with cancel checks between steps and
sync-previous-key fallback for the key-change scenario.
Network-based providers (Dropbox, Nextcloud) and the HTTP/OAuth
primitives they depend on are added in subsequent commits.
Provider-agnostic network primitives shared by the Dropbox and Nextcloud
sync providers (added in subsequent commits).
* HttpRetryHelper: exponential-backoff retry loop for 429/5xx responses
with jitter, Retry-After header support (capped at 60s), and an
abort flag polled between attempts.
* OAuthHttpServer: minimal localhost-only HTTP server for OAuth2
callback handling. Validates the OAuth state parameter for CSRF
protection (RFC 6749 10.12), enforces a request-size cap to prevent
unbounded memory growth, and per-socket read timeouts against
slowloris.
Both are gated on WITH_XC_NETWORKING; they live in the remotesync
library so the sync providers stay decoupled from the rest of the GUI.
* DropboxSyncProvider: WebDAV-like client on top of the
/2/files/download and /2/files/upload endpoints with rev-based
optimistic concurrency, proactive token refresh against the 4-hour
short-lived access_token, best-effort revokeToken, and
classifyError mapping invalid_grant -> AuthRevoked and
*_access_token -> AuthExpired for the retry router.
* DropboxLoginFlow: OAuth2 PKCE handshake driver. Generates the
code_verifier / code_challenge, starts OAuthHttpServer on the
registered localhost port, opens the authorize URL in the browser,
and falls back to manual paste if the port is taken. Signal-driven
so the page UI does not need a reentrancy guard.
Wired into RemoteSyncProvider::create under WITH_XC_NETWORKING.
* NextcloudSyncProvider: WebDAV PUT/GET/PROPFIND on top of
/remote.php/dav, with ETag-based If-Match / If-None-Match optimistic
concurrency, long-lived app-password Basic auth, and a single-401
retry-after-backoff policy. Includes the canonicalizeServerBaseUrl /
validateServerUrl / normalizeRemotePath / buildResourceUrl static
helpers that the settings UI reuses, plus the loopback-cleartext
exemption for self-hosted dev servers.
* NextcloudLoginFlow: initiate POST -> browser handshake -> 5s
polling -> token receipt state machine. Validates that the
server-returned loginUrl AND pollEndpoint origin matches the
configured server (phishing mitigation per nextcloud/server#21698),
refuses to follow redirects automatically, surfaces 3xx/404/410 as
keep-polling and 401/403/5xx/other as hard failure.
Wired into RemoteSyncProvider::create under WITH_XC_NETWORKING.
Adds the Cloud Sync tab to the Database Settings dialog, alongside the
existing Script Sync tab. The tab is gated on WITH_XC_NETWORKING.
* CloudSyncPage: abstract base for per-provider sub-pages; the parent
widget drives every page through this contract without knowing the
concrete type.
* DatabaseSettingsWidgetCloudSync: provider-agnostic host widget.
Drives the provider dropdown, message banner, save / apply lifecycle.
* DropboxCloudSyncPage / NextcloudCloudSyncPage: concrete pages with
their own .ui forms. They own the user-facing details of each
provider (auth UI, paths, banner copy).
* DatabaseSettingsDialog: wires the new tab in and surfaces a
cloudSyncTriggered signal the DatabaseWidget consumes (next commit).
* DatabaseSettingsWidgetRemote: when Cloud Sync is configured, the
Script Sync tab locks its Save button with an explanatory banner --
reciprocal mutual-exclusivity, since both flows would otherwise land
in CustomData with the save order making recovery non-obvious.
* DatabaseSettingsWidgetDatabaseKey: when the user changes the master
key AND has cloud sync configured, capture the previous key via
Database::setSyncPreviousKey so the next sync can migrate the
remote DB to the new key without prompting.
DatabaseWidget owns the SyncEngine instance per open database and routes sync-on-open, sync-on-save, and the user-initiated "Sync now" action through it. Coordinates the sync-in-progress lockout, the remoteDbNeedsKey unlock dialog handoff, and provider abort on database lock / close. MainWindow adds the "Sync now" menu entry and the cloud-sync settings shortcut into the Database menu, both gated on WITH_XC_NETWORKING and on the active database having a configured cloud sync provider.
End-to-end GUI test under tests/gui/ that drives the cloud-sync settings
page exactly the way a user does -- triggerAction on the real Database
Settings menu, navigate the dialog's CategoryListWidget to the Cloud
Sync page, type into QLineEdits via QTest::keyClicks, click QPushButtons
via QTest::mouseClick, verify state through Qt property queries
(isVisible / isEnabled / text / currentText) and on-disk persistence
via a fresh RemoteSettings(m_db).
Fixture (initTestCase + init + cleanup + cleanupTestCase) mirrors TestGui:
* Custom main() with Qt::AA_EnableHighDpiScaling + Application +
applyTheme + AA_Use96Dpi + QTEST_DISABLE_KEYPAD_NAVIGATION.
* initTestCase constructs + show()s + resize(1024, 768)s a real
MainWindow after Crypto::init / temp config / Application::bootstrap.
* init copies NewDatabase.kdbx into a TemporaryFile, opens it via
fileDialog()->setNextFileName + triggerAction(actionDatabaseOpen),
enters password "a" with QTest::keyClicks + Qt::Key_Enter, then
triggerActions actionDatabaseSettings and navigates to the Cloud
Sync category via CategoryListWidget::setCurrentCategory using the
dynamically-discovered pageIndex (compile-flag robust against
Browser/KeeShare/Fdo shifting Cloud Sync's slot).
* cleanup: m_db->markAsClean + MessageBox::setNextAnswer(No) +
triggerAction(actionDatabaseClose) + delete m_dbWidget (TestGui:166).
* cleanupTestCase: TemporaryFile::remove.
Scenario (CloudSettingNotImpactedWhileExploringOtherProviders):
1. Default provider is Dropbox, all fields empty, m_applyButton is
grayed -- proves the empty-initial-load did not fire settingsModified
(the QSignalBlocker around setText in loadFromConfig).
2. QTest::keyClicks into appKeyEdit + remotePathEdit -- the dirty signal
chain re-enables Apply (textChanged -> markModified -> modified ->
settingsModified -> setModified(true)). QTRY_VERIFY because
textChanged may deliver across an event-loop boundary.
3. QTest::mouseClick the real Authorize button with a
MockDropboxLoginFlow returning StartOutcome::Completed -- the page
reaches "Authorized" state, Apply stays enabled (the freshly-acquired
tokens must be saveable).
4. QTest::mouseClick the real m_applyButton -- the EditWidget::apply
signal fires, DatabaseSettingsDialog::applySettings runs
saveAllSettings, setModified(false) re-grays Apply.
5. Switch the combobox to Nextcloud (setCurrentIndex, TestGui:1776/1782
pattern) -- all Nextcloud fields empty, Apply stays grayed
(page-switch is not a user edit).
6. Switch the combobox back to Dropbox -- the typed values are
preserved, Apply stays grayed.
7. Fresh RemoteSettings(m_db) reads back the full Dropbox conf from
CustomData: type / name / appKey / remotePath / accessToken /
refreshToken, activeProvider == "dropbox", no Nextcloud record.
Each // CRITICAL comment marks an assertion that catches a specific
contract failure: dropping a QSignalBlocker in loadFromConfig, breaking
the dirty signal chain on a field edit, dropping the emit modified() in
mergeAndPersistTokens, accidentally emitting modified() from
onProviderChanged, or wiping m_config on every page switch.
The mock (tests/mock/MockDropboxLoginFlow.{h,cpp}) replaces the real
PKCE + localhost OAuthHttpServer + browser-open + token-exchange POST
with a synchronous "emit the canned signal" -- zero network, zero
browser. Auth-protocol correctness belongs in a separate
TestDropboxLoginFlow with MockNetworkReply; this test covers widget
behavior only.
Gated by both WITH_GUI_TESTS (lives under tests/gui/) and
WITH_XC_NETWORKING. Per the project's CLAUDE.md "GUI tests are not
gated by CI" -- the user runs ctest -R testcloudsyncwidget locally.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Verifies the single-provider model enforced by
DatabaseSettingsWidgetCloudSync::saveSettings: when the user switches
providers and applies a new one with authorized credentials, the
previously-persisted provider is wiped from the database CustomData.
Driven through the real DatabaseSettingsDialog. Every user-observable
step is a synthesized event: QTest::keyClicks into QLineEdits,
QTest::mouseClick on the Authorize + Apply QPushButtons, comboBox
setCurrentIndex for the provider switch (TestGui:1776/1782 pattern),
QGroupBox setChecked for the appPasswordGroupBox expand (TestGui:664/
1802 analog). Each mouseClick is preceded by QVERIFY(isVisible) +
isEnabled preconditions matching TestGui:611-612.
Scenario:
1. Configure Dropbox: keyClicks appKey + remotePath, mouseClick
Authorize with a MockDropboxLoginFlow returning Completed,
mouseClick the real Apply. Read back via a fresh RemoteSettings(m_db)
-- assert Dropbox persisted with all fields (type / name / appKey /
remotePath / accessToken / refreshToken), activeProvider ==
"dropbox", no Nextcloud entry. This intermediate assert is
load-bearing: it proves the step 4 "Dropbox is gone" check is not a
false pass on a value that was never written.
2. Switch the combobox to Nextcloud; assert Apply stays grayed
(page-switch is not an edit). keyClicks serverBaseUrl + remotePath
in the top fields, expand appPasswordGroupBox, keyClicks loginName
+ appPassword in the sub-panel. The first text edit must re-enable
Apply (QTRY_VERIFY because textChanged delivers across an event
loop boundary).
3. mouseClick the real Apply.
4. Fresh RemoteSettings(m_db) -- CRITICAL: Dropbox config gone,
Nextcloud config present with type / name / serverBaseUrl /
remotePath / loginName / appPassword, activeProvider ==
"nextcloud". Regressions this catches: removing the if(authorized)
wipe loop, wiping only in-memory but not persisting, or wrong-order
setProviderConfig vs removeProviderConfig leaving both entries on
disk.
5. Switch the combobox back to Dropbox -- the displaced page's
QLineEdits must read empty (saveSettings calls loadFromConfig({})
on every non-active page when the new active page is authorized;
DatabaseSettingsWidgetCloudSync.cpp:215-223 explicitly calls out
this UI-consistency case).
The Nextcloud "Authorize with App Password" button is intentionally NOT
clicked -- it routes through NextcloudSyncProvider::testConnection which
is real network and has no mock on this branch. Instead the test uses
the page's explicitly-supported paste-without-Authorize path:
saveToConfig reads loginNameEdit / appPasswordEdit when non-empty,
which produces an authorized config without the pre-flight validation
call. What's bypassed is the optional pre-flight, not the wipe path
under test.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Verifies the Database > Remote Sync menu's "Trigger <Provider> Sync"
entry tracks the currently-configured cloud provider, by popping the
real QMenu (QMenu::popup({0,0}) + processEvents + close, TestGui:416
pattern) and iterating its visible QActions -- not just the underlying
isCloudSyncAuthorized / getCloudSyncProviderDisplayName accessors.
Scenario:
1. Pop menuRemoteSync on a freshly-opened DB with no provider --
aboutToShow fires from popup() and the production wiring at
MainWindow.cpp:175 rebuilds the menu. Assert no "Trigger Dropbox
Sync" / "Trigger Nextcloud Sync" visible action.
2. Configure Nextcloud through the cloud-sync widget fixture:
setCurrentIndex(1) on the provider combobox, QTest::keyClicks
serverBaseUrl + remotePath, expand appPasswordGroupBox via
setChecked (TestGui:664/1802 analog for toggles), QTest::keyClicks
loginName + appPassword, QTest::mouseClick the real Apply button.
Then QTRY_VERIFY on m_dbWidget->isCloudSyncAuthorized() instead of
a fixed qWait -- this polls until the 150ms Database::modified
timer (Database.cpp:1074) fires and DatabaseWidget::onDatabaseModified
reloads m_remoteSettings. TestGui has zero qWait/qSleep usages and
this test matches that. Pop the menu, assert "Trigger Nextcloud
Sync" visible action present and "Trigger Dropbox Sync" not.
3. Switch combobox to Dropbox, keyClicks appKey + remotePath,
mouseClick the mock Authorize button, mouseClick Apply. QTRY_COMPARE
the dbWidget displayName flip from "Nextcloud" to "Dropbox" (proves
dbWidget re-loaded), pop the menu, assert "Trigger Dropbox Sync"
visible action present and "Trigger Nextcloud Sync" not
(single-provider model wiped the previous entry;
menuRemoteSync->clear() at the top of updateRemoteSyncMenuEntries
is what enforces this).
The exact menu string comes from MainWindow.cpp:1290 --
tr("Trigger %1 Sync").arg(providerName) where providerName is
RemoteSyncProvider::displayName() ("Dropbox" / "Nextcloud", untranslated
brand identifiers). The lambda hasTriggerEntryFor filters by
action->isVisible() so the test reflects what the user can actually
click, not just what's in the QMenu's action list.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
End-to-end Dropbox happy-path lifecycle: validation banners on empty state, Authorize via MockDropboxLoginFlow, Test Connection + sync-on-save + sync-on-open via the new MockDropboxSyncProvider (installed through RemoteSyncProvider::setFactoryOverrideForTest), close/reopen DB to verify CustomData persistence, then Remove + OK to verify the no-sync-after-Remove invariant. The mock provider has a "kill switch" (setIsAuthorizedOverride) to neuter the post-sync re-trigger chain (Database::save's RandomSlug update re-emits databaseSaved, and the queued onDatabaseSavedTriggerSync fires after m_syncInProgress clears) so the test can lock in exact expected sync counts (1 after Apply, 1 after reopen, 0 after Remove+OK). Adds an objectName on DatabaseWidget::m_messageWidget so the test can disambiguate it from unrelated MessageWidget instances in the dialog/Edit page trees. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
End-to-end Nextcloud lifecycle parallel to the Dropbox workflow: empty-state
warning banners, Authorize via MockNextcloudLoginFlow, Test Connection via
MockNextcloudSyncProvider, Apply/OK -> sync-on-save -> completion banner +
status bar, close+reopen db with sync-on-open, Remove with two-sentence
banner, final OK with zero syncs.
One block is intentionally failing: after closing and reopening the dialog,
loadFromConfig auto-checks appPasswordGroupBox whenever loginName +
appPassword are persisted, which contradicts the intended UX (the box should
only auto-open when the user took the paste path, not the Login Flow v2
path). Locking the contract via failing asserts now means the eventual fix
flips this test green without needing to add new assertions.
Supporting infrastructure:
- MockNextcloudLoginFlow / MockNextcloudSyncProvider (parallel to the
Dropbox mocks)
- NextcloudLoginFlow::startLoginFlow / ::cancel made virtual
- NextcloudSyncProvider::testConnection made virtual
- NextcloudCloudSyncPage::setLoginFlowForTest seam (parallel to
DropboxCloudSyncPage::setLoginFlowForTest)
- initTestCase factory override now also returns MockNextcloudSyncProvider
for type "nextcloud"
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Member
|
Damn very impressive |
varjolintu
reviewed
May 15, 2026
varjolintu
reviewed
May 15, 2026
varjolintu
reviewed
May 15, 2026
varjolintu
reviewed
May 15, 2026
varjolintu
reviewed
May 15, 2026
varjolintu
reviewed
May 15, 2026
varjolintu
reviewed
May 15, 2026
varjolintu
reviewed
May 15, 2026
varjolintu
reviewed
May 15, 2026
varjolintu
reviewed
May 15, 2026
varjolintu
reviewed
May 15, 2026
varjolintu
reviewed
May 15, 2026
varjolintu
reviewed
May 15, 2026
varjolintu
reviewed
May 15, 2026
varjolintu
reviewed
May 15, 2026
| // --------------------------------------------------------------------------- | ||
| QString NextcloudSyncProvider::canonicalizeServerBaseUrl(QString input) | ||
| { | ||
| input = input.trimmed(); |
Member
There was a problem hiding this comment.
Experiment if you can use ::fromUserInput() to simplify the code in this function.
droidmonkey
marked this pull request as draft
May 19, 2026 23:05
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Hi, I made this because I'm switching to Linux and I'll be using KeePassXC from now on. I did use AI to assist me.
Summary
Adds optional cloud sync for KeePassXC databases via Dropbox or Nextcloud.
Same shape as the legacy
KeePassSync/GoogleSyncPluginworkflow from KeePass2.are stored in the database's CustomData — they travel with the
.kdbxfile,not with the user's profile.
Merger. Same code path as Database > Merge From..., so no merge-semanticsregressions: any KDBX feature that already merges correctly (history,
custom data, attachments, recycle bin, deleted objects) continues to
merge correctly under cloud sync.
the other, never both, surfaced as a locked tab + banner.
Design
Architecture
src/remotesync/— provider-agnostic libraryRemoteSyncProvider(abstract) + factory + test seam.SyncEngine: synchronous state machine(
refreshAuth → download → merge → save → upload) with cancel checksbetween every step and temp-file cleanup on every exit path
(success, error, cancel, destructor).
CommandSyncProviderwraps the existingRemoteHandlerso Script Syncflows through the same abstraction — no double code path.
HttpRetryHelper,OAuthHttpServer— provider-agnostic primitives.DropboxSyncProvider+DropboxLoginFlow— Dropbox v2 API(
/2/files/download,/2/files/upload) with rev-based optimisticconcurrency. OAuth2 PKCE login flow with browser callback (localhost
HTTP server) and manual-paste fallback.
NextcloudSyncProvider+NextcloudLoginFlow— WebDAV(
/remote.php/dav/files/<user>) with ETag-based If-Match/If-None-Match.Login Flow v2 (initiate + poll + browser handshake).
Adding a third provider (Google Drive, OneDrive, etc.) is approximately
one new pair of files under
src/remotesync/+ a concreteCloudSyncPagesubclass under
src/gui/remote/. The engine, UI host widget, and factoryseam are generic.
Master-key changes
When the user changes the local master key, the remote
.kdbxstillholds the previous key.
Database::setSyncPreviousKey()capturesthe pre-change key as a transient in-memory snapshot (never persisted,
cleared on lock / successful upload / destruction). The sync engine
retries
remoteDb->open(...)with that snapshot before surfacing a keymismatch, then re-uploads under the new key — migrating the remote
without prompting the user.
Screenshots
Fill your provider informations then clic Authorize, allow in browser then clic apply. clic ok to go back to the database view.
You can also manually trigger a sync from there.
After each sync you get a green banner and the time of your last sync appear at the bottom right, you also get a loading bar at the bottom right position during the sync
Testing strategy
ctest -R testcloudsyncwidget --output-on-failureCloudSettingMenuEntry— verifies the Database menu surface.CloudSettingNotImpactedWhileExploringOtherProviders— the settingspage lets the user click around providers without dirtying state.
CloudSettingSwitchProviderRemoveOldOne— switching from Dropbox toNextcloud (or vice versa) clears the previous provider's CustomData.
CloudSettingAddAndRemoveDropboxFullWorkflow— end-to-end add /authorize / sync / remove against
MockDropboxSyncProvider+MockDropboxLoginFlow.CloudSettingAddAndRemoveNextCloudFullWorkflow— same for Nextcloud.Type of change