feat: WebDAV sync + batch DnD + clean shutdown + center-modal UX bundle
- 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 <noreply@anthropic.com>
This commit is contained in:
@@ -454,6 +454,53 @@ hotkeys autofill, etc. **Device-only** (localStorage seulement) :
|
|||||||
`quickUnlockEnabled` (DPAPI lié au compte Windows), `autofillEnabled`
|
`quickUnlockEnabled` (DPAPI lié au compte Windows), `autofillEnabled`
|
||||||
(toggle hotkey Win32), `rememberedUsername` (auth screen autofill local).
|
(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
|
## PIN unlock
|
||||||
|
|
||||||
Optional shortcut unlock with a 4–12 digit PIN, complementary to Quick
|
Optional shortcut unlock with a 4–12 digit PIN, complementary to Quick
|
||||||
|
|||||||
+135
@@ -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)
|
||||||
+14
-4
@@ -2059,10 +2059,20 @@ body[data-editor-position="center"] #settingsPanel.is-open {
|
|||||||
opacity: 1;
|
opacity: 1;
|
||||||
pointer-events: auto;
|
pointer-events: auto;
|
||||||
}
|
}
|
||||||
/* No dim/blur in center mode — the panel doesn't actually block
|
/* Center mode = true modal: dim + blur backdrop, click anywhere
|
||||||
interaction (cards, sidebar, topbar stay clickable without
|
outside the panel dismisses it (handled in JS). The backdrop is
|
||||||
dismissing it), so painting a modal-style backdrop would lie about
|
painted via a body pseudo-element so it covers everything but
|
||||||
the behaviour. The panel just floats above the page. */
|
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 {
|
.slideover-header {
|
||||||
display: flex; align-items: center; justify-content: space-between;
|
display: flex; align-items: center; justify-content: space-between;
|
||||||
padding: 16px 20px;
|
padding: 16px 20px;
|
||||||
|
|||||||
@@ -28,6 +28,21 @@ begin
|
|||||||
if Result = '' then Result := ADefault;
|
if Result = '' then Result := ADefault;
|
||||||
end;
|
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
|
// SQLite DATETIME columns: FireDAC parses to TDateTime internally, then AsString
|
||||||
// would format in system locale (DD/MM/YYYY in French). Force ISO format
|
// 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
|
// '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)
|
LObj.AddPair('template', TJSONNull.Create)
|
||||||
else
|
else
|
||||||
LObj.AddPair('template', LQ.FieldByName('template').AsString);
|
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
|
// Custom fields: opaque ciphertext + IV, treated identically to
|
||||||
// password / totp_secret. NULL → JSON null so the client can
|
// password / totp_secret. NULL → JSON null so the client can
|
||||||
// distinguish "never set" from "empty array stored".
|
// distinguish "never set" from "empty array stored".
|
||||||
@@ -165,6 +182,117 @@ begin
|
|||||||
TJSONHelper.SendJSON(AResponse, LArr);
|
TJSONHelper.SendJSON(AResponse, LArr);
|
||||||
end;
|
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<string>);
|
||||||
|
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<string>);
|
||||||
|
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<TJSONArray>('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 =========================================================
|
// ===== POST /entries =========================================================
|
||||||
|
|
||||||
procedure HandleCreateEntry(ARequest: TIdHTTPRequestInfo;
|
procedure HandleCreateEntry(ARequest: TIdHTTPRequestInfo;
|
||||||
@@ -173,7 +301,7 @@ var
|
|||||||
LUserId, LNewId: Integer;
|
LUserId, LNewId: Integer;
|
||||||
LBody, LObj: TJSONObject;
|
LBody, LObj: TJSONObject;
|
||||||
LSite, LTitle, LUser, LFolder, LEnc, LIV, LTags, LNow, LTotpSec, LTotpIv,
|
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;
|
LQ: TFDQuery;
|
||||||
begin
|
begin
|
||||||
try
|
try
|
||||||
@@ -200,6 +328,10 @@ begin
|
|||||||
LCfIv := LBody.GetValue<string>('custom_fields_iv', '');
|
LCfIv := LBody.GetValue<string>('custom_fields_iv', '');
|
||||||
LIcon := LBody.GetValue<string>('icon_b64', '');
|
LIcon := LBody.GetValue<string>('icon_b64', '');
|
||||||
LTemplate:= Trim(LBody.GetValue<string>('template', ''));
|
LTemplate:= Trim(LBody.GetValue<string>('template', ''));
|
||||||
|
// Caller may bring its own UUID (sync restore / import preserving
|
||||||
|
// identity). Otherwise the server mints a fresh one.
|
||||||
|
LUuid := Trim(LBody.GetValue<string>('uuid', ''));
|
||||||
|
if LUuid = '' then LUuid := NewUUIDv4;
|
||||||
finally
|
finally
|
||||||
LBody.Free;
|
LBody.Free;
|
||||||
end;
|
end;
|
||||||
@@ -229,9 +361,9 @@ begin
|
|||||||
'INSERT INTO vault_entries ' +
|
'INSERT INTO vault_entries ' +
|
||||||
'(user_id, site, title, username, encrypted_password, iv, encryption_method, ' +
|
'(user_id, site, title, username, encrypted_password, iv, encryption_method, ' +
|
||||||
' folder, tags, totp_secret, totp_iv, kind, custom_fields, custom_fields_iv,' +
|
' 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, ' +
|
'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('uid').AsInteger := LUserId;
|
||||||
LQ.ParamByName('s').AsString := LSite;
|
LQ.ParamByName('s').AsString := LSite;
|
||||||
LQ.ParamByName('tt').AsString := LTitle;
|
LQ.ParamByName('tt').AsString := LTitle;
|
||||||
@@ -266,6 +398,7 @@ begin
|
|||||||
LQ.ParamByName('tpl').DataType := ftString;
|
LQ.ParamByName('tpl').DataType := ftString;
|
||||||
if LTemplate = '' then LQ.ParamByName('tpl').Clear
|
if LTemplate = '' then LQ.ParamByName('tpl').Clear
|
||||||
else LQ.ParamByName('tpl').AsString := LTemplate;
|
else LQ.ParamByName('tpl').AsString := LTemplate;
|
||||||
|
LQ.ParamByName('uuid').AsString := LUuid;
|
||||||
LQ.ParamByName('c').AsString := LNow;
|
LQ.ParamByName('c').AsString := LNow;
|
||||||
LQ.ParamByName('c2').AsString := LNow;
|
LQ.ParamByName('c2').AsString := LNow;
|
||||||
LQ.ExecSQL;
|
LQ.ExecSQL;
|
||||||
@@ -280,6 +413,7 @@ begin
|
|||||||
LogAudit(LUserId, 'add_entry', GetClientIP(ARequest));
|
LogAudit(LUserId, 'add_entry', GetClientIP(ARequest));
|
||||||
LObj := TJSONObject.Create;
|
LObj := TJSONObject.Create;
|
||||||
LObj.AddPair('id', TJSONNumber.Create(LNewId));
|
LObj.AddPair('id', TJSONNumber.Create(LNewId));
|
||||||
|
LObj.AddPair('uuid', LUuid);
|
||||||
LObj.AddPair('site', LSite);
|
LObj.AddPair('site', LSite);
|
||||||
LObj.AddPair('title', LTitle);
|
LObj.AddPair('title', LTitle);
|
||||||
LObj.AddPair('username', LUser);
|
LObj.AddPair('username', LUser);
|
||||||
@@ -475,7 +609,19 @@ begin
|
|||||||
try
|
try
|
||||||
LQ.Connection := DB.Connection;
|
LQ.Connection := DB.Connection;
|
||||||
if LPermanent then
|
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
|
else
|
||||||
LQ.SQL.Text :=
|
LQ.SQL.Text :=
|
||||||
'UPDATE vault_entries SET deleted=1, deleted_at=datetime(''now'') ' +
|
'UPDATE vault_entries SET deleted=1, deleted_at=datetime(''now'') ' +
|
||||||
@@ -870,6 +1016,16 @@ begin
|
|||||||
LQ := TFDQuery.Create(nil);
|
LQ := TFDQuery.Create(nil);
|
||||||
try
|
try
|
||||||
LQ.Connection := DB.Connection;
|
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 :=
|
LQ.SQL.Text :=
|
||||||
'DELETE FROM vault_entries ' +
|
'DELETE FROM vault_entries ' +
|
||||||
'WHERE user_id = :uid AND deleted = 1 ' +
|
'WHERE user_id = :uid AND deleted = 1 ' +
|
||||||
@@ -914,6 +1070,12 @@ begin
|
|||||||
LQ := TFDQuery.Create(nil);
|
LQ := TFDQuery.Create(nil);
|
||||||
try
|
try
|
||||||
LQ.Connection := DB.Connection;
|
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.SQL.Text := 'DELETE FROM vault_entries WHERE user_id=:uid AND deleted=1';
|
||||||
LQ.ParamByName('uid').AsInteger := LUserId;
|
LQ.ParamByName('uid').AsInteger := LUserId;
|
||||||
LQ.ExecSQL;
|
LQ.ExecSQL;
|
||||||
@@ -940,7 +1102,7 @@ var
|
|||||||
LBody, LObj, LEntry: TJSONObject;
|
LBody, LObj, LEntry: TJSONObject;
|
||||||
LArr, LIds: TJSONArray;
|
LArr, LIds: TJSONArray;
|
||||||
LSite, LTitle, LUser, LFolder, LEnc, LIV, LTags, LTotpSec, LTotpIv, LNow,
|
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;
|
LQ: TFDQuery;
|
||||||
begin
|
begin
|
||||||
try
|
try
|
||||||
@@ -985,9 +1147,9 @@ begin
|
|||||||
'INSERT INTO vault_entries ' +
|
'INSERT INTO vault_entries ' +
|
||||||
'(user_id, site, title, username, encrypted_password, iv, encryption_method, ' +
|
'(user_id, site, title, username, encrypted_password, iv, encryption_method, ' +
|
||||||
' folder, tags, totp_secret, totp_iv, kind, custom_fields, custom_fields_iv,' +
|
' 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, ' +
|
'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
|
// Declare optional param types ONCE — the prepared statement is
|
||||||
// reused across every imported entry, and FireDAC needs the
|
// reused across every imported entry, and FireDAC needs the
|
||||||
// type set before the first .Clear call would otherwise fail
|
// type set before the first .Clear call would otherwise fail
|
||||||
@@ -1021,6 +1183,8 @@ begin
|
|||||||
LCfIv := LEntry.GetValue<string>('custom_fields_iv', '');
|
LCfIv := LEntry.GetValue<string>('custom_fields_iv', '');
|
||||||
LIcon := LEntry.GetValue<string>('icon_b64', '');
|
LIcon := LEntry.GetValue<string>('icon_b64', '');
|
||||||
LTemplate:= Trim(LEntry.GetValue<string>('template', ''));
|
LTemplate:= Trim(LEntry.GetValue<string>('template', ''));
|
||||||
|
LUuid := Trim(LEntry.GetValue<string>('uuid', ''));
|
||||||
|
if LUuid = '' then LUuid := NewUUIDv4;
|
||||||
|
|
||||||
// Ciphertext is always required. Site is required only for
|
// Ciphertext is always required. Site is required only for
|
||||||
// logins — notes legitimately have no site (their body lives
|
// 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 LIcon = '' then LQ.ParamByName('ic').Clear else LQ.ParamByName('ic').Value := LIcon;
|
||||||
if LTemplate = '' then LQ.ParamByName('tpl').Clear
|
if LTemplate = '' then LQ.ParamByName('tpl').Clear
|
||||||
else LQ.ParamByName('tpl').AsString := LTemplate;
|
else LQ.ParamByName('tpl').AsString := LTemplate;
|
||||||
|
LQ.ParamByName('uuid').AsString := LUuid;
|
||||||
LQ.ParamByName('c').AsString := LNow;
|
LQ.ParamByName('c').AsString := LNow;
|
||||||
LQ.ParamByName('c2').AsString := LNow;
|
LQ.ParamByName('c2').AsString := LNow;
|
||||||
LQ.ExecSQL;
|
LQ.ExecSQL;
|
||||||
@@ -1136,6 +1301,8 @@ initialization
|
|||||||
Router.Register('DELETE', '/entries/trash/empty', HandleEmptyTrash);
|
Router.Register('DELETE', '/entries/trash/empty', HandleEmptyTrash);
|
||||||
Router.Register('DELETE', '/entries/trash/old', HandleAutoPurgeTrash);
|
Router.Register('DELETE', '/entries/trash/old', HandleAutoPurgeTrash);
|
||||||
Router.Register('DELETE', '/entries/icons/all', HandleClearAllIcons);
|
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/bulk-import', HandleBulkImport);
|
||||||
Router.Register('POST', '/entries/(\d+)/restore', HandleRestoreEntry);
|
Router.Register('POST', '/entries/(\d+)/restore', HandleRestoreEntry);
|
||||||
Router.Register('POST', '/entries/(\d+)/favorite', HandleToggleFavorite);
|
Router.Register('POST', '/entries/(\d+)/favorite', HandleToggleFavorite);
|
||||||
|
|||||||
@@ -92,6 +92,12 @@ type
|
|||||||
FSavedPlacement: TWindowPlacement;
|
FSavedPlacement: TWindowPlacement;
|
||||||
FHasSavedPlacement: Boolean;
|
FHasSavedPlacement: Boolean;
|
||||||
FOnSystemLock: TProc;
|
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;
|
FOnTrayRestore: TProc;
|
||||||
FOnLockRequest: TProc;
|
FOnLockRequest: TProc;
|
||||||
FOnQuit: TProc;
|
FOnQuit: TProc;
|
||||||
@@ -158,6 +164,9 @@ type
|
|||||||
property AutofillRegistered: Boolean read FAutofillRegistered;
|
property AutofillRegistered: Boolean read FAutofillRegistered;
|
||||||
// Fired on main thread when Windows locks the session (WTS_SESSION_LOCK).
|
// Fired on main thread when Windows locks the session (WTS_SESSION_LOCK).
|
||||||
property OnSystemLock: TProc read FOnSystemLock write FOnSystemLock;
|
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.
|
// Fired on main thread when the user clicks the tray icon.
|
||||||
property OnTrayRestore: TProc read FOnTrayRestore write FOnTrayRestore;
|
property OnTrayRestore: TProc read FOnTrayRestore write FOnTrayRestore;
|
||||||
// Fired when the user picks "Lock vault" from the tray menu. Handler
|
// Fired when the user picks "Lock vault" from the tray menu. Handler
|
||||||
@@ -735,6 +744,18 @@ begin
|
|||||||
if Assigned(FOnSystemLock) then FOnSystemLock();
|
if Assigned(FOnSystemLock) then FOnSystemLock();
|
||||||
end
|
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
|
else if (AMsg.Msg <> 0) and (AMsg.Msg = WM_PMShowMessage) then
|
||||||
begin
|
begin
|
||||||
// A second instance was launched and PostMessage'd HWND_BROADCAST.
|
// A second instance was launched and PostMessage'd HWND_BROADCAST.
|
||||||
|
|||||||
@@ -314,6 +314,42 @@ begin
|
|||||||
// Drives the card/table label so notes-with-fields read as "Credit card"
|
// Drives the card/table label so notes-with-fields read as "Credit card"
|
||||||
// instead of the generic "Encrypted note" placeholder.
|
// instead of the generic "Encrypted note" placeholder.
|
||||||
AddColumnIfMissing('vault_entries', 'template', 'TEXT');
|
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
|
// Per-folder customisation. NULL = no override → JS uses the default
|
||||||
// accent + i-folder symbol.
|
// accent + i-folder symbol.
|
||||||
AddColumnIfMissing('folders', 'color', 'TEXT');
|
AddColumnIfMissing('folders', 'color', 'TEXT');
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ interface
|
|||||||
uses
|
uses
|
||||||
System.SysUtils, System.Classes, System.UITypes, System.NetEncoding,
|
System.SysUtils, System.Classes, System.UITypes, System.NetEncoding,
|
||||||
System.StrUtils, System.Generics.Collections, System.IOUtils, System.JSON,
|
System.StrUtils, System.Generics.Collections, System.IOUtils, System.JSON,
|
||||||
|
System.Net.HttpClient, System.Net.URLClient,
|
||||||
Winapi.Windows, Winapi.ShellAPI,
|
Winapi.Windows, Winapi.ShellAPI,
|
||||||
FMX.Forms, FMX.Controls, FMX.Controls.Presentation, FMX.StdCtrls,
|
FMX.Forms, FMX.Controls, FMX.Controls.Presentation, FMX.StdCtrls,
|
||||||
FMX.Memo, FMX.Memo.Types, FMX.ScrollBox, FMX.Edit, FMX.Layouts, FMX.Types,
|
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.
|
// so we bypass the minimize-to-tray intercept in that case.
|
||||||
if FQuitting then Exit;
|
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
|
// Otherwise: minimize to tray on close instead of quitting, so the vault
|
||||||
// stays available without the dev-panel being visible.
|
// stays available without the dev-panel being visible.
|
||||||
// When the server is stopped, allow normal close — there's no vault to
|
// When the server is stopped, allow normal close — there's no vault to
|
||||||
@@ -932,6 +945,119 @@ begin
|
|||||||
BoolToStr(PM.AutoStart.IsAutoStartEnabled, True).ToLower + ')');
|
BoolToStr(PM.AutoStart.IsAutoStartEnabled, True).ToLower + ')');
|
||||||
end
|
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) ------
|
// ---- Native file save (bypasses WebView2's browser download UI) ------
|
||||||
// JS sends: cmd://file/save?name=<filename>&data=<base64>&reqId=<id>
|
// JS sends: cmd://file/save?name=<filename>&data=<base64>&reqId=<id>
|
||||||
// Delphi opens GetSaveFileName, writes the decoded bytes, then calls
|
// Delphi opens GetSaveFileName, writes the decoded bytes, then calls
|
||||||
|
|||||||
Binary file not shown.
+51
@@ -646,6 +646,57 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="slideover-field" id="syncField" style="display:none">
|
||||||
|
<div class="slideover-field-label">Sync (WebDAV)</div>
|
||||||
|
<p style="font-size:11px;color:var(--text-faint);margin:0 0 8px;line-height:1.5">
|
||||||
|
Bidirectional sync with a WebDAV server (Nextcloud,
|
||||||
|
ownCloud, Apache mod_dav). Auto-merge by entry
|
||||||
|
timestamp. <b>Master password is never sent to the
|
||||||
|
remote</b> — the snapshot is encrypted with a separate
|
||||||
|
sync password you set once per device (same on each).
|
||||||
|
</p>
|
||||||
|
<label style="display:flex;align-items:center;gap:8px;margin-bottom:8px">
|
||||||
|
<input type="checkbox" id="settingSyncEnabled">
|
||||||
|
<span>Enable sync</span>
|
||||||
|
</label>
|
||||||
|
<div id="syncConfig" style="display:none;border-left:2px solid var(--border);padding-left:10px;margin-left:4px">
|
||||||
|
<div style="margin-bottom:6px">
|
||||||
|
<div style="font-size:11px;color:var(--text-faint);margin-bottom:4px">WebDAV URL (full path to the snapshot file)</div>
|
||||||
|
<input type="text" id="syncUrl" class="so-input"
|
||||||
|
placeholder="https://cloud.example.com/remote.php/dav/files/USER/PMServer/vault-sync.json"
|
||||||
|
style="width:100%;font-size:12px">
|
||||||
|
</div>
|
||||||
|
<div style="display:flex;gap:8px;margin-bottom:6px">
|
||||||
|
<label style="flex:1">
|
||||||
|
<div style="font-size:11px;color:var(--text-faint);margin-bottom:4px">Username</div>
|
||||||
|
<input type="text" id="syncUser" class="so-input" style="width:100%">
|
||||||
|
</label>
|
||||||
|
<label style="flex:1">
|
||||||
|
<div style="font-size:11px;color:var(--text-faint);margin-bottom:4px">Password / app token</div>
|
||||||
|
<input type="password" id="syncPwd" class="so-input" style="width:100%">
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<label style="display:flex;align-items:center;gap:8px;margin:8px 0">
|
||||||
|
<input type="checkbox" id="syncPreBackup">
|
||||||
|
<span style="font-size:12px">Create a local backup before each sync</span>
|
||||||
|
</label>
|
||||||
|
<p style="font-size:11px;color:var(--text-faint);margin:0 0 8px;line-height:1.4">
|
||||||
|
Sync password ≠ master password. <b>Use the same one
|
||||||
|
on every device</b> — that's how the encrypted snapshot
|
||||||
|
round-trips. Stored DPAPI-protected locally.
|
||||||
|
</p>
|
||||||
|
<div style="display:flex;gap:6px;flex-wrap:wrap;align-items:center;margin-bottom:6px">
|
||||||
|
<button class="btn btn-ghost btn-sm" id="syncSetPwdBtn">Set sync password</button>
|
||||||
|
<span id="syncPwdStatus" style="font-size:11px;color:var(--text-faint)">Not set.</span>
|
||||||
|
</div>
|
||||||
|
<div style="display:flex;gap:6px;flex-wrap:wrap;align-items:center;margin-top:6px">
|
||||||
|
<button class="btn btn-ghost btn-sm" id="syncTestBtn">Test connection</button>
|
||||||
|
<button class="btn btn-primary btn-sm" id="syncNowBtn">Sync now</button>
|
||||||
|
<span id="syncLast" style="font-size:11px;color:var(--text-faint)"></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="slideover-field" id="autoBackupField" style="display:none">
|
<div class="slideover-field" id="autoBackupField" style="display:none">
|
||||||
<div class="slideover-field-label">Auto-backup</div>
|
<div class="slideover-field-label">Auto-backup</div>
|
||||||
<p style="font-size:12px;color:var(--text-dim);margin:0 0 8px;line-height:1.5">
|
<p style="font-size:12px;color:var(--text-dim);margin:0 0 8px;line-height:1.5">
|
||||||
|
|||||||
@@ -2451,8 +2451,12 @@ function renderSidebar() {
|
|||||||
await reorderFolderTo(draggedFolder, name, before);
|
await reorderFolderTo(draggedFolder, name, before);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const id = e.dataTransfer.getData('text/plain');
|
const raw = e.dataTransfer.getData('text/plain') || '';
|
||||||
if (id) await moveEntryToFolder(parseInt(id), name);
|
const ids = raw.split(',').map(s => parseInt(s)).filter(n => n > 0);
|
||||||
|
if (ids.length) {
|
||||||
|
await moveEntriesToFolder(ids, name);
|
||||||
|
state.checked.clear();
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const delBtn = el('button', {
|
const delBtn = el('button', {
|
||||||
@@ -2496,8 +2500,12 @@ function renderSidebar() {
|
|||||||
item.addEventListener('drop', async e => {
|
item.addEventListener('drop', async e => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
item.classList.remove('drag-over');
|
item.classList.remove('drag-over');
|
||||||
const id = e.dataTransfer.getData('text/plain');
|
const raw = e.dataTransfer.getData('text/plain') || '';
|
||||||
if (id) await moveEntryToFolder(parseInt(id), 'All');
|
const ids = raw.split(',').map(s => parseInt(s)).filter(n => n > 0);
|
||||||
|
if (ids.length) {
|
||||||
|
await moveEntriesToFolder(ids, 'All');
|
||||||
|
state.checked.clear();
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
fList.appendChild(item);
|
fList.appendChild(item);
|
||||||
@@ -3385,7 +3393,16 @@ function renderCard(e) {
|
|||||||
|
|
||||||
if (!inTrash) {
|
if (!inTrash) {
|
||||||
card.addEventListener('dragstart', ev => {
|
card.addEventListener('dragstart', ev => {
|
||||||
ev.dataTransfer.setData('text/plain', String(e.id));
|
// If the dragged card is part of an active selection, carry
|
||||||
|
// ALL checked ids so a drop on a folder / trash moves the
|
||||||
|
// whole batch in one gesture. Otherwise carry just this one.
|
||||||
|
let ids;
|
||||||
|
if (state.checked.size > 1 && state.checked.has(e.id)) {
|
||||||
|
ids = Array.from(state.checked).join(',');
|
||||||
|
} else {
|
||||||
|
ids = String(e.id);
|
||||||
|
}
|
||||||
|
ev.dataTransfer.setData('text/plain', ids);
|
||||||
ev.dataTransfer.effectAllowed = 'move';
|
ev.dataTransfer.effectAllowed = 'move';
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -3849,8 +3866,21 @@ function renderTableRow(e) {
|
|||||||
+ (state.selectedId === e.id ? ' is-selected' : '')
|
+ (state.selectedId === e.id ? ' is-selected' : '')
|
||||||
+ (checked ? ' is-checked' : ''),
|
+ (checked ? ' is-checked' : ''),
|
||||||
'data-id': String(e.id),
|
'data-id': String(e.id),
|
||||||
|
draggable: !inTrash ? 'true' : 'false',
|
||||||
on: { click: ev => handleCardClick(ev, e, inTrash) },
|
on: { click: ev => handleCardClick(ev, e, inTrash) },
|
||||||
});
|
});
|
||||||
|
if (!inTrash) {
|
||||||
|
tr.addEventListener('dragstart', ev => {
|
||||||
|
let ids;
|
||||||
|
if (state.checked.size > 1 && state.checked.has(e.id)) {
|
||||||
|
ids = Array.from(state.checked).join(',');
|
||||||
|
} else {
|
||||||
|
ids = String(e.id);
|
||||||
|
}
|
||||||
|
ev.dataTransfer.setData('text/plain', ids);
|
||||||
|
ev.dataTransfer.effectAllowed = 'move';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Cells are emitted in EXACTLY the same order as getTableColumns()
|
// Cells are emitted in EXACTLY the same order as getTableColumns()
|
||||||
// returns headers, otherwise THs and TDs drift apart and clicks land
|
// returns headers, otherwise THs and TDs drift apart and clicks land
|
||||||
@@ -5293,6 +5323,13 @@ async function soSave() {
|
|||||||
if (typeof healthCache !== 'undefined') healthCache = null;
|
if (typeof healthCache !== 'undefined') healthCache = null;
|
||||||
|
|
||||||
const updated = state.entries.find(x => x.id === targetId);
|
const updated = state.entries.find(x => x.id === targetId);
|
||||||
|
// Clear soState BEFORE re-opening so openSlideOver doesn't mistake
|
||||||
|
// the re-open for a "switch entry while dirty" — the form still
|
||||||
|
// holds the just-saved values but soState.original is stale, which
|
||||||
|
// would falsely trigger the discard-confirm modal and (on Cancel)
|
||||||
|
// leave soState at id=null, causing a second Save to POST again
|
||||||
|
// and create a duplicate.
|
||||||
|
soState = null;
|
||||||
if (updated) openSlideOver(updated.id);
|
if (updated) openSlideOver(updated.id);
|
||||||
else closeSlideOver();
|
else closeSlideOver();
|
||||||
render();
|
render();
|
||||||
@@ -5586,6 +5623,7 @@ async function restoreEntry(id) {
|
|||||||
try {
|
try {
|
||||||
await api('/entries/' + id + '/restore', { method: 'POST', headers: authHeaders() });
|
await api('/entries/' + id + '/restore', { method: 'POST', headers: authHeaders() });
|
||||||
toast('Restored');
|
toast('Restored');
|
||||||
|
state.checked.delete(id);
|
||||||
await loadEntries();
|
await loadEntries();
|
||||||
await loadTrash();
|
await loadTrash();
|
||||||
render();
|
render();
|
||||||
@@ -5603,6 +5641,7 @@ async function permanentDelete(id) {
|
|||||||
try {
|
try {
|
||||||
await api('/entries/' + id + '?permanent=1', { method: 'DELETE', headers: authHeaders() });
|
await api('/entries/' + id + '?permanent=1', { method: 'DELETE', headers: authHeaders() });
|
||||||
toast('Deleted permanently');
|
toast('Deleted permanently');
|
||||||
|
state.checked.delete(id);
|
||||||
await loadTrash();
|
await loadTrash();
|
||||||
render();
|
render();
|
||||||
} catch (err) { toast(err.message, 'error'); }
|
} catch (err) { toast(err.message, 'error'); }
|
||||||
@@ -5620,6 +5659,7 @@ async function emptyTrash() {
|
|||||||
try {
|
try {
|
||||||
await api('/entries/trash/empty', { method: 'DELETE', headers: authHeaders() });
|
await api('/entries/trash/empty', { method: 'DELETE', headers: authHeaders() });
|
||||||
toast('Trash emptied');
|
toast('Trash emptied');
|
||||||
|
state.checked.clear();
|
||||||
await loadTrash();
|
await loadTrash();
|
||||||
render();
|
render();
|
||||||
} catch (err) { toast(err.message, 'error'); }
|
} catch (err) { toast(err.message, 'error'); }
|
||||||
@@ -5727,6 +5767,7 @@ async function deleteEntry(id) {
|
|||||||
await api('/entries/' + id, { method: 'DELETE', headers: authHeaders() });
|
await api('/entries/' + id, { method: 'DELETE', headers: authHeaders() });
|
||||||
toast('Moved to trash');
|
toast('Moved to trash');
|
||||||
closeSlideOver();
|
closeSlideOver();
|
||||||
|
state.checked.delete(id);
|
||||||
await loadEntries();
|
await loadEntries();
|
||||||
state.trashedCount = (state.trashedCount || 0) + 1;
|
state.trashedCount = (state.trashedCount || 0) + 1;
|
||||||
render();
|
render();
|
||||||
@@ -5752,6 +5793,42 @@ async function togglePin(id) {
|
|||||||
} catch (e) { toast(e.message, 'error'); }
|
} catch (e) { toast(e.message, 'error'); }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function moveEntriesToFolder(ids, folder) {
|
||||||
|
let ok = 0, skipped = 0;
|
||||||
|
for (const id of ids) {
|
||||||
|
const e = state.entries.find(x => x.id === id);
|
||||||
|
if (!e) continue;
|
||||||
|
if (e.folder === folder) { skipped++; continue; }
|
||||||
|
try {
|
||||||
|
await api('/entries/' + id, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: authHeaders({ 'Content-Type': 'application/json' }),
|
||||||
|
body: JSON.stringify({
|
||||||
|
site: e.site,
|
||||||
|
title: e.title || '',
|
||||||
|
username: e.username,
|
||||||
|
encrypted_password: e.encrypted_password,
|
||||||
|
iv: e.iv,
|
||||||
|
folder,
|
||||||
|
tags: e.tags || '',
|
||||||
|
kind: e.kind || 'login',
|
||||||
|
totp_secret: e.totp_secret || '',
|
||||||
|
totp_iv: e.totp_iv || '',
|
||||||
|
custom_fields: e.custom_fields || '',
|
||||||
|
custom_fields_iv: e.custom_fields_iv || '',
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
e.folder = folder;
|
||||||
|
ok++;
|
||||||
|
} catch (err) { /* try the rest */ }
|
||||||
|
}
|
||||||
|
state.checked.clear();
|
||||||
|
render();
|
||||||
|
if (ok === 0 && skipped > 0) return;
|
||||||
|
if (ok === 1) toast('Moved to ' + folder);
|
||||||
|
else if (ok > 1) toast('Moved ' + ok + ' entries to ' + folder);
|
||||||
|
}
|
||||||
|
|
||||||
async function moveEntryToFolder(id, folder) {
|
async function moveEntryToFolder(id, folder) {
|
||||||
const e = state.entries.find(x => x.id === id);
|
const e = state.entries.find(x => x.id === id);
|
||||||
if (!e || e.folder === folder) return;
|
if (!e || e.folder === folder) return;
|
||||||
@@ -7998,6 +8075,7 @@ function parseEntriesFromJSON(text) {
|
|||||||
a && typeof a === 'object' && a.filename && a.content_b64);
|
a && typeof a === 'object' && a.filename && a.content_b64);
|
||||||
}
|
}
|
||||||
entries.push({
|
entries.push({
|
||||||
|
uuid: String(e.uuid || '').trim(),
|
||||||
site: site,
|
site: site,
|
||||||
title: String(e.title || '').trim(),
|
title: String(e.title || '').trim(),
|
||||||
username: String(e.username || e.user || e.login || '').trim(),
|
username: String(e.username || e.user || e.login || '').trim(),
|
||||||
@@ -8043,6 +8121,7 @@ async function encryptImportEntry(plain) {
|
|||||||
} catch (e) { /* drop silently */ }
|
} catch (e) { /* drop silently */ }
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
|
uuid: plain.uuid || '',
|
||||||
site: plain.site,
|
site: plain.site,
|
||||||
title: plain.title || '',
|
title: plain.title || '',
|
||||||
username: plain.username || '',
|
username: plain.username || '',
|
||||||
@@ -8366,6 +8445,7 @@ async function doExport() {
|
|||||||
} catch (_) { /* partial export beats a failed one */ }
|
} catch (_) { /* partial export beats a failed one */ }
|
||||||
|
|
||||||
payload.entries.push({
|
payload.entries.push({
|
||||||
|
uuid: e.uuid || '',
|
||||||
site: e.site,
|
site: e.site,
|
||||||
title: e.title || '',
|
title: e.title || '',
|
||||||
username: e.username,
|
username: e.username,
|
||||||
@@ -8845,6 +8925,12 @@ function openSettings() {
|
|||||||
} else {
|
} else {
|
||||||
refreshAutoBackupUI(null);
|
refreshAutoBackupUI(null);
|
||||||
}
|
}
|
||||||
|
// Sync: same gate (Bridge required for WebDAV HTTP + DPAPI prefs).
|
||||||
|
const syncField = document.getElementById('syncField');
|
||||||
|
if (syncField) {
|
||||||
|
syncField.style.display = Bridge.active ? '' : 'none';
|
||||||
|
if (Bridge.active) loadSyncConfig().then(refreshSyncUI);
|
||||||
|
}
|
||||||
|
|
||||||
$('#settingsPanel').classList.add('is-open');
|
$('#settingsPanel').classList.add('is-open');
|
||||||
}
|
}
|
||||||
@@ -9027,6 +9113,399 @@ async function autoPurgeTrashIfNeeded() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// SYNC (WebDAV, auto-merge with timestamps)
|
||||||
|
// ============================================================
|
||||||
|
// Multi-device sync via a remote WebDAV server (Nextcloud, ownCloud,
|
||||||
|
// Apache mod_dav, any compatible). The remote file is an encrypted
|
||||||
|
// JSON snapshot (same crypto container as the export, separate
|
||||||
|
// password — the sync password is device-local DPAPI and the user must
|
||||||
|
// configure the same one on each device they want to sync).
|
||||||
|
//
|
||||||
|
// Strategy: auto-merge with last-write-wins on per-entry updated_at,
|
||||||
|
// tombstones for delete propagation. No conflict UI in v1 — silent
|
||||||
|
// resolution because solo personal use rarely produces simultaneous
|
||||||
|
// edits across devices. Toast reports added / updated / deleted counts.
|
||||||
|
//
|
||||||
|
// Sensitive actions (export, change master pw…) keep their own reauth
|
||||||
|
// path. Sync only touches entries + folders + tombstones.
|
||||||
|
|
||||||
|
const SYNC_PREFS = {
|
||||||
|
enabled: 'syncEnabled',
|
||||||
|
url: 'syncUrl', // e.g. https://cloud.example.com/remote.php/dav/files/USER/PMServer/vault-sync.json
|
||||||
|
user: 'syncUser',
|
||||||
|
pwd: 'syncPwd', // WebDAV password / app token
|
||||||
|
encPwd: 'syncEncPwd', // secret for the encrypted JSON container
|
||||||
|
preBackup: 'syncPreBackup', // 'on' / '' — write a local copy before each sync
|
||||||
|
last: 'syncLast', // ISO timestamp of last successful run
|
||||||
|
};
|
||||||
|
|
||||||
|
let _webdavResolvers = {};
|
||||||
|
Bridge.onWebdavResult = function(reqId, status, payload) {
|
||||||
|
const r = _webdavResolvers[reqId];
|
||||||
|
if (!r) return;
|
||||||
|
delete _webdavResolvers[reqId];
|
||||||
|
r({ status: status | 0, payload: payload || '' });
|
||||||
|
};
|
||||||
|
function _webdavCall(method, url, user, pwd, dataB64) {
|
||||||
|
return new Promise(resolve => {
|
||||||
|
const reqId = 'dav_' + Date.now() + '_' + Math.random().toString(36).slice(2, 8);
|
||||||
|
_webdavResolvers[reqId] = resolve;
|
||||||
|
let q = 'cmd://webdav/' + method
|
||||||
|
+ '?reqId=' + encodeURIComponent(reqId)
|
||||||
|
+ '&url=' + encodeURIComponent(url)
|
||||||
|
+ '&user=' + encodeURIComponent(user || '')
|
||||||
|
+ '&pwd=' + encodeURIComponent(pwd || '');
|
||||||
|
if (dataB64) q += '&data=' + encodeURIComponent(dataB64);
|
||||||
|
window.location.href = q;
|
||||||
|
setTimeout(() => {
|
||||||
|
if (_webdavResolvers[reqId]) {
|
||||||
|
delete _webdavResolvers[reqId];
|
||||||
|
resolve({ status: 0, payload: 'timeout' });
|
||||||
|
}
|
||||||
|
}, 60000);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function refreshSyncUI(cfg) {
|
||||||
|
if (!cfg) return;
|
||||||
|
const cb = document.getElementById('settingSyncEnabled');
|
||||||
|
const cfg2 = document.getElementById('syncConfig');
|
||||||
|
if (cb) cb.checked = !!cfg.enabled;
|
||||||
|
if (cfg2) cfg2.style.display = cfg.enabled ? '' : 'none';
|
||||||
|
const url = document.getElementById('syncUrl');
|
||||||
|
const user = document.getElementById('syncUser');
|
||||||
|
const pwd = document.getElementById('syncPwd');
|
||||||
|
const pre = document.getElementById('syncPreBackup');
|
||||||
|
if (url) url.value = cfg.url || '';
|
||||||
|
if (user) user.value = cfg.user || '';
|
||||||
|
if (pwd) pwd.value = cfg.pwd || '';
|
||||||
|
if (pre) pre.checked = !!cfg.preBackup;
|
||||||
|
const pwdStatus = document.getElementById('syncPwdStatus');
|
||||||
|
if (pwdStatus) pwdStatus.textContent = cfg.encPwd ? 'Set.' : 'Not set.';
|
||||||
|
const last = document.getElementById('syncLast');
|
||||||
|
if (last) last.textContent = cfg.last
|
||||||
|
? ' · Last: ' + cfg.last.replace('T', ' ').slice(0, 16)
|
||||||
|
: '';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function syncSetEncPwdFlow() {
|
||||||
|
let err = '';
|
||||||
|
let n = 0;
|
||||||
|
for (;;) {
|
||||||
|
const v = await promptDialog({
|
||||||
|
title: 'Set sync password',
|
||||||
|
message: 'Use the SAME password on every device that syncs with this remote. ' +
|
||||||
|
'Stored DPAPI-protected on this device only — never transmitted.',
|
||||||
|
placeholder: 'At least 8 characters',
|
||||||
|
password: true,
|
||||||
|
okText: 'Save',
|
||||||
|
error: err,
|
||||||
|
});
|
||||||
|
if (!v) return;
|
||||||
|
if (v.length >= 8) {
|
||||||
|
Bridge.setPref(SYNC_PREFS.encPwd, v);
|
||||||
|
const pwdStatus = document.getElementById('syncPwdStatus');
|
||||||
|
if (pwdStatus) pwdStatus.textContent = 'Set.';
|
||||||
|
toast('Sync password saved');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
n++;
|
||||||
|
if (n >= 5) return toast('Too many invalid attempts', 'error');
|
||||||
|
err = 'Use at least 8 characters (attempt ' + n + ' / 5).';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadSyncConfig() {
|
||||||
|
if (!Bridge.active) return null;
|
||||||
|
const [enabled, url, user, pwd, encPwd, pre, last] = await Promise.all([
|
||||||
|
Bridge.getPref(SYNC_PREFS.enabled),
|
||||||
|
Bridge.getPref(SYNC_PREFS.url),
|
||||||
|
Bridge.getPref(SYNC_PREFS.user),
|
||||||
|
Bridge.getPref(SYNC_PREFS.pwd),
|
||||||
|
Bridge.getPref(SYNC_PREFS.encPwd),
|
||||||
|
Bridge.getPref(SYNC_PREFS.preBackup),
|
||||||
|
Bridge.getPref(SYNC_PREFS.last),
|
||||||
|
]);
|
||||||
|
return {
|
||||||
|
enabled: enabled === '1',
|
||||||
|
url: url || '',
|
||||||
|
user: user || '',
|
||||||
|
pwd: pwd || '',
|
||||||
|
encPwd: encPwd || '',
|
||||||
|
preBackup: pre === '1',
|
||||||
|
last: last || '',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function syncTestConnection() {
|
||||||
|
const cfg = await loadSyncConfig();
|
||||||
|
if (!cfg || !cfg.url) return toast('Set the WebDAV URL first', 'warning');
|
||||||
|
toast('Testing connection…');
|
||||||
|
const r = await _webdavCall('test', cfg.url, cfg.user, cfg.pwd);
|
||||||
|
if (r.status >= 200 && r.status < 400) {
|
||||||
|
toast('Connection OK (' + r.status + ')');
|
||||||
|
} else if (r.status === 404) {
|
||||||
|
// Server reachable, snapshot file just doesn't exist yet — normal
|
||||||
|
// before the first sync. Treat as success.
|
||||||
|
toast('Connection OK · snapshot not created yet');
|
||||||
|
} else if (r.status === 401 || r.status === 403) {
|
||||||
|
toast('Auth failed (' + r.status + ') — check user/password', 'error');
|
||||||
|
} else if (r.status === 0) {
|
||||||
|
toast('Network error: ' + (r.payload || 'unreachable'), 'error');
|
||||||
|
} else {
|
||||||
|
toast('Server returned ' + r.status, 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build the snapshot payload that gets encrypted + pushed to the remote.
|
||||||
|
// Includes entries (decrypted plaintext, then re-encrypted under the
|
||||||
|
// sync key), folders metadata, and tombstones. Mirrors doExport's shape
|
||||||
|
// so a sync snapshot is also importable via "Import vault".
|
||||||
|
async function buildSyncSnapshot() {
|
||||||
|
const payload = {
|
||||||
|
version: 1,
|
||||||
|
snapshot_at: new Date().toISOString(),
|
||||||
|
username: state.username,
|
||||||
|
folders: (state.folders || [])
|
||||||
|
.filter(f => f && f.name && f.name !== 'All')
|
||||||
|
.map(f => ({ name: f.name, color: f.color || '', icon: f.icon || '' })),
|
||||||
|
entries: [],
|
||||||
|
tombstones: [],
|
||||||
|
};
|
||||||
|
for (const e of state.entries) {
|
||||||
|
if (!e.uuid) continue; // legacy row that missed the backfill — skip
|
||||||
|
const plain = await decryptPwd(e.encrypted_password, e.iv);
|
||||||
|
let plainTotp = '';
|
||||||
|
if (e.totp_secret && e.totp_iv) {
|
||||||
|
plainTotp = await decryptTotpSecret(e.totp_secret, e.totp_iv);
|
||||||
|
if (plainTotp === '[ERROR]') plainTotp = '';
|
||||||
|
}
|
||||||
|
let plainCustom = [];
|
||||||
|
if (e.custom_fields && e.custom_fields_iv) {
|
||||||
|
try { plainCustom = await decryptCustomFields(
|
||||||
|
e.custom_fields, e.custom_fields_iv); }
|
||||||
|
catch (_) {}
|
||||||
|
}
|
||||||
|
let attachments = [];
|
||||||
|
try {
|
||||||
|
const metas = await api('/entries/' + e.id + '/attachments',
|
||||||
|
{ headers: authHeaders() });
|
||||||
|
for (const m of (metas || [])) {
|
||||||
|
const full = await api('/attachments/' + m.id,
|
||||||
|
{ headers: authHeaders() });
|
||||||
|
const bytes = await decryptBlobBytes(full.encrypted_blob, full.iv);
|
||||||
|
attachments.push({
|
||||||
|
filename: m.filename, mime: m.mime,
|
||||||
|
size_bytes: m.size_bytes,
|
||||||
|
content_b64: bytesToBase64(bytes),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (_) {}
|
||||||
|
payload.entries.push({
|
||||||
|
uuid: e.uuid,
|
||||||
|
site: e.site || '',
|
||||||
|
title: e.title || '',
|
||||||
|
username: e.username || '',
|
||||||
|
password: plain === '[ERROR]' ? '' : plain,
|
||||||
|
folder: e.folder || 'All',
|
||||||
|
tags: parseTags(e.tags),
|
||||||
|
favorite: !!e.favorite,
|
||||||
|
totp_secret: plainTotp,
|
||||||
|
kind: e.kind || 'login',
|
||||||
|
template: e.template || '',
|
||||||
|
custom_fields: plainCustom,
|
||||||
|
attachments,
|
||||||
|
icon_b64: e.icon_b64 || '',
|
||||||
|
created_at: e.created_at,
|
||||||
|
updated_at: e.updated_at,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const ts = await api('/entries/tombstones', { headers: authHeaders() });
|
||||||
|
payload.tombstones = (Array.isArray(ts) ? ts : []).map(t => ({
|
||||||
|
uuid: t.uuid, deleted_at: t.deleted_at,
|
||||||
|
}));
|
||||||
|
} catch (_) {}
|
||||||
|
return payload;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function applyRemoteSnapshot(remote) {
|
||||||
|
if (!remote || !Array.isArray(remote.entries)) return { added:0, updated:0, deleted:0 };
|
||||||
|
let added = 0, updated = 0, deleted = 0;
|
||||||
|
|
||||||
|
// Push remote tombstones first — server will hard-delete any matching
|
||||||
|
// local entries AND remember them so they don't reappear from a future
|
||||||
|
// local push.
|
||||||
|
if (Array.isArray(remote.tombstones) && remote.tombstones.length > 0) {
|
||||||
|
const uuids = remote.tombstones.map(t => t.uuid).filter(Boolean);
|
||||||
|
if (uuids.length > 0) {
|
||||||
|
try {
|
||||||
|
await api('/entries/tombstones', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: authHeaders({ 'Content-Type': 'application/json' }),
|
||||||
|
body: JSON.stringify({ uuids }),
|
||||||
|
});
|
||||||
|
deleted = uuids.length;
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Refresh local view AFTER tombstones so the maps reflect the cull.
|
||||||
|
await loadEntries();
|
||||||
|
const byUuid = new Map();
|
||||||
|
for (const e of state.entries) if (e.uuid) byUuid.set(e.uuid, e);
|
||||||
|
|
||||||
|
// Folders — add missing ones with the remote's color/icon. Existing
|
||||||
|
// folders are left untouched (user's local customisation wins).
|
||||||
|
if (Array.isArray(remote.folders)) {
|
||||||
|
const localNames = new Set((state.folders || []).map(f => f.name));
|
||||||
|
for (const f of remote.folders) {
|
||||||
|
if (!f.name || localNames.has(f.name)) continue;
|
||||||
|
try {
|
||||||
|
await api('/folders', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: authHeaders({ 'Content-Type': 'application/json' }),
|
||||||
|
body: JSON.stringify({
|
||||||
|
name: f.name, color: f.color || '', icon: f.icon || '',
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
await loadFolders();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Per-entry merge.
|
||||||
|
for (const r of remote.entries) {
|
||||||
|
if (!r.uuid) continue;
|
||||||
|
const local = byUuid.get(r.uuid);
|
||||||
|
if (!local) {
|
||||||
|
// New entry on remote — encrypt locally + POST keeping the uuid.
|
||||||
|
try {
|
||||||
|
const enc = await encryptImportEntry(Object.assign({}, r, { uuid: r.uuid }));
|
||||||
|
const created = await api('/entries', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: authHeaders({ 'Content-Type': 'application/json' }),
|
||||||
|
body: JSON.stringify(enc),
|
||||||
|
});
|
||||||
|
added++;
|
||||||
|
// Restore attachments for this new entry.
|
||||||
|
if (Array.isArray(r.attachments) && created && created.id) {
|
||||||
|
for (const a of r.attachments) {
|
||||||
|
try {
|
||||||
|
const bytes = base64ToBytes(a.content_b64 || '');
|
||||||
|
const blob = await encryptBlobBytes(bytes);
|
||||||
|
await api('/entries/' + created.id + '/attachments', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: authHeaders({ 'Content-Type': 'application/json' }),
|
||||||
|
body: JSON.stringify({
|
||||||
|
filename: a.filename,
|
||||||
|
mime: a.mime || 'application/octet-stream',
|
||||||
|
encrypted_blob: blob.encrypted,
|
||||||
|
iv: blob.iv,
|
||||||
|
size_bytes: a.size_bytes || bytes.length,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (_) {}
|
||||||
|
} else {
|
||||||
|
// Both sides have it — keep the newer one (lexical ISO sort).
|
||||||
|
const remoteWins = (r.updated_at || '') > (local.updated_at || '');
|
||||||
|
if (!remoteWins) continue;
|
||||||
|
try {
|
||||||
|
const enc = await encryptImportEntry(r);
|
||||||
|
// PUT keeps the existing id but accepts the encrypted blobs.
|
||||||
|
// template + uuid not touched here (server preserves columns
|
||||||
|
// when fields aren't in body — uuid is immutable anyway).
|
||||||
|
await api('/entries/' + local.id, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: authHeaders({ 'Content-Type': 'application/json' }),
|
||||||
|
body: JSON.stringify(enc),
|
||||||
|
});
|
||||||
|
updated++;
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { added, updated, deleted };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runSyncNow() {
|
||||||
|
const cfg = await loadSyncConfig();
|
||||||
|
if (!cfg) return toast('Bridge not available', 'error');
|
||||||
|
if (!cfg.url) return toast('Sync not configured', 'warning');
|
||||||
|
if (!cfg.encPwd) return toast('Set the sync password first', 'warning');
|
||||||
|
if (!state.cryptoKey) return toast('Vault is locked', 'warning');
|
||||||
|
|
||||||
|
// Pre-sync backup — best effort, doesn't block sync on failure.
|
||||||
|
if (cfg.preBackup) {
|
||||||
|
try {
|
||||||
|
const ab = await loadAutoBackupConfig();
|
||||||
|
const dir = (ab && ab.dir) ? ab.dir : null;
|
||||||
|
if (dir) {
|
||||||
|
const snap = await buildSyncSnapshot();
|
||||||
|
const container = await encryptExportPayload(snap, cfg.encPwd);
|
||||||
|
const ts = new Date().toISOString()
|
||||||
|
.replace(/[-:]/g, '').replace('T', '-').slice(0, 15);
|
||||||
|
const path = dir.replace(/[\\/]+$/, '') + '\\vault-presync-' + ts + '.json';
|
||||||
|
await Bridge.writeFile(path, JSON.stringify(container, null, 2));
|
||||||
|
}
|
||||||
|
} catch (_) { /* best-effort */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
toast('Syncing…');
|
||||||
|
let merged = { added: 0, updated: 0, deleted: 0 };
|
||||||
|
let pulled = false;
|
||||||
|
try {
|
||||||
|
const r = await _webdavCall('get', cfg.url, cfg.user, cfg.pwd);
|
||||||
|
if (r.status >= 200 && r.status < 300 && r.payload) {
|
||||||
|
try {
|
||||||
|
const jsonText = new TextDecoder().decode(base64ToBytes(r.payload));
|
||||||
|
const container = JSON.parse(jsonText);
|
||||||
|
const snap = await decryptExportContainer(container, cfg.encPwd);
|
||||||
|
merged = await applyRemoteSnapshot(snap);
|
||||||
|
pulled = true;
|
||||||
|
} catch (e) {
|
||||||
|
return toast('Remote decrypt failed — wrong sync password?', 'error');
|
||||||
|
}
|
||||||
|
} else if (r.status === 404) {
|
||||||
|
// First sync — no remote yet, we'll just upload our state.
|
||||||
|
pulled = true;
|
||||||
|
} else if (r.status === 0) {
|
||||||
|
return toast('Network error: ' + (r.payload || 'unreachable'), 'error');
|
||||||
|
} else {
|
||||||
|
return toast('Pull failed: HTTP ' + r.status, 'error');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
return toast('Sync pull failed: ' + (e && e.message || e), 'error');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Push merged state back to the remote.
|
||||||
|
try {
|
||||||
|
await loadEntries(); // pull latest after applying remote changes
|
||||||
|
const snap = await buildSyncSnapshot();
|
||||||
|
const container = await encryptExportPayload(snap, cfg.encPwd);
|
||||||
|
const bodyBytes = new TextEncoder().encode(JSON.stringify(container, null, 2));
|
||||||
|
const r = await _webdavCall('put', cfg.url, cfg.user, cfg.pwd,
|
||||||
|
bytesToBase64(bodyBytes));
|
||||||
|
if (!(r.status >= 200 && r.status < 300)) {
|
||||||
|
return toast('Push failed: HTTP ' + r.status, 'error');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
return toast('Sync push failed: ' + (e && e.message || e), 'error');
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
Bridge.setPref(SYNC_PREFS.last, now);
|
||||||
|
render();
|
||||||
|
const summary =
|
||||||
|
merged.added + ' added · ' +
|
||||||
|
merged.updated + ' updated · ' +
|
||||||
|
merged.deleted + ' deleted';
|
||||||
|
toast('Sync complete — ' + summary);
|
||||||
|
}
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
// AUTO-BACKUP (silent encrypted exports on a schedule)
|
// AUTO-BACKUP (silent encrypted exports on a schedule)
|
||||||
// ============================================================
|
// ============================================================
|
||||||
@@ -9194,6 +9673,7 @@ async function runAutoBackupNow() {
|
|||||||
}
|
}
|
||||||
} catch (_) {}
|
} catch (_) {}
|
||||||
payload.entries.push({
|
payload.entries.push({
|
||||||
|
uuid: e.uuid || '',
|
||||||
site: e.site, title: e.title || '', username: e.username,
|
site: e.site, title: e.title || '', username: e.username,
|
||||||
password: plain, folder: e.folder, tags: parseTags(e.tags),
|
password: plain, folder: e.folder, tags: parseTags(e.tags),
|
||||||
favorite: !!e.favorite, totp_secret: plainTotp,
|
favorite: !!e.favorite, totp_secret: plainTotp,
|
||||||
@@ -9620,15 +10100,21 @@ async function init() {
|
|||||||
trashItem.addEventListener('drop', async ev => {
|
trashItem.addEventListener('drop', async ev => {
|
||||||
ev.preventDefault();
|
ev.preventDefault();
|
||||||
trashItem.classList.remove('drag-over');
|
trashItem.classList.remove('drag-over');
|
||||||
const id = parseInt(ev.dataTransfer.getData('text/plain'));
|
const raw = ev.dataTransfer.getData('text/plain') || '';
|
||||||
if (!id) return;
|
const ids = raw.split(',').map(s => parseInt(s)).filter(n => n > 0);
|
||||||
|
if (!ids.length) return;
|
||||||
|
let ok = 0;
|
||||||
|
for (const id of ids) {
|
||||||
try {
|
try {
|
||||||
await api('/entries/' + id, { method: 'DELETE', headers: authHeaders() });
|
await api('/entries/' + id, { method: 'DELETE', headers: authHeaders() });
|
||||||
toast('Moved to trash');
|
ok++;
|
||||||
|
} catch (err) { /* keep going for the rest */ }
|
||||||
|
}
|
||||||
|
toast(ok === 1 ? 'Moved to trash' : 'Moved ' + ok + ' entries to trash');
|
||||||
|
state.checked.clear();
|
||||||
await loadEntries();
|
await loadEntries();
|
||||||
await loadTrash();
|
await loadTrash();
|
||||||
render();
|
render();
|
||||||
} catch (err) { toast(err.message, 'error'); }
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -10002,6 +10488,34 @@ async function init() {
|
|||||||
saveServerSettings();
|
saveServerSettings();
|
||||||
toast('Unlock method updated');
|
toast('Unlock method updated');
|
||||||
});
|
});
|
||||||
|
// Sync (WebDAV) listeners
|
||||||
|
const syncCb = document.getElementById('settingSyncEnabled');
|
||||||
|
const syncCfgBox = document.getElementById('syncConfig');
|
||||||
|
const syncUrlEl = document.getElementById('syncUrl');
|
||||||
|
const syncUserEl = document.getElementById('syncUser');
|
||||||
|
const syncPwdEl = document.getElementById('syncPwd');
|
||||||
|
const syncPreEl = document.getElementById('syncPreBackup');
|
||||||
|
const syncSetPwd = document.getElementById('syncSetPwdBtn');
|
||||||
|
const syncTest = document.getElementById('syncTestBtn');
|
||||||
|
const syncNow = document.getElementById('syncNowBtn');
|
||||||
|
if (syncCb) syncCb.addEventListener('change', e => {
|
||||||
|
const on = !!e.target.checked;
|
||||||
|
Bridge.setPref(SYNC_PREFS.enabled, on ? '1' : '');
|
||||||
|
if (syncCfgBox) syncCfgBox.style.display = on ? '' : 'none';
|
||||||
|
toast(on ? 'Sync enabled' : 'Sync disabled');
|
||||||
|
});
|
||||||
|
if (syncUrlEl) syncUrlEl.addEventListener('change', e =>
|
||||||
|
Bridge.setPref(SYNC_PREFS.url, e.target.value.trim()));
|
||||||
|
if (syncUserEl) syncUserEl.addEventListener('change', e =>
|
||||||
|
Bridge.setPref(SYNC_PREFS.user, e.target.value.trim()));
|
||||||
|
if (syncPwdEl) syncPwdEl.addEventListener('change', e =>
|
||||||
|
Bridge.setPref(SYNC_PREFS.pwd, e.target.value));
|
||||||
|
if (syncPreEl) syncPreEl.addEventListener('change', e =>
|
||||||
|
Bridge.setPref(SYNC_PREFS.preBackup, e.target.checked ? '1' : ''));
|
||||||
|
if (syncSetPwd) syncSetPwd.addEventListener('click', syncSetEncPwdFlow);
|
||||||
|
if (syncTest) syncTest.addEventListener('click', syncTestConnection);
|
||||||
|
if (syncNow) syncNow.addEventListener('click', runSyncNow);
|
||||||
|
|
||||||
$('#settingAutoBackupEnabled').addEventListener('change', onToggleAutoBackup);
|
$('#settingAutoBackupEnabled').addEventListener('change', onToggleAutoBackup);
|
||||||
$('#autoBackupPickDirBtn').addEventListener('click', pickAutoBackupFolder);
|
$('#autoBackupPickDirBtn').addEventListener('click', pickAutoBackupFolder);
|
||||||
$('#settingAutoBackupInterval').addEventListener('change', e => {
|
$('#settingAutoBackupInterval').addEventListener('change', e => {
|
||||||
|
|||||||
Reference in New Issue
Block a user