Skip to content

Feature/cloud sync : add cloud sync support for dropbox and nextcloud with conflict resolution#13341

Draft
ThongvanAlexis wants to merge 14 commits into
keepassxreboot:developfrom
ThongvanAlexis:feature/cloud-sync
Draft

Feature/cloud sync : add cloud sync support for dropbox and nextcloud with conflict resolution#13341
ThongvanAlexis wants to merge 14 commits into
keepassxreboot:developfrom
ThongvanAlexis:feature/cloud-sync

Conversation

@ThongvanAlexis

Copy link
Copy Markdown

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 / GoogleSyncPlugin workflow from KeePass2.

  • Settings (provider, server URL, OAuth tokens, app password, remote path)
    are stored in the database's CustomData — they travel with the .kdbx file,
    not with the user's profile.
  • Sync is a one-way merge of remote into local using KeePassXC's existing
    Merger. Same code path as Database > Merge From..., so no merge-semantics
    regressions: any KDBX feature that already merges correctly (history,
    custom data, attachments, recycle bin, deleted objects) continues to
    merge correctly under cloud sync.
  • Reciprocal mutual-exclusivity with Script Sync: a database can use one or
    the other, never both, surfaced as a locked tab + banner.

Design

Architecture

  • src/remotesync/ — provider-agnostic library
    • RemoteSyncProvider (abstract) + factory + test seam.
    • SyncEngine: synchronous state machine
      (refreshAuth → download → merge → save → upload) with cancel checks
      between every step and temp-file cleanup on every exit path
      (success, error, cancel, destructor).
    • CommandSyncProvider wraps the existing RemoteHandler so Script Sync
      flows through the same abstraction — no double code path.
    • HttpRetryHelper, OAuthHttpServer — provider-agnostic primitives.
  • Concrete providers under the same dir:
    • DropboxSyncProvider + DropboxLoginFlow — Dropbox v2 API
      (/2/files/download, /2/files/upload) with rev-based optimistic
      concurrency. 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 concrete CloudSyncPage
subclass under src/gui/remote/. The engine, UI host widget, and factory
seam are generic.

Master-key changes

When the user changes the local master key, the remote .kdbx still
holds the previous key. Database::setSyncPreviousKey() captures
the 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 key
mismatch, then re-uploads under the new key — migrating the remote
without prompting the user.

Screenshots

A001

Fill your provider informations then clic Authorize, allow in browser then clic apply. clic ok to go back to the database view.

A002

You can also manually trigger a sync from there.

A003

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

  • Built locally and tested manually
    • Configuring a new provider wipes the previous one.
    • Both providers' Authorize buttons do a working browser dance.
    • Every type of conflict tested (it is KeePassXC's own merge function being called anyway).
    • Master key change works and the latest key always ends up on the remote provider.
  • End-to-end GUI tests (only the network / browser-opener boundaries are mocked)
    • Run with: ctest -R testcloudsyncwidget --output-on-failure
    • CloudSettingMenuEntry — verifies the Database menu surface.
    • CloudSettingNotImpactedWhileExploringOtherProviders — the settings
      page lets the user click around providers without dirtying state.
    • CloudSettingSwitchProviderRemoveOldOne — switching from Dropbox to
      Nextcloud (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

  • ✅ New feature (change that adds functionality)

ThongvanAlexis and others added 14 commits May 13, 2026 22:35
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>
@droidmonkey

Copy link
Copy Markdown
Member

Damn very impressive

@varjolintu varjolintu added the pr: ai-assisted Pull request contains significant contributions by generative AI label May 15, 2026
Comment thread src/gui/dbsettings/DatabaseSettingsDialog.cpp
Comment thread src/gui/remote/dropbox/DropboxCloudSyncPage.cpp
Comment thread src/gui/remote/dropbox/DropboxCloudSyncPage.cpp
Comment thread src/gui/remote/dropbox/DropboxCloudSyncPage.cpp
Comment thread src/gui/remote/RemoteHandler.h
Comment thread src/gui/remote/RemoteSettings.cpp
Comment thread src/gui/DatabaseWidget.cpp
Comment thread src/gui/DatabaseWidget.cpp
Comment thread src/gui/MainWindow.cpp
Comment thread src/remotesync/CommandSyncProvider.cpp
Comment thread src/remotesync/HttpRetryHelper.cpp
Comment thread src/remotesync/NextcloudLoginFlow.cpp
Comment thread src/remotesync/NextcloudSyncProvider.cpp
Comment thread src/remotesync/NextcloudSyncProvider.cpp
// ---------------------------------------------------------------------------
QString NextcloudSyncProvider::canonicalizeServerBaseUrl(QString input)
{
input = input.trimmed();

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Experiment if you can use ::fromUserInput() to simplify the code in this function.

@droidmonkey droidmonkey added the pr: new feature Pull request adds a new feature label May 19, 2026
@droidmonkey
droidmonkey marked this pull request as draft May 19, 2026 23:05
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

pr: ai-assisted Pull request contains significant contributions by generative AI pr: new feature Pull request adds a new feature

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants