From 6869b7c692604b69810eb0176fbb17d2978f69c1 Mon Sep 17 00:00:00 2001 From: r-zakarya <82443831+r-zakarya@users.noreply.github.com> Date: Tue, 30 Jun 2026 00:32:12 +0100 Subject: [PATCH] feat: WebDAV sync + batch DnD + clean shutdown + center-modal UX bundle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Sync (WebDAV, auto-merge): UUID + tombstones foundations (server + JS), THTTPClient bridge cmds (get/put/test), runSyncNow engine with pull/merge/push flow, Settings UI, pre-sync backup option. Test connection now treats 404 as OK (snapshot not yet created) and 401/ 403 as auth failure with dedicated toast. - Batch drag-drop: cards + table rows carry checked-set ids (CSV) when dragged from an active selection; folder + trash drop handlers parse and apply in batch via new moveEntriesToFolder helper that preserves TOTP / custom_fields / kind in the full PUT payload. - Clean shutdown: WM_QUERYENDSESSION / WM_ENDSESSION captured in the bridge message-only window; FormCloseQuery bypasses the tray-minimize intercept on system shutdown / restart / logoff so FireDAC closes the SQLite WAL cleanly instead of leaving -shm / -wal residue after a force-kill. - Center-mode modal: blur+dim backdrop via body::before pseudo-element in editor-position=center, swallows clicks below the panel so the existing outside-click handlers reliably dismiss the slideover / settings panel. - Batch bar state fixes: state.checked cleared before render in moveEntriesToFolder, emptyTrash, and per-card restoreEntry / permanentDelete / deleteEntry so the action bar disappears once the selection is fully processed. - Save-then-discard duplicate fix: soState reset to null before openSlideOver re-opens the freshly saved entry, otherwise the dirty check fired on the soState.id=null → newId switch and a Cancel left the form in new-entry mode (second Save → POST duplicate). - TEST_SYNC.md: end-to-end checklist for validating the WebDAV sync with 2 real instances. Co-Authored-By: Claude Opus 4.7 --- CLAUDE.md | 47 ++ TEST_SYNC.md | 135 +++++ css/style.css | 18 +- .../Handlers/PM.Handler.Entries.pas | 181 +++++- delphi-backend/Source/PM.Bridge.pas | 21 + delphi-backend/Source/PM.Database.pas | 36 ++ delphi-backend/UMainForm.pas | 126 ++++ delphi-backend/assets/assets.res | Bin 578992 -> 608500 bytes index.html | 51 ++ js/app.js | 542 +++++++++++++++++- 10 files changed, 1132 insertions(+), 25 deletions(-) create mode 100644 TEST_SYNC.md diff --git a/CLAUDE.md b/CLAUDE.md index f40c809..b26fa5d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -454,6 +454,53 @@ hotkeys autofill, etc. **Device-only** (localStorage seulement) : `quickUnlockEnabled` (DPAPI lié au compte Windows), `autofillEnabled` (toggle hotkey Win32), `rememberedUsername` (auth screen autofill local). +## Sync (WebDAV, auto-merge) + +Multi-device sync via a user-hosted WebDAV server (Nextcloud, ownCloud, +Apache mod_dav). Auto-merge strategy: last-write-wins per entry on +`updated_at`, tombstones propagate hard-deletes. No conflict UI — solo +personal use rarely produces simultaneous edits. + +Foundations : +- `vault_entries.uuid` (TEXT, indexed) — stable cross-device identity. + Migration backfills existing rows via `hex(randomblob)` → RFC 4122 v4. +- `entry_tombstones (user_id, uuid, deleted_at)` — UNIQUE(user_id, uuid), + written on hard-delete (`DELETE permanent=1`, trash empty, auto-purge). +- GET `/entries` returns uuid ; POST/bulk-import accept it (mint fresh + if absent) ; PUT keeps it immutable. +- GET `/entries/tombstones` lists local tombstones. +- POST `/entries/tombstones {uuids:[...]}` adds tombstones + hard-deletes + any local rows matching those uuids (idempotent via INSERT OR IGNORE). + +Transport ([UMainForm.pas](delphi-backend/UMainForm.pas)) : +- `cmd://webdav/get|put|test?reqId=&url=&user=&pwd=[&data=]` → async via + `THTTPClient` (WinHTTP under the hood, no OpenSSL DLLs required). + Basic auth, 10s connect / 30s response timeout. Callback + `Bridge.onWebdavResult(reqId, status, payload)`. + +Settings : all config in DPAPI prefs (`syncEnabled`, `syncUrl`, +`syncUser`, `syncPwd`, `syncEncPwd`, `syncPreBackup`, `syncLast`). +**`syncEncPwd` MUST be the same on every device** — it's the secret +that encrypts the WebDAV-stored snapshot. User sets it once per +device, never transmitted. + +`runSyncNow()` flow : +1. (Optional) Write `vault-presync-yyyymmdd-HHMMSS.json` to the + auto-backup folder if enabled. +2. `webdav/get` → 404 = first sync, treat as empty remote. +3. Decrypt with `syncEncPwd` (reuses `encryptExportPayload` container). +4. POST remote tombstones → server hard-deletes local matches. +5. Folders : add missing ones additively (don't touch existing). +6. Entries : for each remote uuid → not in local = POST keeping uuid + + restore attachments ; both sides have it = compare `updated_at`, + PUT if remote newer. +7. `loadEntries()` + `buildSyncSnapshot()` for the post-merge state. +8. `webdav/put` push the merged snapshot. +9. Toast `X added · Y updated · Z deleted`. + +Sensitive actions (export, change master pw, recovery code…) still +require master pw via `askReauth` — sync never substitutes. + ## PIN unlock Optional shortcut unlock with a 4–12 digit PIN, complementary to Quick diff --git a/TEST_SYNC.md b/TEST_SYNC.md new file mode 100644 index 0000000..10ced6d --- /dev/null +++ b/TEST_SYNC.md @@ -0,0 +1,135 @@ +# Test plan — WebDAV sync (auto-merge) + +Server : `wsgidav --host=127.0.0.1 --port=8080 --root=E:\webdav-test --auth=anonymous` +URL Settings : `http://127.0.0.1:8080/vault-sync.json` +User/pwd Settings : vides (anonymous) +Sync password : choisir une fois, **identique sur les 2 devices** + +--- + +## 0. Setup deux devices RÉELLEMENT séparés + +Le sync est par-vault. Un même `vault.db` avec 2 comptes user ≠ 2 devices. + +- [ ] **Device A** : exe actuel `Z:\password-manager\delphi-backend\Win32\Debug\PMServer.exe` +- [ ] **Device B** : + - Copier **uniquement le `.exe`** dans `D:\PMServer-B\` (assets.res est déjà embarqué dedans) + - Lancer → crée un user `userB` avec un nouveau master pw + - `vault.db` de B est créé à côté de l'exe B (séparé de A) +- [ ] Vérifier les 2 lancent sur des ports différents (chacun écrit son port au boot dans le log) + +## 1. Connexion server + +- [ ] wsgidav tourne (logs visibles, "Serving on http://127.0.0.1:8080") +- [ ] A : Settings → Sync → URL + Set sync password (`testsync`) → Test connection → toast `Connection OK · snapshot not created yet` +- [ ] B : idem mais **même sync password** `testsync` → Test connection → toast OK aussi + +## 2. Premier push (A vide → server) + +- [ ] A : créer 3 entries distinctes (`gmail`, `bank`, `github` par ex.) + 1 note + 1 folder custom `Work` +- [ ] A : **Sync now** → wsgidav log `GET 404` + `PUT 201` → toast `Sync complete — 0 added · 0 updated · 0 deleted` +- [ ] Vérifier `Get-ChildItem E:\webdav-test` → `vault-sync.json` existe (~quelques KB) + +## 3. Premier pull (B vide ← server) + +- [ ] B : **Sync now** → wsgidav log `GET 200` + `PUT 201` → toast `Sync complete — 5 added · 0 updated · 0 deleted` (3 logins + note + ??? folder ne compte pas dans `added`) +- [ ] B : vérifier que les 3 entries + la note sont visibles dans la grille +- [ ] B : ouvrir `gmail` → password déchiffrable +- [ ] B : ouvrir la note → texte lisible +- [ ] B : sidebar Folders → `Work` présent avec sa couleur+icône + +## 4. Auto-merge ajout des deux côtés + +- [ ] A : créer entry `slack` +- [ ] B : créer entry `discord` +- [ ] A : Sync now → toast `1 added` (récupère `discord`) +- [ ] B : Sync now → toast `1 added` (récupère `slack`) +- [ ] Les deux devices ont maintenant 5 + 2 = 7 entries + +## 5. Last-write-wins + +- [ ] A : éditer `gmail` → changer username en `user-from-A` → Save +- [ ] B (sans sync entre temps) : éditer `gmail` → changer username en `user-from-B` → Save (B a `updated_at` plus récent) +- [ ] A : Sync now → toast `0 added · 1 updated · 0 deleted` → `gmail.username` devient `user-from-B` +- [ ] Inverse pour confirmer : édit A puis édit B puis sync B en premier → B garde sa version (rien à update côté B), puis sync A → A bascule sur B + +## 6. Tombstones (delete propagation) + +- [ ] A : delete `slack` (soft) → Trash → Empty trash (hard-delete = tombstone créé) +- [ ] A : Sync now → push tombstone +- [ ] B : Sync now → toast inclut `1 deleted` → `slack` disparait côté B +- [ ] Recréer `slack` côté B → Sync now → vérifier qu'il ne ressuscite **pas** côté A (tombstone réutilisé sauf si nouveau uuid mint → vérifier ce comportement) + +## 7. Custom fields + TOTP préservés + +- [ ] A : créer entry `aws` avec TOTP secret valide + 2 custom fields (`access_key`, `secret_key` is_secret=true) +- [ ] A : Sync now +- [ ] B : Sync now → `aws` apparaît +- [ ] B : ouvrir `aws` → TOTP code visible et tick · les 2 custom fields visibles · `secret_key` masqué (is_secret) +- [ ] B : éditer un custom field → Save → Sync now +- [ ] A : Sync now → modif reflétée + +## 8. Attachments round-trip + +- [ ] A : ouvrir une entry → Attach file → upload PDF < 1 MB +- [ ] A : Sync now (wsgidav log → file size augmente sensiblement) +- [ ] B : Sync now → ouvrir la même entry → attachment visible → Download → fichier décrypté identique + +## 9. Pre-sync backup + +- [ ] A : Settings → cocher "Create a local backup before each sync" +- [ ] A : Sync now → `Get-ChildItem "C:\Users\zakar\Desktop\backup test\vault-presync-*.json"` → fichier daté du jour existe +- [ ] Tester restore : Import vault → choisir le `.json` → tape `testsync` → entries restaurées + +## 10. Auth WebDAV (optionnel) + +Si tu veux tester avec un vrai user/pwd (au lieu d'anonymous) : +``` +wsgidav --host=127.0.0.1 --port=8080 --root=E:\webdav-test ^ + --auth=basic --user-mapping={"/":{"alice":{"password":"s3cret","roles":["editor"]}}} +``` +- [ ] A : Username `alice` + Password `s3cret` → Test connection OK +- [ ] A : vider user/pwd → Test connection → toast `Auth failed (401)` + +## 11. Erreurs réseau + +- [ ] Tuer wsgidav (Ctrl+C dans son terminal) +- [ ] A : Test connection → toast `Network error: ...` +- [ ] A : Sync now → toast `Sync pull failed: ...` (pas d'écrasement local) +- [ ] Relancer wsgidav → re-Sync now → reprend normalement + +## 12. Conflict — édit + delete sur le même entry + +- [ ] A : delete `github` → hard-delete (Empty trash) → Sync now (push tombstone) +- [ ] B (sans sync entre) : édite `github` (la version locale a un `updated_at` plus récent que la deletion) +- [ ] B : Sync now → comportement attendu = entry deletée (tombstones gagnent toujours sur update — vérifier que c'est bien ça) + +## 13. Vault locked pendant sync + +- [ ] A : Lock vault +- [ ] A : (pas accès Settings — locked) — vérifier qu'il n'y a pas d'auto-sync silencieux qui tenterait quand même +- [ ] Unlock → Sync now → fonctionne + +## 14. Master pw rotation + sync + +- [ ] A : change master password → re-encrypte toutes les entries localement avec nouvelle clé +- [ ] A : Sync now → push avec **même** sync password (encPwd indépendant du master) → toast OK +- [ ] B : Sync now → toast `0 added · 0 updated · 0 deleted` (les uuids n'ont pas changé, les `updated_at` non plus pour la plupart) + +--- + +## Critères de réussite + +- ✅ Pull récupère systématiquement les entries manquantes (added > 0 quand attendu) +- ✅ Push ne supprime **jamais** silencieusement de données (sauf via tombstone explicite) +- ✅ Pre-sync backup créé si dossier configuré (sinon skip silencieux, OK) +- ✅ Erreur réseau ou auth → toast clair, **pas** d'écrasement +- ✅ Sync password divergent → toast `Remote decrypt failed — wrong sync password?` + pas de push +- ✅ Tombstones propagent les deletes hard + +## Gotchas connus + +- **Tester avec 2 vraies instances séparées**, pas 2 users dans la même DB +- Sync password ≠ master password ; doit être identique sur tous les devices +- Pre-sync backup réutilise le dossier auto-backup (pas de dossier dédié actuellement) +- Le snapshot WebDAV contient TOUTES les entries en plaintext sous le sync password — ne pas confondre avec la sécurité par master (qui reste pour les blobs DB locaux) diff --git a/css/style.css b/css/style.css index 859a91e..0c4c009 100644 --- a/css/style.css +++ b/css/style.css @@ -2059,10 +2059,20 @@ body[data-editor-position="center"] #settingsPanel.is-open { opacity: 1; pointer-events: auto; } -/* No dim/blur in center mode — the panel doesn't actually block - interaction (cards, sidebar, topbar stay clickable without - dismissing it), so painting a modal-style backdrop would lie about - the behaviour. The panel just floats above the page. */ +/* Center mode = true modal: dim + blur backdrop, click anywhere + outside the panel dismisses it (handled in JS). The backdrop is + painted via a body pseudo-element so it covers everything but + the active panel. */ +body[data-editor-position="center"]:has(#slideover.is-open)::before, +body[data-editor-position="center"]:has(#settingsPanel.is-open)::before { + content: ''; + position: fixed; inset: 0; + background: rgba(0, 0, 0, 0.45); + backdrop-filter: blur(4px); + -webkit-backdrop-filter: blur(4px); + z-index: 45; /* above topbar (40), below .slideover (50) */ + pointer-events: auto; /* swallow clicks so cards/sidebar don't see them */ +} .slideover-header { display: flex; align-items: center; justify-content: space-between; padding: 16px 20px; diff --git a/delphi-backend/Handlers/PM.Handler.Entries.pas b/delphi-backend/Handlers/PM.Handler.Entries.pas index 95433d4..6f11f44 100644 --- a/delphi-backend/Handlers/PM.Handler.Entries.pas +++ b/delphi-backend/Handlers/PM.Handler.Entries.pas @@ -28,6 +28,21 @@ begin if Result = '' then Result := ADefault; end; +// RFC 4122 v4 UUID — lowercase canonical hex with dashes, no braces. +// Used as the cross-device stable identity for vault_entries. +function NewUUIDv4: string; +var + G: TGUID; + S: string; +begin + CreateGUID(G); + S := GUIDToString(G); + // Strip surrounding braces RTL adds, lowercase the rest. + if (Length(S) > 0) and (S[1] = '{') then + S := Copy(S, 2, Length(S) - 2); + Result := LowerCase(S); +end; + // SQLite DATETIME columns: FireDAC parses to TDateTime internally, then AsString // would format in system locale (DD/MM/YYYY in French). Force ISO format // 'yyyy-mm-dd hh:nn:ss' which is what api.php / SQLite text storage uses and @@ -132,6 +147,8 @@ begin LObj.AddPair('template', TJSONNull.Create) else LObj.AddPair('template', LQ.FieldByName('template').AsString); + // Stable cross-device identity (always populated post-migration). + LObj.AddPair('uuid', LQ.FieldByName('uuid').AsString); // Custom fields: opaque ciphertext + IV, treated identically to // password / totp_secret. NULL → JSON null so the client can // distinguish "never set" from "empty array stored". @@ -165,6 +182,117 @@ begin TJSONHelper.SendJSON(AResponse, LArr); end; +// ===== GET /entries/tombstones ============================================== +// Sync helper — returns the uuid + deleted_at of every permanently-removed +// entry so the merge engine can propagate deletes to other devices. + +procedure HandleGetTombstones(ARequest: TIdHTTPRequestInfo; + AResponse: TIdHTTPResponseInfo; const AParams: TArray); +var + LUserId: Integer; + LQ: TFDQuery; + LArr: TJSONArray; + LObj: TJSONObject; +begin + try + LUserId := Authenticate(ARequest, AResponse); + except + on ESessionRejected do Exit; + end; + + LArr := TJSONArray.Create; + DB.Lock; + try + LQ := TFDQuery.Create(nil); + try + LQ.Connection := DB.Connection; + LQ.SQL.Text := + 'SELECT uuid, deleted_at FROM entry_tombstones ' + + 'WHERE user_id = :uid ORDER BY deleted_at DESC'; + LQ.ParamByName('uid').AsInteger := LUserId; + LQ.Open; + while not LQ.Eof do + begin + LObj := TJSONObject.Create; + LObj.AddPair('uuid', LQ.FieldByName('uuid').AsString); + LObj.AddPair('deleted_at', ISODateTimeField(LQ.FieldByName('deleted_at'))); + LArr.Add(LObj); + LQ.Next; + end; + finally + LQ.Free; + end; + finally + DB.Unlock; + end; + TJSONHelper.SendJSON(AResponse, LArr); +end; + +// ===== POST /entries/tombstones ============================================= +// Sync helper — body {uuids: ["x","y", ...]} adds tombstones for entries +// deleted on another device. Idempotent (UNIQUE constraint). + +procedure HandlePostTombstones(ARequest: TIdHTTPRequestInfo; + AResponse: TIdHTTPResponseInfo; const AParams: TArray); +var + LUserId, I, LAdded: Integer; + LBody: TJSONObject; + LArr: TJSONArray; + LQ: TFDQuery; + LUuid: string; +begin + try + LUserId := Authenticate(ARequest, AResponse); + RequireCSRF(ARequest, AResponse, LUserId); + except + on ESessionRejected do Exit; + end; + + LBody := TJSONHelper.ReadBody(ARequest); + LAdded := 0; + try + LArr := LBody.GetValue('uuids'); + if (LArr = nil) or (LArr.Count = 0) then + begin + TJSONHelper.SendOK(AResponse, 'No tombstones'); + Exit; + end; + DB.Lock; + try + LQ := TFDQuery.Create(nil); + try + LQ.Connection := DB.Connection; + LQ.SQL.Text := + 'INSERT OR IGNORE INTO entry_tombstones (user_id, uuid) ' + + 'VALUES (:uid, :u)'; + for I := 0 to LArr.Count - 1 do + begin + LUuid := Trim(LArr.Items[I].Value); + if LUuid = '' then Continue; + LQ.ParamByName('uid').AsInteger := LUserId; + LQ.ParamByName('u').AsString := LUuid; + LQ.ExecSQL; + if LQ.RowsAffected > 0 then Inc(LAdded); + end; + // Hard-delete any local entries whose UUID just received a + // tombstone — propagates remote deletes during sync pull. + LQ.SQL.Text := + 'DELETE FROM vault_entries WHERE user_id = :uid AND uuid IN ' + + ' (SELECT uuid FROM entry_tombstones WHERE user_id = :uid)'; + LQ.ParamByName('uid').AsInteger := LUserId; + LQ.ExecSQL; + finally + LQ.Free; + end; + finally + DB.Unlock; + end; + finally + LBody.Free; + end; + TJSONHelper.SendOK(AResponse, IntToStr(LAdded) + ' tombstones added'); +end; + // ===== POST /entries ========================================================= procedure HandleCreateEntry(ARequest: TIdHTTPRequestInfo; @@ -173,7 +301,7 @@ var LUserId, LNewId: Integer; LBody, LObj: TJSONObject; LSite, LTitle, LUser, LFolder, LEnc, LIV, LTags, LNow, LTotpSec, LTotpIv, - LKind, LCf, LCfIv, LIcon, LTemplate: string; + LKind, LCf, LCfIv, LIcon, LTemplate, LUuid: string; LQ: TFDQuery; begin try @@ -200,6 +328,10 @@ begin LCfIv := LBody.GetValue('custom_fields_iv', ''); LIcon := LBody.GetValue('icon_b64', ''); LTemplate:= Trim(LBody.GetValue('template', '')); + // Caller may bring its own UUID (sync restore / import preserving + // identity). Otherwise the server mints a fresh one. + LUuid := Trim(LBody.GetValue('uuid', '')); + if LUuid = '' then LUuid := NewUUIDv4; finally LBody.Free; end; @@ -229,9 +361,9 @@ begin 'INSERT INTO vault_entries ' + '(user_id, site, title, username, encrypted_password, iv, encryption_method, ' + ' folder, tags, totp_secret, totp_iv, kind, custom_fields, custom_fields_iv,' + - ' icon_b64, template, created_at, updated_at, password_changed_at) ' + + ' icon_b64, template, uuid, created_at, updated_at, password_changed_at) ' + 'VALUES (:uid, :s, :tt, :u, :e, :i, ''client'', :f, :t, :ts, :tiv, :k, ' + - ' :cf, :cfiv, :ic, :tpl, :c, :c2, :c)'; + ' :cf, :cfiv, :ic, :tpl, :uuid, :c, :c2, :c)'; LQ.ParamByName('uid').AsInteger := LUserId; LQ.ParamByName('s').AsString := LSite; LQ.ParamByName('tt').AsString := LTitle; @@ -266,6 +398,7 @@ begin LQ.ParamByName('tpl').DataType := ftString; if LTemplate = '' then LQ.ParamByName('tpl').Clear else LQ.ParamByName('tpl').AsString := LTemplate; + LQ.ParamByName('uuid').AsString := LUuid; LQ.ParamByName('c').AsString := LNow; LQ.ParamByName('c2').AsString := LNow; LQ.ExecSQL; @@ -280,6 +413,7 @@ begin LogAudit(LUserId, 'add_entry', GetClientIP(ARequest)); LObj := TJSONObject.Create; LObj.AddPair('id', TJSONNumber.Create(LNewId)); + LObj.AddPair('uuid', LUuid); LObj.AddPair('site', LSite); LObj.AddPair('title', LTitle); LObj.AddPair('username', LUser); @@ -475,7 +609,19 @@ begin try LQ.Connection := DB.Connection; if LPermanent then - LQ.SQL.Text := 'DELETE FROM vault_entries WHERE id=:id AND user_id=:uid' + begin + // Record a tombstone BEFORE the delete so the sync engine can + // propagate this removal to other devices. UPSERT semantics — + // re-deleting an already-tombstoned uuid is a no-op. + LQ.SQL.Text := + 'INSERT OR IGNORE INTO entry_tombstones (user_id, uuid) ' + + 'SELECT user_id, uuid FROM vault_entries ' + + 'WHERE id = :id AND user_id = :uid AND uuid IS NOT NULL'; + LQ.ParamByName('id').AsInteger := LId; + LQ.ParamByName('uid').AsInteger := LUserId; + LQ.ExecSQL; + LQ.SQL.Text := 'DELETE FROM vault_entries WHERE id=:id AND user_id=:uid'; + end else LQ.SQL.Text := 'UPDATE vault_entries SET deleted=1, deleted_at=datetime(''now'') ' + @@ -870,6 +1016,16 @@ begin LQ := TFDQuery.Create(nil); try LQ.Connection := DB.Connection; + // Tombstones FIRST so the sync engine can propagate the purge. + LQ.SQL.Text := + 'INSERT OR IGNORE INTO entry_tombstones (user_id, uuid) ' + + 'SELECT user_id, uuid FROM vault_entries ' + + 'WHERE user_id = :uid AND deleted = 1 AND uuid IS NOT NULL ' + + ' AND deleted_at IS NOT NULL ' + + ' AND (julianday(''now'') - julianday(deleted_at)) >= :d'; + LQ.ParamByName('uid').AsInteger := LUserId; + LQ.ParamByName('d').AsInteger := LDays; + LQ.ExecSQL; LQ.SQL.Text := 'DELETE FROM vault_entries ' + 'WHERE user_id = :uid AND deleted = 1 ' + @@ -914,6 +1070,12 @@ begin LQ := TFDQuery.Create(nil); try LQ.Connection := DB.Connection; + LQ.SQL.Text := + 'INSERT OR IGNORE INTO entry_tombstones (user_id, uuid) ' + + 'SELECT user_id, uuid FROM vault_entries ' + + 'WHERE user_id = :uid AND deleted = 1 AND uuid IS NOT NULL'; + LQ.ParamByName('uid').AsInteger := LUserId; + LQ.ExecSQL; LQ.SQL.Text := 'DELETE FROM vault_entries WHERE user_id=:uid AND deleted=1'; LQ.ParamByName('uid').AsInteger := LUserId; LQ.ExecSQL; @@ -940,7 +1102,7 @@ var LBody, LObj, LEntry: TJSONObject; LArr, LIds: TJSONArray; LSite, LTitle, LUser, LFolder, LEnc, LIV, LTags, LTotpSec, LTotpIv, LNow, - LKind, LCf, LCfIv, LIcon, LTemplate: string; + LKind, LCf, LCfIv, LIcon, LTemplate, LUuid: string; LQ: TFDQuery; begin try @@ -985,9 +1147,9 @@ begin 'INSERT INTO vault_entries ' + '(user_id, site, title, username, encrypted_password, iv, encryption_method, ' + ' folder, tags, totp_secret, totp_iv, kind, custom_fields, custom_fields_iv,' + - ' icon_b64, template, created_at, updated_at) ' + + ' icon_b64, template, uuid, created_at, updated_at) ' + 'VALUES (:uid, :s, :tt, :u, :e, :i, ''client'', :f, :t, :ts, :tiv, :k, ' + - ' :cf, :cfiv, :ic, :tpl, :c, :c2)'; + ' :cf, :cfiv, :ic, :tpl, :uuid, :c, :c2)'; // Declare optional param types ONCE — the prepared statement is // reused across every imported entry, and FireDAC needs the // type set before the first .Clear call would otherwise fail @@ -1021,6 +1183,8 @@ begin LCfIv := LEntry.GetValue('custom_fields_iv', ''); LIcon := LEntry.GetValue('icon_b64', ''); LTemplate:= Trim(LEntry.GetValue('template', '')); + LUuid := Trim(LEntry.GetValue('uuid', '')); + if LUuid = '' then LUuid := NewUUIDv4; // Ciphertext is always required. Site is required only for // logins — notes legitimately have no site (their body lives @@ -1055,6 +1219,7 @@ begin if LIcon = '' then LQ.ParamByName('ic').Clear else LQ.ParamByName('ic').Value := LIcon; if LTemplate = '' then LQ.ParamByName('tpl').Clear else LQ.ParamByName('tpl').AsString := LTemplate; + LQ.ParamByName('uuid').AsString := LUuid; LQ.ParamByName('c').AsString := LNow; LQ.ParamByName('c2').AsString := LNow; LQ.ExecSQL; @@ -1136,6 +1301,8 @@ initialization Router.Register('DELETE', '/entries/trash/empty', HandleEmptyTrash); Router.Register('DELETE', '/entries/trash/old', HandleAutoPurgeTrash); Router.Register('DELETE', '/entries/icons/all', HandleClearAllIcons); + Router.Register('GET', '/entries/tombstones', HandleGetTombstones); + Router.Register('POST', '/entries/tombstones', HandlePostTombstones); Router.Register('POST', '/entries/bulk-import', HandleBulkImport); Router.Register('POST', '/entries/(\d+)/restore', HandleRestoreEntry); Router.Register('POST', '/entries/(\d+)/favorite', HandleToggleFavorite); diff --git a/delphi-backend/Source/PM.Bridge.pas b/delphi-backend/Source/PM.Bridge.pas index 6a4d72f..30363ae 100644 --- a/delphi-backend/Source/PM.Bridge.pas +++ b/delphi-backend/Source/PM.Bridge.pas @@ -92,6 +92,12 @@ type FSavedPlacement: TWindowPlacement; FHasSavedPlacement: Boolean; FOnSystemLock: TProc; + // Set TRUE the moment Windows tells us the session is ending + // (WM_QUERYENDSESSION / WM_ENDSESSION). FormCloseQuery checks this + // to bypass the "minimize to tray" intercept so the form closes + // normally and the DB connection is checkpointed instead of being + // force-killed (which leaves -shm / -wal files behind). + FShutdownPending: Boolean; FOnTrayRestore: TProc; FOnLockRequest: TProc; FOnQuit: TProc; @@ -158,6 +164,9 @@ type property AutofillRegistered: Boolean read FAutofillRegistered; // Fired on main thread when Windows locks the session (WTS_SESSION_LOCK). property OnSystemLock: TProc read FOnSystemLock write FOnSystemLock; + // True once WM_QUERYENDSESSION (or WM_ENDSESSION) has been received. + // FormCloseQuery uses this to allow normal close during shutdown. + property ShutdownPending: Boolean read FShutdownPending; // Fired on main thread when the user clicks the tray icon. property OnTrayRestore: TProc read FOnTrayRestore write FOnTrayRestore; // Fired when the user picks "Lock vault" from the tray menu. Handler @@ -735,6 +744,18 @@ begin if Assigned(FOnSystemLock) then FOnSystemLock(); end + else if (AMsg.Msg = WM_QUERYENDSESSION) or (AMsg.Msg = WM_ENDSESSION) then + begin + // Windows is logging off / shutting down / restarting. Flip the flag + // so FormCloseQuery lets the form actually close instead of + // minimizing to tray — otherwise Windows force-kills us after the + // shutdown timeout and SQLite's WAL/SHM never get checkpointed. + // Return TRUE (do not block shutdown). DefWindowProc returns TRUE + // by default for WM_QUERYENDSESSION, so we just don't assign Result. + FShutdownPending := True; + AMsg.Result := 1; + end + else if (AMsg.Msg <> 0) and (AMsg.Msg = WM_PMShowMessage) then begin // A second instance was launched and PostMessage'd HWND_BROADCAST. diff --git a/delphi-backend/Source/PM.Database.pas b/delphi-backend/Source/PM.Database.pas index 0081509..4f78e1d 100644 --- a/delphi-backend/Source/PM.Database.pas +++ b/delphi-backend/Source/PM.Database.pas @@ -314,6 +314,42 @@ begin // Drives the card/table label so notes-with-fields read as "Credit card" // instead of the generic "Encrypted note" placeholder. AddColumnIfMissing('vault_entries', 'template', 'TEXT'); + // Stable identity that survives export/import + cross-device sync. + // SQLite `id` is autoincrement local-only — useless to match the same + // logical entry across two installs. Populate existing rows with a + // fresh UUID v4 below so the migration is non-destructive. + AddColumnIfMissing('vault_entries', 'uuid', 'TEXT'); + FConn.ExecSQL( + 'CREATE INDEX IF NOT EXISTS idx_entries_uuid ' + + ' ON vault_entries(uuid)'); + // Backfill UUIDs for legacy rows that landed before the column existed. + // SQLite has no native uuid() — emit one via hex(randomblob) + manual + // dashes (RFC 4122 v4 = 8-4-4-4-12 hex, version nibble forced to 4, + // variant nibble high bits 10). + FConn.ExecSQL( + 'UPDATE vault_entries SET uuid = ' + + ' lower(hex(randomblob(4))) || ''-'' || ' + + ' lower(hex(randomblob(2))) || ''-4'' || ' + + ' substr(lower(hex(randomblob(2))), 2) || ''-'' || ' + + ' substr(''89ab'', 1 + (abs(random()) % 4), 1) || ' + + ' substr(lower(hex(randomblob(2))), 2) || ''-'' || ' + + ' lower(hex(randomblob(6))) ' + + 'WHERE uuid IS NULL OR uuid = '''''); + // Tombstones: every hard-delete inserts a row here so the sync engine + // can propagate deletes to other devices without leaving deleted + // entries to silently reappear at next pull. + FConn.ExecSQL( + 'CREATE TABLE IF NOT EXISTS entry_tombstones (' + + ' id INTEGER PRIMARY KEY AUTOINCREMENT,' + + ' user_id INTEGER NOT NULL,' + + ' uuid TEXT NOT NULL,' + + ' deleted_at DATETIME DEFAULT CURRENT_TIMESTAMP,' + + ' FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,' + + ' UNIQUE(user_id, uuid)' + + ')'); + FConn.ExecSQL( + 'CREATE INDEX IF NOT EXISTS idx_tombstones_user ' + + ' ON entry_tombstones(user_id, deleted_at DESC)'); // Per-folder customisation. NULL = no override → JS uses the default // accent + i-folder symbol. AddColumnIfMissing('folders', 'color', 'TEXT'); diff --git a/delphi-backend/UMainForm.pas b/delphi-backend/UMainForm.pas index 3460647..a9cea6e 100644 --- a/delphi-backend/UMainForm.pas +++ b/delphi-backend/UMainForm.pas @@ -18,6 +18,7 @@ interface uses System.SysUtils, System.Classes, System.UITypes, System.NetEncoding, System.StrUtils, System.Generics.Collections, System.IOUtils, System.JSON, + System.Net.HttpClient, System.Net.URLClient, Winapi.Windows, Winapi.ShellAPI, FMX.Forms, FMX.Controls, FMX.Controls.Presentation, FMX.StdCtrls, FMX.Memo, FMX.Memo.Types, FMX.ScrollBox, FMX.Edit, FMX.Layouts, FMX.Types, @@ -370,6 +371,18 @@ begin // so we bypass the minimize-to-tray intercept in that case. if FQuitting then Exit; + // Windows shutdown / logoff / restart: WM_QUERYENDSESSION flipped the + // bridge's flag. Let the form close normally so FServer.Free and the + // FireDAC connection get a chance to checkpoint the WAL — otherwise + // Windows force-kills us at the shutdown timeout and we leave + // -shm / -wal files next to vault.db. + if Assigned(FBridge) and FBridge.ShutdownPending then + begin + FQuitting := True; + LogLine('System shutdown detected — closing normally.'); + Exit; + end; + // Otherwise: minimize to tray on close instead of quitting, so the vault // stays available without the dev-panel being visible. // When the server is stopped, allow normal close — there's no vault to @@ -932,6 +945,119 @@ begin BoolToStr(PM.AutoStart.IsAutoStartEnabled, True).ToLower + ')'); end + // ---- WebDAV remote sync (THTTPClient → WinHTTP, async) -------------- + // cmd://webdav/get | put | test ?reqId=&url=&user=&pwd=[&data=] + // Callback: Bridge.onWebdavResult(reqId, status, bodyOrError) + // - GET ok → status=200, body=base64 of response bytes + // - GET 404 → status=404, body='' (caller treats as "no remote yet") + // - PUT ok → status=200/201/204, body='' + // - test → status=200..399 means reachable, body='' + // Network errors → status=0, body=exception message. + else if (ACmd = 'webdav/get') or (ACmd = 'webdav/put') or (ACmd = 'webdav/test') then + begin + var LMethod := ACmd; + var LReqId := GetParam('reqId'); + var LUrl := GetParam('url'); + var LUser := GetParam('user'); + var LPwd := GetParam('pwd'); + var LData := GetParam('data'); + TThread.CreateAnonymousThread( + procedure + var + LHttp: System.Net.HttpClient.THTTPClient; + LResp: System.Net.HttpClient.IHTTPResponse; + LBodyStream: TBytesStream; + LReqStream: TBytesStream; + LBytes: TBytes; + LBodyB64: string; + LStatus: Integer; + LErr: string; + begin + LStatus := 0; + LBodyB64 := ''; + LErr := ''; + try + LHttp := System.Net.HttpClient.THTTPClient.Create; + try + LHttp.ConnectionTimeout := 10000; + LHttp.ResponseTimeout := 30000; + if (LUser <> '') then + begin + LHttp.CredentialsStorage.AddCredential( + System.Net.URLClient.TCredentialsStorage.TCredential.Create( + System.Net.URLClient.TAuthTargetType.Server, '', '', LUser, LPwd)); + end; + if LMethod = 'webdav/get' then + begin + LBodyStream := TBytesStream.Create; + try + LResp := LHttp.Get(LUrl, LBodyStream); + LStatus := LResp.StatusCode; + if (LStatus >= 200) and (LStatus < 300) and (LBodyStream.Size > 0) then + begin + SetLength(LBytes, LBodyStream.Size); + Move(LBodyStream.Bytes[0], LBytes[0], LBodyStream.Size); + LBodyB64 := TNetEncoding.Base64.EncodeBytesToString(LBytes); + LBodyB64 := StringReplace(LBodyB64, #13, '', [rfReplaceAll]); + LBodyB64 := StringReplace(LBodyB64, #10, '', [rfReplaceAll]); + end; + finally + LBodyStream.Free; + end; + end + else if LMethod = 'webdav/put' then + begin + LBytes := TNetEncoding.Base64.DecodeStringToBytes(LData); + LReqStream := TBytesStream.Create(LBytes); + try + LResp := LHttp.Put(LUrl, LReqStream); + LStatus := LResp.StatusCode; + finally + LReqStream.Free; + end; + end + else // webdav/test — HEAD is widely supported even when PROPFIND isn't + begin + LResp := LHttp.Head(LUrl); + LStatus := LResp.StatusCode; + end; + finally + LHttp.Free; + end; + except + on E: Exception do + begin + LStatus := 0; + LErr := E.Message; + end; + end; + TThread.Queue(nil, + procedure + var + LEscReq, LEscPayload: string; + begin + LEscReq := StringReplace(LReqId, '"', '\"', [rfReplaceAll]); + // GET success path → ship body base64. Otherwise the field + // carries either the empty string or the exception message + // (for status=0 network errors). + if (LMethod = 'webdav/get') and (LStatus >= 200) and (LStatus < 300) then + LEscPayload := LBodyB64 + else + LEscPayload := LErr; + LEscPayload := StringReplace(LEscPayload, '\', '\\', [rfReplaceAll]); + LEscPayload := StringReplace(LEscPayload, '"', '\"', [rfReplaceAll]); + LEscPayload := StringReplace(LEscPayload, #13, '', [rfReplaceAll]); + LEscPayload := StringReplace(LEscPayload, #10, '\n', [rfReplaceAll]); + WebBrowser.ExecuteJavaScript( + 'if(window.Bridge&&Bridge.onWebdavResult)' + + 'Bridge.onWebdavResult("' + LEscReq + '",' + + IntToStr(LStatus) + ',"' + LEscPayload + '")'); + LogLine(Format('%s %s → %d (%d bytes payload)', + [LMethod, LUrl, LStatus, Length(LEscPayload)])); + end); + end).Start; + end + // ---- Native file save (bypasses WebView2's browser download UI) ------ // JS sends: cmd://file/save?name=&data=&reqId= // Delphi opens GetSaveFileName, writes the decoded bytes, then calls diff --git a/delphi-backend/assets/assets.res b/delphi-backend/assets/assets.res index 5d846bb95bb847688a9624357d41b3cec4584fa9..7ab11e69b14c4cac44d3b049b3f32392d4dde4e1 100644 GIT binary patch delta 16883 zcmch8dvsIRneUQxbR-*NL;S$l2DZ&Z64;Unk0b^=7-AAA*batf63RoCj%@|K&^f}m z2{E+kw4IwwplbO#ZJy0E&o(9Pblhf=2GZ#?(@qjhAG20(+K~QnJI!5_kTvPt-qpw4 z-?#TUl5CjV$y&YFD=QrBv-h|6{@$;1=IN#HomzfUc;2gWJ-hVfhc$B|9V-leeQ@p& z9z^gKWB7*N4~GiR-rZQ(|3F>g;*-}E&VRLW_2xu8kuzdeB9o3L1uqig32RJ5g<2s@ zBX`8eiD18R)QTlDd7A#NcR?^Sp5B5V@pjRhjmE|dk;=pm#G^+-VbPnnG94)+H)@FC z33_vzUR!wWl)u7Cqzu!Frm}_hziFJCHW;XBq%C1(Xx=A03$Mu;sf=Z8j12dPUmpLg zumIFdN3-Tw#u5or80lDUB5N6O=X|D-jpm|Oq4k-DHS*lqsA-O8a&a+{$pf?{GU=Eh zve+kX97*6YXhu^8MumYR7v_Jaws7)HL*d{PO?08)Z7#h2rR^FctI+xADuur3+ZqdE zqN(t3Mk{n4($wHnkA9}`vA3HF^FHjAFAn^5?{vDeLPU5oGqPu>Z&ze+=a3{~H1(>&z77KwG4<` zG82OU4$Jpq*ci#=49TZbCJWQIEiP<&qPcMK`H{kTm$r`i6ij9_{4YQKut-Hs3#{&p zg~i6<9w1(EpyGQL6<&QEivZ+r!WN7&nTuKhQ;cQC<+75Fhb zXU6#yeg?M#$S+KPoz-e{;kmCI{l@=zxO2bzz2ASl=|85L{=-%KIZrM+PG>&jZl>*@ zbyxYsop*{rU;{nk)9N7Y~-HABex!%`E$8U6Lls)g&n^Ku0Mqe5vGR)nXt(jyTs}gauU8HH# zaZkf(x7$Z=k*AK99B}*TVd0uH^`+0c_xk_;9&^!uddxs?GD+vUeD$X-kC%>r%C|@z z(tN<(a&j^f5!*&2hsJZ!(NVBlEDE&`BLQqd?T?6PT0~*fj=+Q%Nk!Rqo|K!Ygu*NS z0Z{Lb9Rl_%b_h^_Az@}9i}740%XT6vMkMY;L|D0~IR-0t#4u_4Hmz=8d@PfMg^OCT zF_B1PwjoBrOZl7;76UL|x$y*~Pwpvh&x6UVF&q%n!ZVJL1ZQL44YX;KYXN=uQ@$E) zwOFx&o*(x#QY_=Eqx&E9Eb)hL&m_`d4LZ2c8=B7t?S|;(66w*P5eEHFz2aI#_jPzy zc<^rOT!%+|XqiM#bU0~riPl7Vx4gjhB4Fk6MnH6lz(_P{(!1@R?X>DiPX+aLfR(Hq z^}g(?rQ`RyHG1eCcLm*vVgLV&QxZ;j^HgBM6a6()bW75R!XwBGS*#B?_iY{6)hBXB zM<#2eaZM%+KMo}mabtj;hj_*?(*aAQ5^yUH8AYrD7W)qNQcy4lI<}ecYCtJC98i1X zV=#^aj*m6b#?Hp1T!EP>SeryLSq=+i2DTX@Q?bL09s#Eui6--gDTebE6NxC5$aB!P3lp8~9CIxd1> z#4S;{GRaUmETD_o+s0Rvxu`o0*Ms>yZ!*TBV19s$iy@u~XJl|-Xt#)tMic3BK-goI zh&g5`o`({OfdnNsb@u2M41embVeKO;>76xhox%k$>C2ziTInx;rd3kpDbHM5a-TLd z6}?w`+(p5HXEDF7mT#u;W}#ax{55&%%iau}(pFwYFBs5Dk%DJ|0(slb?zvNsKB}$p zth`K(DX|QRJLE-}J2QtM5uNQ+IZt0i15X0{A0PDWqDz;c5<4H)>gnHn`ogKNKjisW z7j-|SUBz~gPWJ2b=)hB2oBZ`km0m+9pVETz*V+4_c`h~RmGq0p+!1H!4}Ml2nM12q z(c#}}bu{?6$J;C-D?v;URI(jJePu)(lP5byzaY;FYI(rrU!NEODRIhfoD&i*%tMK6 z7Q6&C9s;*QbEG%OMP(CLhv?GNT3e3+juG~Fk60Z7HDPgrft}DtaS;%!`2MT#!2Att zF|~PA;fY*#YxSRe^)YSg)yKRoiAlZ!q(s>TQ=|+l8jo605sYP$nOwU_fNMh13D2a# zr`en#9d+5*6@VL`C9dwg zdVNRddT76K@c1OW2FPF90_^Cp1P#%1ASs2b{2=H{K!Agh7)|rPsi7S=WNQ@&!8ck^DGm1s+h=;#>#i-Fk5$(3iZ0=(YqgFxHRug= zY1&&wmtOR0wZMB;6mRPmE?SlTNoTfoZB z#kxM#u}I%Ke^R2GHvtp$X0srv!l8&kTV|k}qa0e!VlBxOEG;%aF|U;aipJv!X}y{M zy1-0O@vPC5k8Bf3MByP%CXEqGRf4XN(wt7n{VMSmKBs8w7`?nek_x#XkAoePS3_IfO_S zp`v2cVZ;C&I4ebL2f1-$gd;Xn#Pe+G%MZlZ8_p-24|OC`ENTE_WTEgmSV!mrF)rYB2ZOL>Fa{3J;kWMHr}&Ms5v>k> z7!QyD{K3bTP2#=mFe72Ni|>87Et7;y0?1P`A%$dLD62V zc^BI;&%9PcO%5DYOXDABVm4^?Bj04zfnJ3oc1=-#WS8dX$$5K>OP-+0oruI zTSuvFdUZQ2LD7P;@=B!PDnNWVqrnPan2zRK8KK^5_13Fq;ewrdd13bBssFcHGhO;O zmzN@bm$z-!TvqssFo+HWgvN-|Bc~8!41CX9N%21Ys;PU!dZzK3qOzaG{xcOmWC!XJ z@f?d9WHw6Y7P@QMH%mZMM!~AX{7N}P%wofE*jO;6MuuXQ|FXP^u_(N0tG&puwt`3_ zJY&YS`C5$Tu*k7e9gOarzJNDW*OGYr;sz>EzE`Ei#EFe+F!kWrw3U$8om!7{x(4d#`>q z4HonTOW7~rvu$Aqb~G3YgLvs6_5@Fj@rym%rhfld`o~*>L$~&CaT1d4O6#d~p-em$ z^Mody_;=S0lVT?{V4}lL;qlJmNFoa7R^_{#t18V`N>hR(iaE+kX`9N^l-M>sfv`V? zJY51264I8trSrxqNWrNy=0F$%W70`GG7#z7IJpozohY-C8BQTsaIC@mQ6(VC0|H;F zG^N4oo$Q5F2P_EkI>k|Jsjk_f3?0*LavO7hNwQKfs|*VJ0nLauqp+^h?357zxksym&7sjTo=8t}%*shr+uh1%;Sd?|15rpf zz`(>0LuI7lax&k=5p4s3@D8(xGuZNnMZZMO2%-v0?AgY#@{!d_A4*loP{1OYWL`Q7 z!v=>h9M~i?yzQhZ6Vp7DgTa^XsB}FMWhGKzjcD4)f1U5OQ<3mBh7KU^xp#D zk;zz}8VD$rn$IO2*N$Iv(m2r0f+_zP72(ma_|v6|Co^fOe_6NN8>@Zs+dfiuyy$Jp zSIEoFJd&-Fip*Y?o0YKPOks_LE&=q(-wFmI7F-I^EW#woXDmCV?4%USq5w*0Pj6`I=RlJpT62vS8dCoJ&%sBb2X%L!8>foFduRUaVH{XXO}ML zLAmoeU?3~Tna|`@{AR@-G3=RrD#Q(qqfHIEX94l>JzZM6k=>^^ndU2~yr*yzo z;lXt&5^-&$qf1>?KIdF#m%5f+%?LY;O9f)7I0_pIbrFtU)ix6E4sQz#2dFpbTGn*= z_zIk-I{;#wSmJ6}j31@INUg`(6?|Mwl5;fvLGSizb#o=)6}UwaKnH#Zg^V&6mThA3 znuovWMn7Y3N?u^_YRqJ4zk6Qq ztWeNF58i)qC1CG?!ot?4C-jw7yw@I9Bp9FGpiqfJ}2hEA-) z4oWp|O=iY}4&Sj58#&fZC~GK9iIFt9Zt^ar!9RL5db(F{rLW!UtEiNxl->v(+@hmw z(VB|iD?(BmuvP)ZVKFUi~n1NI$pWak;V3}37OP_S&OWvl<0DtA06hO(@ zwks)3HOFPD77c$WZKgO05U2ln6WFxZ63DT_K3_{eJgfJ(1o_YDEjly_E=sO0PXitQ zvF4@UZ_#V%HK=DYf$>X{BciRIp9^!&GxVSt3quma9K z!SwBlo!`I#syPB(+fw+(ft$;cvz550@a^#=& zd9s~>j{md1WI2xSWSO1hF(g?Eob8#Rd4X2%^VTopi`)5S2Flq0C@i}`3Qt%n>Nh{+ zuDjK?qON&|UN@<(Bg2A$ z4~ZbV1jSik&5)xhkBMS_y!f6GxVDpBk)odfuWv{-v%qzajWNmLYdI|@GU$_qI;XHS zDU(cM0%($Ig7eQaN<(&)o}E$DY(NyI10oKtrEnN%=3_C#L`=aLn(X$x0I<*K6JJT_uN=g3aSe+Xi12xyIT*LHm`z5N9( zM9p8+R@2}YwGZ>p4t~<<{D<@<^sVQ#07Z7_emd}q9-!=ceQs@vdplr;Qcy`Lbjo#! z6rJ6NZ$!t zYP!pSlpz`Z$GWrFth?beFZ(#uM67o8kp`Ce)OHaB-MJ?O<;XdF61zl>Y`Ty<4@qNpzkiCw9r6#v$9rAin4P zqGOXJJF6iUht`?Lwso7W&O$}weTT|nbsyyks4OI7JU}7P=^jQaV_3hNukp${;y<2zp?JBcGHZsuym^E>~5$<@BnlFZajWC^f z+_ShzAz^r84;OP)d2c6r%sA$TZe2RbS94UWniV!t{!>Hk!oWm_9Cp=9s#9>7RJE#P z1oNwM_|Uov&s=$BV}L3S=Ysp}O0EOSF|yHh@OfvIa)az$W4)b|n^8_iVwy`Ck{KX< zELBd4k{;>z)OO27{ZOY&^7pM}mdu!4MhtDBH&gC9Rf?Pi+g0tVcPjr7+0rhv>yVAq z?M8t=TV9C#^699zf(z(=t|xYWP?Jeq3!-@!yk7e1)7l*R%l#UPl57u{UC8w^3ZED6 zfewG;8NFuru_Cz!i@AH5ope z%uC@>a7j+So+T))aw46Y^wswEgKHT!xR{p+t^RXJ z8ZYJ4>v-Wg6Dvy;3`m`>%x==cqO z`rR6RV`=EE>tLz7&U@!m-#T3>8p&Qrpk+>e3*7S1I=zms+pcQatddZ} zoJY?biDPE32-<CA6D+MHI!rwo8Q3v7wL0t>ow^z480G;|H_*)3~CPW{hrT7kZm zZ6SySv+zQgr%TZXqtgu2rp>OpF3UhKKbLe@N$y31F|@ed*Min1{(eDOU?d3Slhm#G z6wb%%uNmjqW`ygx2sF@~0=tT0+i)g6AyqXC5?3`7sZ`#Q(FbavsM_;~5K2)o>v)ya zo;=SzA)nNiNHWi`U?mBeW22OFOBCIvFNDpSd`ho}gu{HNA%)TbDLNlTH-%b%7TI&guqZvsh-jP$G&p5<} zx1tm)pNEfA$~z5y8C{UvUgM4q)5-7ZA$t9ir}A2KpE6%Yhgnxdhr{^IeyVzcugbla zrIelHeZ&*==C!WoMI7^Cd2~DqxhM+}WrRqrC(#7*;(0w#&GS?O20S-NXWs*NOBVj* zw7#;?f1{tCoz{D3;~bZt<}K6Fe|Z6U!&`rU~re$RPxPK&9L1BfbthsL~>_KjL_`Wv*&hnWu_p z$Fb#QS>Oi2T{q3QT&;BJq^sig8L24Pr2k4L%0X%|eR9QY)EXy0Wd|E{;0s!NCBC0Y z??y@ihd7Y~0?v+lt1F5Lk&?Y(GE~)ag%ZgUwDJd?g|vDOG~dZnKA#6PqTen@;MD&s z?;O9w%#v7vB8C*HuM|}z5Ju!!fJgFT*T`g{(MdOGR{qTVy3o+1;^Gh}2{npAyKe7)i=a zdtZsS;KHQpLpG_BXVvH00<=nLCt2oXL!dp{wP z-vKHgBKtikgvHl*D>gFl%@ep=dDov*(J;X8m-2;>QR^ef!fXoaa~E+WsuGtUkWA*7 zBojTb11?YKNBT1Q<~hBlydJ>Gb9@Gq8K;^DT=kPSZu2R8w>2TUi#S)L4XXfJ)`sn1 z8rfR#%T%&2?zX>uRljDtj_;pf2IzNnA`lN`#FklaYl=MC@_%R@z33yi|FhR zT>2)v%YQW|%~Xdz=d1bq8(ErtzovaTi^T0*T;#8w27dzOG5IJuV|V-nUszP%hZDVW zEh5Xa_;73L<4wMn&0(lxsimB6d_|I5BGHPbREW*u&|_PMp#~_^>s_&w7xdXZMRwd) zHg9BfbLJ>zd%a7R@Uj_=0HtYra>+hQqkg>@lkmeSR+PcKJLwdsr{GTZ)dV1$JpP$Zb_0{{}8FGgN9pC0_qPO<>YHlqnJ|fl2 z4PIzcOf#3PmW<(mW@TLjFKJ*LY*uar0c_HNqA?`9*_)?5_x;w=ES6 zIf}jrP?B7om+1}iUl}6a{TD$pgE(iD&n1Z0mz^=owe^{2oP9X(P6-k}mmrnfh?4LA zyFp*e(!gvmim}ukZJxtNbIlP(B7XNtHs0gFRC(ecpH9#mOy5M8LVg zcVwt0ONUT>Q`Ut~%~CKZ!-!d=)50G+B1Axc<{?hyB8pU**G%5x@LL9~S#((@`5QG^ zRzVDDi6Fd`ID6rUF}Dxd4YV9M$&V)!w@Bram4!EaFDE;8Whk08jeI=Qf&4nBN;q{1 zU@`s#1GPK+e*!%k(5?uX*~oIVY=6lq&k&|RF$%o$%Bo&APirsYc8Rs?Hn2g64&&$u)3e+RA6(ms%Hgm9(u=Uk zUn=swL~tFx@PdU{J>hR!$BWEybkSjsL^_Iw=#WJ|-HbR*^(rZD8WQ;_Ui!{Q{q=No UrGF*8@nzrqsRusluig890A|O_EdT%j delta 892 zcma)4O=uKJ6rSm>R8OL_#z~BdCi{YD#{7*&JVb-UU)WJh)PRSmSkqma-t=^h-8Bg# z{zOnjTv$bSAA&5Rq6Q6)3RVsZMnMGe=OCiHAR=Boh#owy%hn_!F6_ZeJ?ee$``-7e z-lyYpiiY#7mU~`9NpXhyaK)%RhVPe|l{kFVteLz?2Cly|$xHUst7Rsv#g}Qb3Wqw(1*-BF z(|M=n4U&zGczTr7mW~RXb=!<%_XV;FH@6#8RQ*|UG5Wtq&v|nH8$@YNV?t?-t>)q3 zF`|aY%Bo$})6htvJ!#bY_}Gs@eechgTV)|>AKN|ozDp|>-8rdXo=Qfb|tOB&VyDj z4$Y5R`0zd{$F5N_6>rau#!3Li$}Myj=4M98*DINLt>PgoHUZq}A{4v#n3J(FVa4&u zEt6tV8KCYSvYJ<5@tHLVJKtL5_a_!r_G7EOqbw4MjCLr$BKGU+{p$rdZZ?(i@&UL$ z*xZ*q&>Jx_y{+!%!~`xKb}`D0D}ySxT^y6Xvyq zvY=TRX7@XR$ibk + +