feat: entry templates + tag autocomplete + slideover push + robustness bundle

- Entry templates: new vault_entries.template column drives a typed
  sub-kind ('credit-card', 'ssh-key', 'server', 'recovery-codes'). Card
  + table label off the template, badge reads "credit card" instead of
  "note". Templates seed kind=note (no site/password required), use
  custom_fields with optional dropdown options (brand, month/year,
  protocol). Round-tripped across export/import/duplicate/master-pw
  rotation, preserved by partial PUTs via a HasTemplate flag.
- Custom fields: support per-field `options[]` rendering as <select>
  (card brand, expiry MM/YYYY, SSH/server protocol).
- Tags: existing-tag autocomplete dropdown under the chip input,
  filtered against what's already selected.
- Search history: per-query X for individual delete + 1s debounced
  commit (no Enter required).
- Slideover: clicking outside closes again (drag-selection respected
  via mousedown origin tracker), Esc closes, X closes. App shell is
  pushed left by 420px when the panel is open so the table / pagination
  / sort / search stay visible and interactive.
- Export/import: JSON now round-trips custom_fields, attachments
  (decrypted to base64, re-encrypted under current key on restore),
  icon_b64, and template. CSV warning lists what's not included.
- Auto-backup: same payload shape as user-driven export.
- Notes: import (JSON + CSV) accepts kind=note with empty site,
  preserves title/template/custom_fields. CSV parser detects kind/
  template columns.
- Bulk-import response returns `ids[]` parallel to input so the
  client can map back to new entry IDs (drives attachment restore).
- Move-to-folder bugs fixed: moveEntryToFolder, batchMoveToFolder,
  addTag, batchAddTag were all silently wiping TOTP / custom_fields
  / kind / template via partial PUT. Now re-ship full payload.
- Master-pw rotation: server mints a fresh session token + csrf so
  the very next request after rotation no longer ESessionRejects.
  Client adopts the new pair. Attachments are re-encrypted client-side
  during rotation (GET old → decrypt with old key → encrypt with new
  → PUT). New endpoints: GET /attachments/all, PUT /attachments/:id.
- Duplicate: carries icon_b64 + template + attachments to the copy.
- HandleCreateEntry: accepts icon_b64.
- FireDAC param fix: all blob/icon/custom_fields params use ftMemo +
  .Value assignment so SQLite TEXT no longer truncates to 4000 chars
  (deepseek's 200+ KB favicon was being wiped on lock/unlock).
- HandleSetEntryIcon cap: 262144 → 524288 chars (base64 of a 256 KB
  raw fetch overflows the old cap, fails silently in saveEntryIcon).
- Native save dialog: surfaces server errors instead of swallowing.
- Modals: reauth (export) + backup-password prompt support inline
  error display, retry up to 5 attempts, then hard-stop.
- Keyboard cursor (j/k): bootstraps to current page, auto-paginates
  when the cursor crosses a page boundary, Enter opens slideover.
- Slideover focuses Title on edit-open so j/k → Enter → type Just
  Works.
- TOTP tool: Esc closes the modal.
- App version + launch mode (auto/manual): exposed via bridge,
  surfaced in Settings → Account. Autostart launches suppress the
  first-time tray balloon.
- Passkey button hidden (Delphi backend stubs WebAuthn at 501).
- TEST_PLAN.md captured for regression coverage.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
r-zakarya
2026-06-26 21:20:07 +01:00
parent fa7ea191be
commit e23a78dda7
14 changed files with 1752 additions and 203 deletions
+15 -5
View File
@@ -358,7 +358,9 @@ Résout le cas "j'ai ajouté un mot de passe avec tri A-Z, où se loge-t-il ?"
Une `vault_entries` row porte **plusieurs blobs chiffrés indépendants** : Une `vault_entries` row porte **plusieurs blobs chiffrés indépendants** :
`encrypted_password/iv`, `totp_secret/totp_iv`, `custom_fields/custom_fields_iv`, `encrypted_password/iv`, `totp_secret/totp_iv`, `custom_fields/custom_fields_iv`,
plus le champ-icône `icon_b64` et les méta non chiffrées (`site, title, plus le champ-icône `icon_b64` et les méta non chiffrées (`site, title,
username, folder, tags, kind`). username, folder, tags, kind, template`). `template` est le sous-type
(ex: `credit-card`, `ssh-key`, `server`, `recovery-codes`) qui drive le
label de card/table — vide pour login/note génériques.
Quand tu ajoutes un nouveau champ (chiffré ou non), il faut **toujours** Quand tu ajoutes un nouveau champ (chiffré ou non), il faut **toujours**
mettre à jour ces 6 endroits sous peine de perdre la donnée silencieusement mettre à jour ces 6 endroits sous peine de perdre la donnée silencieusement
@@ -375,7 +377,13 @@ sur certaines actions :
`for (const e of state.entries)` re-chiffre chaque blob et push dans `for (const e of state.entries)` re-chiffre chaque blob et push dans
`encrypted[]`. Manquer un champ chiffré = donnée perdue. `encrypted[]`. Manquer un champ chiffré = donnée perdue.
6. **`duplicateEntry`** (js/app.js) — copier le blob chiffré tel quel 6. **`duplicateEntry`** (js/app.js) — copier le blob chiffré tel quel
(même vault key, pas besoin de re-chiffrer) (même vault key, pas besoin de re-chiffrer). Pour `icon_b64` :
inclure dans le body POST. Pour attachments : boucle séparée après
create qui GET source attachments + POST sur le nouveau id.
7. **`moveEntryToFolder`** (js/app.js) — PUT partiel sans ces champs =
wipe silencieux (TOTP perdu, custom_fields perdus, et notes rejetées
en 400 "Site required" parce que `kind` default à 'login'). Re-ship
le payload complet, seul `folder` change.
Bonus utile (pas critique) : `soDirtyCheck` doit comparer le nouveau Bonus utile (pas critique) : `soDirtyCheck` doit comparer le nouveau
champ, et `openSlideOver` doit le déchiffrer et l'exposer via `soState`. champ, et `openSlideOver` doit le déchiffrer et l'exposer via `soState`.
@@ -403,9 +411,11 @@ client-side with the vault key.
mime, size are stored in cleartext (leaked metadata) so the listing mime, size are stored in cleartext (leaked metadata) so the listing
doesn't have to decrypt all rows on slideover-open. doesn't have to decrypt all rows on slideover-open.
- **Cap** : 5 MB raw client-side check, ~10 MB base64 server-side. - **Cap** : 5 MB raw client-side check, ~10 MB base64 server-side.
- **Master pw rotation** : attachments are NOT re-encrypted on rotation - **Master pw rotation** : attachments ARE re-encrypted client-side after
they become inaccessible. Known limitation, document for users who the entries flip. Loop fetches each blob via `GET /attachments/:id`,
rotate master pw (rotate before adding heavy attachments). decrypts with old key, re-encrypts with new key, PUTs the new blob via
`PUT /attachments/:id`. Best-effort: a failure on one attachment shows
a warning but doesn't undo the rotation.
- **UI** : `soAttachmentsField(entryId)` rendered in slideover (existing - **UI** : `soAttachmentsField(entryId)` rendered in slideover (existing
entries only, never on new). Upload via hidden file input + paperclip entries only, never on new). Upload via hidden file input + paperclip
button. Download reuses `Bridge.saveFile` (native Save As dialog). button. Download reuses `Bridge.saveFile` (native Save As dialog).
+102
View File
@@ -0,0 +1,102 @@
# Test plan — commit `fa7ea19` (native save + auto-backup + folder customization + attachments + UX bundle)
Coche au fur et à mesure. Les **🔥 regression** sont les bugs déjà vus à
re-vérifier explicitement.
## 0. Setup
- [✅] `delphi-backend\assets\BuildAssets.cmd` + F9 (sinon embedded ≠ disque)
- [✅] Lance avec `config.txt` présent (debug panel + log)
- [✅] Crée 2 users de test ou utilise vault existant non critique
## 1. Native Save As
- [✅] Settings → Export encrypted JSON → maître pw OK → backup pw → boîte "Enregistrer sous" Windows native (pas le popup Téléchargements Edge)
- [✅] Export CSV → idem natif
- [✅] Annule la dialog → toast "save cancelled" attendu, pas d'erreur silencieuse
## 2. Auto-backup
- [✅] Settings → Auto-backup → toggle on → prompt pwd (< 6 chars rejeté)
- [✅] "Choose…" → folder Windows valide → path s'affiche
- [✅] "Backup now" → fichier `vault-autobackup-yyyymmdd-HHmmss.json` créé dans le folder
- [✅] Lance plusieurs "Backup now" (interval = 1 jour) → fichiers s'accumulent → après N+1, le plus vieux est purgé (vérifier `keep` retention)
- [✅] Toggle off → re-on → prompt pwd à nouveau (confirme que le pwd est effacé)
- [ ] Restart app, unlock → 5s plus tard, si `interval` passé, backup auto silencieux (regarde le folder)
- [✅] **Restore** : install fresh → import "Encrypted JSON" → choisis un backup → tape le pwd → entries restaurées intactes
## 3. Folder customization
- [✅] Sidebar → + Folders → modal s'ouvre → name+color+icon → Create → folder visible avec custom icon + color
- [✅] Hover sur folder → pencil apparaît → click → modal pré-rempli → change icon → Save → re-render OK
- [✅] **Folder chip sur card** : crée entry dans "Banking" → la chip sous le pwd montre l'icône shield + couleur (pas le default folder gris) 🔥
- [✅] Drag-reorder : grab folder → drag au-dessus d'un autre → ligne d'insertion cyan visible (above/below selon mouse Y) → drop → ordre changé persisté (refresh app)
## 4. Drag entries vers folders 🔥
- [✅] Drag d'une LOGIN entry → folder → toast "Moved to X" → entry change de folder
- [✅] Drag d'une NOTE entry → folder → toast "Moved to X" (régression v1, doit marcher maintenant)
- [✅] Drag d'une entry avec **TOTP configuré** → folder → ouvre l'entry après → **TOTP toujours là** (pas wipé)
- [✅] Drag d'une entry avec **custom_fields** → folder → custom_fields préservés
- [✅] Drag d'une entry vers son propre folder → no-op (pas d'erreur)
## 5. Recently used
- [✅] Vue vide au start (rien jamais touché) → "No entries"
- [✅] Copy password d'une entry → attends 2s → sidebar "Recently used" → entry visible en tête
- [✅] Ouvre une entry (slideover) → idem
- [✅] Copy 5 entries rapidement → max 10 visibles, ordre = plus récent d'abord
- [✅] Lock vault + unlock → liste rechargée du serveur (accessed_at persiste)
## 6. Encrypted attachments
- [✅] Crée entry, sauve, ré-ouvre → section "Attachments" visible (pas sur new entry)
- [✅] "Attach file" → file picker → upload PDF/PNG < 5 MB → toast OK → liste affiche filename+size+mime
- [✅] Upload fichier > 5 MB → toast erreur "File too large"
- [✅] Download → boîte Save As → fichier décrypté identique à l'original (hash check si paranoid)
- [✅] Delete → confirm modal → row disparaît
- [✅] Crée 2nd attachment, supprime entry parent → vérifier que les rows entry_attachments sont aussi delete (CASCADE FK) via `sqlite vault.db "select count(*) from entry_attachments"`
- [✅] Master pw rotation avec attachments → toast "Re-encrypting N attachment(s)…" puis "Attachments re-encrypted" → download d'un attachment **après rotation** réussit (re-chiffré client-side dans le flux change-master)
## 7. Password expiry
- [✅] Settings → Flag aged → 90 days
- [✅] Backdoor : `sqlite vault.db "UPDATE vault_entries SET password_changed_at = '2024-01-01' WHERE id = X"` → recharge → badge "Aged" visible sur la card de X
- [✅] Edit l'entry, change le password, Save → badge disparaît (password_changed_at bumpé)
- [✅] Edit l'entry, change **uniquement le title**, Save → badge **toujours là** (password_changed_at pas bumpé — le CASE SQL filtre)
- [✅] Filters dropdown → "Aged password" → liste filtrée
## 8. Print recovery code
- [✅] Settings → Recovery → Generate (ou regenerate) → modal avec code → bouton "Print"
- [✅] Click Print → preview Edge → vérifie : code 32px monospace, account name, date, instructions 3 étapes, page A4 propre (le reste de l'app caché)
- [✅] Annule la print preview → app revient normalement (cleanup du #printRecoveryArea)
## 9. Audit log
- [✅] Sidebar → Tools → Audit log → table visible avec actions chronologiques
- [✅] Fais une action (add entry, copy pwd) → refresh audit log → nouvelle ligne en tête
- [✅] Search dans audit log → filtre marche
## 10. Pinned entries
- [✅] Sur n'importe quelle card, click pushpin → toast "Pinned to top" → entry remonte en tête de la liste
- [✅] Re-click → toast "Unpinned" → entry retrouve sa place dans le sort courant
- [✅] Change le sort (name asc → date desc) → entries pinned restent en tête, le tri interne s'applique aux deux groupes
- [✅] Compact mode (Settings → Compact actions) → kebab menu contient "Pin to top" / "Unpin"
- [✅] Pin 3 entries, lock vault, unlock → les 3 sont toujours en tête (persist serveur)
- [ ] Master pw rotation → pinned préservé (pas wipé)
- [✅] Duplicate d'une entry pinned → la copie est **non pinned** (comportement voulu)
## 11. Search history
- [✅] Tape une recherche, attends 1s sans bouger → recherche ajoutée à l'historique (pas besoin d'Enter)
- [✅] Vide la searchbar → focus → dropdown "Recent searches" avec les dernières
- [✅] Click sur un item → re-filtre et bump en tête
- [✅] X individuel sur un item → suppression isolée
- [✅] "Clear" → vide tout l'historique
- [✅] Dropdown disparaît au click hors searchbar / Escape / blur => un petit probleme > focus search bar > dropdown search affiché > clique à nouveau search (focus non changé) dropdown non affiché
## 12. Regressions générales 🔥
- [✅] Quick unlock encore fonctionnel après update
- [✅] Master password rotation OK + entries déchiffrables après
- [✅] Autofill (Ctrl+Shift+L/P) toujours OK
- [ ] Recovery code redeem fonctionne (sur compte test)
- [✅] Lock vault → unlock → toutes entries présentes
- [ ] Trash → restore → entry revient avec tous ses champs (kind, custom_fields, TOTP)
## 13. Console / log scrutiny
- [ ] Ouvre debug panel → execute scénarios ci-dessus → log Delphi sans `FAILED`/`ERROR` non attendu
- [ ] Si tu peux ouvrir console JS (DevTools désactivé en prod, faut hack — sinon skip) → pas d'erreur JS rouge pendant les flows
---
**Ordre conseillé** : 0 → 4 → 6 → 7 → 1 → 2 → 3 → 5 → 8 → 9 → 10 → 11 → 12 → 13. Le drag entries (#4) est le plus à risque (régression confirmée hier), le faire tôt + à fond.
+135 -1
View File
@@ -304,6 +304,35 @@ input[type="range"]::-webkit-slider-thumb {
box-shadow: 0 0 0 3px var(--accent-soft); box-shadow: 0 0 0 3px var(--accent-soft);
background: var(--bg); background: var(--bg);
} }
/* Existing-tag autocomplete dropdown anchored under the chip-input. */
.tag-suggest {
position: absolute;
top: 100%; left: 0; right: 0;
margin-top: 4px;
background: var(--bg-elev);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
box-shadow: var(--shadow-lg);
z-index: 60;
overflow: hidden;
max-height: 240px;
overflow-y: auto;
}
.tag-suggest.is-hidden { display: none; }
.tag-suggest-item {
display: flex; align-items: center; gap: 8px;
width: 100%;
padding: 7px 10px;
background: transparent;
border: none;
color: var(--text);
font-size: 13px;
text-align: left;
cursor: pointer;
}
.tag-suggest-item:hover { background: var(--bg-elev-2); }
.tag-suggest-item svg { width: 12px; height: 12px; color: var(--text-faint); }
.chip-input input { .chip-input input {
flex: 1; flex: 1;
min-width: 80px; min-width: 80px;
@@ -605,6 +634,81 @@ input[type="range"]::-webkit-slider-thumb {
opacity: 1; opacity: 1;
} }
.search kbd { position: absolute; right: 8px; } .search kbd { position: absolute; right: 8px; }
/* Search history dropdown */
.search-history {
position: absolute;
top: calc(100% + 4px);
left: 0;
right: 0;
background: var(--bg-elev);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
box-shadow: var(--shadow-lg);
z-index: 50;
overflow: hidden;
}
.search-history.is-hidden { display: none; }
.search-history-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 6px 10px;
border-bottom: 1px solid var(--border-soft);
font-size: 11px;
color: var(--text-faint);
text-transform: uppercase;
letter-spacing: 0.4px;
}
.search-history-clear {
background: transparent;
border: none;
color: var(--text-dim);
font-size: 11px;
cursor: pointer;
padding: 2px 6px;
border-radius: 3px;
}
.search-history-clear:hover { color: var(--danger); background: var(--danger-soft); }
.search-history-row {
display: flex;
align-items: stretch;
}
.search-history-row:hover { background: var(--bg-elev-2); }
.search-history-item {
display: flex;
align-items: center;
gap: 8px;
flex: 1;
min-width: 0;
padding: 8px 12px;
background: transparent;
border: none;
color: var(--text);
font-size: 13px;
cursor: pointer;
text-align: left;
}
.search-history-item svg { width: 13px; height: 13px; color: var(--text-faint); }
.search-history-del {
display: inline-flex;
align-items: center;
justify-content: center;
width: 32px;
flex-shrink: 0;
background: transparent;
border: none;
color: var(--text-dim);
cursor: pointer;
transition: color var(--t-fast), background var(--t-fast);
}
.search-history-del {
font-size: 18px;
line-height: 1;
font-weight: 400;
}
.search-history-row:hover .search-history-del { color: var(--text); }
.search-history-del:hover { color: var(--danger); background: var(--danger-soft); }
.topbar-actions { display: flex; align-items: center; gap: 8px; margin-left: auto; } .topbar-actions { display: flex; align-items: center; gap: 8px; margin-left: auto; }
/* View toggle (Cards / List) */ /* View toggle (Cards / List) */
@@ -709,6 +813,13 @@ input[type="range"]::-webkit-slider-thumb {
background: var(--accent-soft); background: var(--accent-soft);
box-shadow: inset 0 0 0 2px var(--accent); box-shadow: inset 0 0 0 2px var(--accent);
} }
/* Keyboard cursor (j/k nav) — distinct from selected so a focused card
stays visible after the slideover closes. */
.entry-card.is-cursor,
.entry-row.is-cursor {
outline: 2px solid var(--accent);
outline-offset: -2px;
}
/* === List view layout === */ /* === List view layout === */
/* Cards are flattened into a single horizontal row using flexbox + `order`. /* Cards are flattened into a single horizontal row using flexbox + `order`.
@@ -904,7 +1015,7 @@ input[type="range"]::-webkit-slider-thumb {
font-size: 12px; font-size: 12px;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
} }
.entry-fav, .entry-del, .entry-dup { .entry-fav, .entry-del, .entry-dup, .entry-pin {
color: var(--text-faint); color: var(--text-faint);
background: transparent; border: none; background: transparent; border: none;
padding: 4px; padding: 4px;
@@ -913,6 +1024,8 @@ input[type="range"]::-webkit-slider-thumb {
} }
.entry-fav.is-on { color: var(--warning); } .entry-fav.is-on { color: var(--warning); }
.entry-fav:hover { color: var(--warning); transform: scale(1.1); } .entry-fav:hover { color: var(--warning); transform: scale(1.1); }
.entry-pin.is-on { color: var(--accent); }
.entry-pin:hover { color: var(--accent); transform: scale(1.1); }
.entry-dup { .entry-dup {
color: var(--text-dim); color: var(--text-dim);
opacity: 0; opacity: 0;
@@ -1733,6 +1846,18 @@ input[type="range"]::-webkit-slider-thumb {
} }
.new-entry-menu .dropdown-item:hover { background: var(--accent-soft); } .new-entry-menu .dropdown-item:hover { background: var(--accent-soft); }
.new-entry-menu .dropdown-item svg { width: 14px; height: 14px; } .new-entry-menu .dropdown-item svg { width: 14px; height: 14px; }
.new-entry-menu .dropdown-sep {
height: 1px;
background: var(--border-soft);
margin: 4px 0;
}
.new-entry-menu .dropdown-section {
padding: 4px 10px 2px;
font-size: 10px;
text-transform: uppercase;
letter-spacing: 0.5px;
color: var(--text-faint);
}
/* ---- Quick search modal (tray menu) ------------------ */ /* ---- Quick search modal (tray menu) ------------------ */
.quick-search-panel { .quick-search-panel {
@@ -1861,6 +1986,12 @@ input[type="range"]::-webkit-slider-thumb {
z-index: 50; z-index: 50;
} }
.slideover.is-open { transform: translateX(0); } .slideover.is-open { transform: translateX(0); }
/* Push the app shell to the left when the slideover is open, so the
table, sort buttons, pagination and search bar are no longer hidden
behind it. Width matches .slideover (420px) — bumped to 100% via the
responsive override below 720px. */
body:has(.slideover.is-open) #appShell { margin-right: 420px; }
#appShell { transition: margin-right var(--t-base); }
.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;
@@ -2156,6 +2287,9 @@ input[type="range"]::-webkit-slider-thumb {
.topbar { padding: 12px 16px; } .topbar { padding: 12px 16px; }
.content { padding: 16px; } .content { padding: 16px; }
.slideover { width: 100%; } .slideover { width: 100%; }
/* On a narrow viewport the slideover fills the screen — no point
pushing the shell, the table isn't visible anyway. */
body:has(.slideover.is-open) #appShell { margin-right: 0; }
} }
/* ---- 17. AUTOFILL PICKER -------------------------------- */ /* ---- 17. AUTOFILL PICKER -------------------------------- */
@@ -260,6 +260,125 @@ begin
end; end;
end; end;
// ===== GET /attachments/all ==================================================
// Lightweight listing of every attachment id+iv for the current user.
// Used by the master-pw rotation flow to enumerate what needs re-encryption.
// No blob shipped — fetched per-id only when the client is ready to re-encrypt.
procedure HandleListAllAttachments(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 id, entry_id FROM entry_attachments ' +
'WHERE user_id = :uid';
LQ.ParamByName('uid').AsInteger := LUserId;
LQ.Open;
while not LQ.Eof do
begin
LObj := TJSONObject.Create;
LObj.AddPair('id', TJSONNumber.Create(LQ.FieldByName('id').AsInteger));
LObj.AddPair('entry_id', TJSONNumber.Create(LQ.FieldByName('entry_id').AsInteger));
LArr.Add(LObj);
LQ.Next;
end;
finally
LQ.Free;
end;
finally
DB.Unlock;
end;
TJSONHelper.SendJSON(AResponse, LArr);
end;
// ===== PUT /attachments/{id} =================================================
// Update only the ciphertext + iv. Used by master-pw rotation to swap to
// the new vault key. Filename/mime/size stay untouched.
procedure HandleUpdateAttachmentBlob(ARequest: TIdHTTPRequestInfo;
AResponse: TIdHTTPResponseInfo; const AParams: TArray<string>);
var
LUserId, LId: Integer;
LBody: TJSONObject;
LBlob, LIv: string;
LQ: TFDQuery;
begin
try
LUserId := Authenticate(ARequest, AResponse);
RequireCSRF(ARequest, AResponse, LUserId);
except
on ESessionRejected do Exit;
end;
LId := StrToIntDef(AParams[0], 0);
if LId = 0 then
begin
TJSONHelper.SendError(AResponse, 400, 'Invalid id');
Exit;
end;
LBody := TJSONHelper.ReadBody(ARequest);
try
LBlob := LBody.GetValue<string>('encrypted_blob', '');
LIv := LBody.GetValue<string>('iv', '');
finally
LBody.Free;
end;
if (LBlob = '') or (LIv = '') then
begin
TJSONHelper.SendError(AResponse, 400, 'Missing encrypted_blob / iv');
Exit;
end;
if Length(LBlob) > MAX_ATTACHMENT_B64 then
begin
TJSONHelper.SendError(AResponse, 413, 'Attachment too large');
Exit;
end;
DB.Lock;
try
LQ := TFDQuery.Create(nil);
try
LQ.Connection := DB.Connection;
LQ.SQL.Text :=
'UPDATE entry_attachments SET encrypted_blob = :blob, iv = :iv ' +
'WHERE id = :id AND user_id = :uid';
LQ.ParamByName('id').AsInteger := LId;
LQ.ParamByName('uid').AsInteger := LUserId;
LQ.ParamByName('blob').AsString := LBlob;
LQ.ParamByName('iv').AsString := LIv;
LQ.ExecSQL;
if LQ.RowsAffected = 0 then
begin
TJSONHelper.SendError(AResponse, 404, 'Not found');
Exit;
end;
finally
LQ.Free;
end;
finally
DB.Unlock;
end;
TJSONHelper.SendOK(AResponse, 'Updated');
end;
// ===== DELETE /attachments/{id} ============================================== // ===== DELETE /attachments/{id} ==============================================
procedure HandleDeleteAttachment(ARequest: TIdHTTPRequestInfo; procedure HandleDeleteAttachment(ARequest: TIdHTTPRequestInfo;
@@ -311,7 +430,9 @@ end;
initialization initialization
Router.Register('GET', '/entries/(\d+)/attachments', HandleListAttachments); Router.Register('GET', '/entries/(\d+)/attachments', HandleListAttachments);
Router.Register('POST', '/entries/(\d+)/attachments', HandleCreateAttachment); Router.Register('POST', '/entries/(\d+)/attachments', HandleCreateAttachment);
Router.Register('GET', '/attachments/all', HandleListAllAttachments);
Router.Register('GET', '/attachments/(\d+)', HandleGetAttachment); Router.Register('GET', '/attachments/(\d+)', HandleGetAttachment);
Router.Register('PUT', '/attachments/(\d+)', HandleUpdateAttachmentBlob);
Router.Register('DELETE', '/attachments/(\d+)', HandleDeleteAttachment); Router.Register('DELETE', '/attachments/(\d+)', HandleDeleteAttachment);
end. end.
+16 -8
View File
@@ -772,6 +772,7 @@ var
LValid: Boolean; LValid: Boolean;
LEntryId: Integer; LEntryId: Integer;
LEncPwd, LIv, LTotpSec, LTotpIv: string; LEncPwd, LIv, LTotpSec, LTotpIv: string;
LNewToken, LNewCsrf: string;
begin begin
try try
LUserId := Authenticate(ARequest, AResponse); LUserId := Authenticate(ARequest, AResponse);
@@ -941,18 +942,18 @@ begin
LQ.ParamByName('iv').AsString := LIv; LQ.ParamByName('iv').AsString := LIv;
// TOTP / custom_fields are optional per entry — clear when // TOTP / custom_fields are optional per entry — clear when
// empty so existing-NULL rows don't get stomped with empty strings. // empty so existing-NULL rows don't get stomped with empty strings.
LQ.ParamByName('ts').DataType := ftString; LQ.ParamByName('ts').DataType := ftMemo;
LQ.ParamByName('tiv').DataType := ftString; LQ.ParamByName('tiv').DataType := ftMemo;
LQ.ParamByName('cf').DataType := ftString; LQ.ParamByName('cf').DataType := ftMemo;
LQ.ParamByName('cfiv').DataType := ftString; LQ.ParamByName('cfiv').DataType := ftMemo;
if LTotpSec.IsEmpty then LQ.ParamByName('ts').Clear if LTotpSec.IsEmpty then LQ.ParamByName('ts').Clear
else LQ.ParamByName('ts').AsString := LTotpSec; else LQ.ParamByName('ts').Value := LTotpSec;
if LTotpIv = '' then LQ.ParamByName('tiv').Clear if LTotpIv = '' then LQ.ParamByName('tiv').Clear
else LQ.ParamByName('tiv').AsString := LTotpIv; else LQ.ParamByName('tiv').Value := LTotpIv;
if LCf = '' then LQ.ParamByName('cf').Clear if LCf = '' then LQ.ParamByName('cf').Clear
else LQ.ParamByName('cf').AsString := LCf; else LQ.ParamByName('cf').Value := LCf;
if LCfIv = '' then LQ.ParamByName('cfiv').Clear if LCfIv = '' then LQ.ParamByName('cfiv').Clear
else LQ.ParamByName('cfiv').AsString := LCfIv; else LQ.ParamByName('cfiv').Value := LCfIv;
LQ.ExecSQL; LQ.ExecSQL;
end; end;
// Password history is encrypted with the OLD vault key — we // Password history is encrypted with the OLD vault key — we
@@ -993,6 +994,11 @@ begin
end; end;
DeleteAllUserSessions(LUserId); DeleteAllUserSessions(LUserId);
// Immediately mint a fresh session for the calling client so the
// very next request doesn't bounce with ESessionRejected. The user
// hasn't logged out — they rotated their key, the UI session is
// still legitimate.
CreateSession(LUserId, LNewToken, LNewCsrf);
finally finally
LBody.Free; LBody.Free;
end; end;
@@ -1004,6 +1010,8 @@ begin
LObj.AddPair('message', 'Master password changed'); LObj.AddPair('message', 'Master password changed');
LObj.AddPair('salt', LNewSalt); LObj.AddPair('salt', LNewSalt);
LObj.AddPair('kdfIterations', TJSONNumber.Create(PBKDF2_ITERATIONS_TARGET)); LObj.AddPair('kdfIterations', TJSONNumber.Create(PBKDF2_ITERATIONS_TARGET));
LObj.AddPair('token', LNewToken);
LObj.AddPair('csrf', LNewCsrf);
TJSONHelper.SendJSON(AResponse, LObj); TJSONHelper.SendJSON(AResponse, LObj);
end; end;
+159 -44
View File
@@ -104,6 +104,7 @@ begin
else else
LObj.AddPair('deleted_at', ISODateTimeField(LQ.FieldByName('deleted_at'))); LObj.AddPair('deleted_at', ISODateTimeField(LQ.FieldByName('deleted_at')));
LObj.AddPair('favorite', TJSONNumber.Create(LQ.FieldByName('favorite').AsInteger)); LObj.AddPair('favorite', TJSONNumber.Create(LQ.FieldByName('favorite').AsInteger));
LObj.AddPair('pinned', TJSONNumber.Create(LQ.FieldByName('pinned').AsInteger));
LObj.AddPair('tags', LQ.FieldByName('tags').AsString); LObj.AddPair('tags', LQ.FieldByName('tags').AsString);
// TOTP fields are NULL when the entry has no 2FA configured. We emit // TOTP fields are NULL when the entry has no 2FA configured. We emit
// JSON null instead of '' so the client can distinguish "no TOTP" from // JSON null instead of '' so the client can distinguish "no TOTP" from
@@ -126,6 +127,11 @@ begin
var LKindVal := LQ.FieldByName('kind').AsString; var LKindVal := LQ.FieldByName('kind').AsString;
if LKindVal = '' then LKindVal := 'login'; if LKindVal = '' then LKindVal := 'login';
LObj.AddPair('kind', LKindVal); LObj.AddPair('kind', LKindVal);
// Template subtype. Empty = generic; otherwise drives UI labels.
if LQ.FieldByName('template').IsNull then
LObj.AddPair('template', TJSONNull.Create)
else
LObj.AddPair('template', LQ.FieldByName('template').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".
@@ -167,7 +173,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: string; LKind, LCf, LCfIv, LIcon, LTemplate: string;
LQ: TFDQuery; LQ: TFDQuery;
begin begin
try try
@@ -192,6 +198,8 @@ begin
LKind := LBody.GetValue<string>('kind', 'login'); LKind := LBody.GetValue<string>('kind', 'login');
LCf := LBody.GetValue<string>('custom_fields', ''); LCf := LBody.GetValue<string>('custom_fields', '');
LCfIv := LBody.GetValue<string>('custom_fields_iv', ''); LCfIv := LBody.GetValue<string>('custom_fields_iv', '');
LIcon := LBody.GetValue<string>('icon_b64', '');
LTemplate:= Trim(LBody.GetValue<string>('template', ''));
finally finally
LBody.Free; LBody.Free;
end; end;
@@ -221,9 +229,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,' +
' created_at, updated_at, password_changed_at) ' + ' icon_b64, template, 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, :c, :c2, :c)'; ' :cf, :cfiv, :ic, :tpl, :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;
@@ -236,23 +244,28 @@ begin
// assigned a string and sometimes Clear()ed to NULL — without a // assigned a string and sometimes Clear()ed to NULL — without a
// prior typed assignment, .Clear raises "data type unknown" on // prior typed assignment, .Clear raises "data type unknown" on
// SQLite. Declare ftString up front for the optional TOTP fields. // SQLite. Declare ftString up front for the optional TOTP fields.
LQ.ParamByName('ts').DataType := ftString; LQ.ParamByName('ts').DataType := ftMemo;
LQ.ParamByName('tiv').DataType := ftString; LQ.ParamByName('tiv').DataType := ftMemo;
// Store empty TOTP fields as NULL so the GET endpoint emits JSON null // Store empty TOTP fields as NULL so the GET endpoint emits JSON null
// rather than '' — keeps client-side "has TOTP?" checks unambiguous. // rather than '' — keeps client-side "has TOTP?" checks unambiguous.
if LTotpSec = '' then if LTotpSec = '' then
LQ.ParamByName('ts').Clear LQ.ParamByName('ts').Clear
else else
LQ.ParamByName('ts').AsString := LTotpSec; LQ.ParamByName('ts').Value := LTotpSec;
if LTotpIv = '' then if LTotpIv = '' then
LQ.ParamByName('tiv').Clear LQ.ParamByName('tiv').Clear
else else
LQ.ParamByName('tiv').AsString := LTotpIv; LQ.ParamByName('tiv').Value := LTotpIv;
LQ.ParamByName('k').AsString := LKind; LQ.ParamByName('k').AsString := LKind;
LQ.ParamByName('cf').DataType := ftString; LQ.ParamByName('cf').DataType := ftMemo;
LQ.ParamByName('cfiv').DataType := ftString; LQ.ParamByName('cfiv').DataType := ftMemo;
if LCf = '' then LQ.ParamByName('cf').Clear else LQ.ParamByName('cf').AsString := LCf; if LCf = '' then LQ.ParamByName('cf').Clear else LQ.ParamByName('cf').Value := LCf;
if LCfIv = '' then LQ.ParamByName('cfiv').Clear else LQ.ParamByName('cfiv').AsString := LCfIv; if LCfIv = '' then LQ.ParamByName('cfiv').Clear else LQ.ParamByName('cfiv').Value := LCfIv;
LQ.ParamByName('ic').DataType := ftMemo;
if LIcon = '' then LQ.ParamByName('ic').Clear else LQ.ParamByName('ic').Value := LIcon;
LQ.ParamByName('tpl').DataType := ftString;
if LTemplate = '' then LQ.ParamByName('tpl').Clear
else LQ.ParamByName('tpl').AsString := LTemplate;
LQ.ParamByName('c').AsString := LNow; LQ.ParamByName('c').AsString := LNow;
LQ.ParamByName('c2').AsString := LNow; LQ.ParamByName('c2').AsString := LNow;
LQ.ExecSQL; LQ.ExecSQL;
@@ -284,7 +297,8 @@ var
LUserId, LId: Integer; LUserId, LId: Integer;
LBody: TJSONObject; LBody: TJSONObject;
LSite, LTitle, LUser, LFolder, LEnc, LIV, LTags, LNow, LTotpSec, LTotpIv, LSite, LTitle, LUser, LFolder, LEnc, LIV, LTags, LNow, LTotpSec, LTotpIv,
LKind, LCf, LCfIv: string; LKind, LCf, LCfIv, LTemplate: string;
LHasTemplate: Boolean;
LQ: TFDQuery; LQ: TFDQuery;
begin begin
try try
@@ -315,6 +329,10 @@ begin
LKind := LBody.GetValue<string>('kind', 'login'); LKind := LBody.GetValue<string>('kind', 'login');
LCf := LBody.GetValue<string>('custom_fields', ''); LCf := LBody.GetValue<string>('custom_fields', '');
LCfIv := LBody.GetValue<string>('custom_fields_iv', ''); LCfIv := LBody.GetValue<string>('custom_fields_iv', '');
// Template is only updated when the caller explicitly sends it —
// partial PUTs (drag-to-folder) must not wipe it.
LHasTemplate := LBody.GetValue('template') <> nil;
LTemplate := Trim(LBody.GetValue<string>('template', ''));
finally finally
LBody.Free; LBody.Free;
end; end;
@@ -365,6 +383,10 @@ begin
// password_changed_at fires only when the ciphertext actually // password_changed_at fires only when the ciphertext actually
// changes — same conditional used above for history insertion. // changes — same conditional used above for history insertion.
// template column is updated only when the caller sent it, so a
// partial PUT (drag-to-folder, move-to-folder) doesn't wipe it.
var LTemplateSet := '';
if LHasTemplate then LTemplateSet := ', template=:tpl';
LQ.SQL.Text := LQ.SQL.Text :=
'UPDATE vault_entries ' + 'UPDATE vault_entries ' +
'SET site=:s, title=:tt, username=:u, encrypted_password=:e, iv=:i, ' + 'SET site=:s, title=:tt, username=:u, encrypted_password=:e, iv=:i, ' +
@@ -372,7 +394,8 @@ begin
' custom_fields=:cf, custom_fields_iv=:cfiv, ' + ' custom_fields=:cf, custom_fields_iv=:cfiv, ' +
' updated_at=:c, ' + ' updated_at=:c, ' +
' password_changed_at = CASE WHEN encrypted_password <> :e ' + ' password_changed_at = CASE WHEN encrypted_password <> :e ' +
' THEN :c ELSE password_changed_at END ' + ' THEN :c ELSE password_changed_at END' +
LTemplateSet + ' ' +
'WHERE id=:id AND user_id=:uid'; 'WHERE id=:id AND user_id=:uid';
LQ.ParamByName('s').AsString := LSite; LQ.ParamByName('s').AsString := LSite;
LQ.ParamByName('tt').AsString := LTitle; LQ.ParamByName('tt').AsString := LTitle;
@@ -383,23 +406,29 @@ begin
LQ.ParamByName('t').AsString := LTags; LQ.ParamByName('t').AsString := LTags;
// Declare TOTP param types so .Clear works on first use (FireDAC // Declare TOTP param types so .Clear works on first use (FireDAC
// needs an inferred or explicit DataType before NULL binding). // needs an inferred or explicit DataType before NULL binding).
LQ.ParamByName('ts').DataType := ftString; LQ.ParamByName('ts').DataType := ftMemo;
LQ.ParamByName('tiv').DataType := ftString; LQ.ParamByName('tiv').DataType := ftMemo;
// Clearing TOTP (user removed 2FA from this entry) is signaled by an // Clearing TOTP (user removed 2FA from this entry) is signaled by an
// empty string in the request → store NULL in the DB. // empty string in the request → store NULL in the DB.
if LTotpSec = '' then if LTotpSec = '' then
LQ.ParamByName('ts').Clear LQ.ParamByName('ts').Clear
else else
LQ.ParamByName('ts').AsString := LTotpSec; LQ.ParamByName('ts').Value := LTotpSec;
if LTotpIv = '' then if LTotpIv = '' then
LQ.ParamByName('tiv').Clear LQ.ParamByName('tiv').Clear
else else
LQ.ParamByName('tiv').AsString := LTotpIv; LQ.ParamByName('tiv').Value := LTotpIv;
LQ.ParamByName('k').AsString := LKind; LQ.ParamByName('k').AsString := LKind;
LQ.ParamByName('cf').DataType := ftString; LQ.ParamByName('cf').DataType := ftMemo;
LQ.ParamByName('cfiv').DataType := ftString; LQ.ParamByName('cfiv').DataType := ftMemo;
if LCf = '' then LQ.ParamByName('cf').Clear else LQ.ParamByName('cf').AsString := LCf; if LCf = '' then LQ.ParamByName('cf').Clear else LQ.ParamByName('cf').Value := LCf;
if LCfIv = '' then LQ.ParamByName('cfiv').Clear else LQ.ParamByName('cfiv').AsString := LCfIv; if LCfIv = '' then LQ.ParamByName('cfiv').Clear else LQ.ParamByName('cfiv').Value := LCfIv;
if LHasTemplate then
begin
LQ.ParamByName('tpl').DataType := ftString;
if LTemplate = '' then LQ.ParamByName('tpl').Clear
else LQ.ParamByName('tpl').AsString := LTemplate;
end;
LQ.ParamByName('c').AsString := LNow; LQ.ParamByName('c').AsString := LNow;
LQ.ParamByName('id').AsInteger := LId; LQ.ParamByName('id').AsInteger := LId;
LQ.ParamByName('uid').AsInteger := LUserId; LQ.ParamByName('uid').AsInteger := LUserId;
@@ -558,6 +587,51 @@ begin
TJSONHelper.SendOK(AResponse, 'Toggled'); TJSONHelper.SendOK(AResponse, 'Toggled');
end; end;
// ===== POST /entries/{id}/pin ================================================
// Same shape as /favorite: flip the pinned bit, no body required.
procedure HandleTogglePin(ARequest: TIdHTTPRequestInfo;
AResponse: TIdHTTPResponseInfo; const AParams: TArray<string>);
var
LUserId, LId: Integer;
LQ: TFDQuery;
begin
try
LUserId := Authenticate(ARequest, AResponse);
RequireCSRF(ARequest, AResponse, LUserId);
except
on ESessionRejected do Exit;
end;
LId := StrToIntDef(AParams[0], 0);
if LId = 0 then
begin
TJSONHelper.SendError(AResponse, 400, 'Invalid id');
Exit;
end;
DB.Lock;
try
LQ := TFDQuery.Create(nil);
try
LQ.Connection := DB.Connection;
LQ.SQL.Text :=
'UPDATE vault_entries ' +
'SET pinned = CASE WHEN pinned=1 THEN 0 ELSE 1 END ' +
'WHERE id=:id AND user_id=:uid';
LQ.ParamByName('id').AsInteger := LId;
LQ.ParamByName('uid').AsInteger := LUserId;
LQ.ExecSQL;
finally
LQ.Free;
end;
finally
DB.Unlock;
end;
LogAudit(LUserId, 'toggle_pin', GetClientIP(ARequest));
TJSONHelper.SendOK(AResponse, 'Toggled');
end;
// ===== POST /entries/{id}/touch ============================================== // ===== POST /entries/{id}/touch ==============================================
// Bumps accessed_at. Called from JS on copy / slideover-open so the sidebar // Bumps accessed_at. Called from JS on copy / slideover-open so the sidebar
// "Recent" view can show what the user actually uses. Auth-only (no CSRF // "Recent" view can show what the user actually uses. Auth-only (no CSRF
@@ -637,12 +711,12 @@ begin
LBody.Free; LBody.Free;
end; end;
// Soft cap to prevent a misbehaving fetcher from ballooning the DB. // Soft cap on the SERIALISED data URI ('data:image/...;base64,...'). The
// 32x32 PNG favicons rarely exceed 4 KB; 64 KB leaves room for SVG / 64x64. // favicon fetcher allows 256 KB raw, which becomes ~350 KB after base64
// Soft cap. Most favicons are < 10 KB; bumped to 256 KB because DDG // + prefix overhead. Cap at 512 KB chars so a max-raw fetch + a bit of
// occasionally serves the brand's full-resolution PNG (deepseek.com // headroom still passes (the silent 413 here was deleting deepseek's
// came back at 200+ KB) and we want those to be cacheable too. // 200+ KB icon on lock/unlock since saveEntryIcon swallows the error).
if Length(LIcon) > 262144 then if Length(LIcon) > 524288 then
begin begin
TJSONHelper.SendError(AResponse, 413, 'Icon too large'); TJSONHelper.SendError(AResponse, 413, 'Icon too large');
Exit; Exit;
@@ -658,7 +732,7 @@ begin
'WHERE id=:id AND user_id=:uid'; 'WHERE id=:id AND user_id=:uid';
LQ.ParamByName('ic').DataType := ftMemo; // long text → ftMemo on SQLite LQ.ParamByName('ic').DataType := ftMemo; // long text → ftMemo on SQLite
if LIcon = '' then LQ.ParamByName('ic').Clear if LIcon = '' then LQ.ParamByName('ic').Clear
else LQ.ParamByName('ic').AsString := LIcon; else LQ.ParamByName('ic').Value := LIcon;
LQ.ParamByName('id').AsInteger := LId; LQ.ParamByName('id').AsInteger := LId;
LQ.ParamByName('uid').AsInteger := LUserId; LQ.ParamByName('uid').AsInteger := LUserId;
LQ.ExecSQL; LQ.ExecSQL;
@@ -862,10 +936,11 @@ end;
procedure HandleBulkImport(ARequest: TIdHTTPRequestInfo; procedure HandleBulkImport(ARequest: TIdHTTPRequestInfo;
AResponse: TIdHTTPResponseInfo; const AParams: TArray<string>); AResponse: TIdHTTPResponseInfo; const AParams: TArray<string>);
var var
LUserId, I, LImported: Integer; LUserId, I, LImported, LNewId: Integer;
LBody, LObj, LEntry: TJSONObject; LBody, LObj, LEntry: TJSONObject;
LArr: TJSONArray; LArr, LIds: TJSONArray;
LSite, LTitle, LUser, LFolder, LEnc, LIV, LTags, LTotpSec, LTotpIv, LNow: string; LSite, LTitle, LUser, LFolder, LEnc, LIV, LTags, LTotpSec, LTotpIv, LNow,
LKind, LCf, LCfIv, LIcon, LTemplate: string;
LQ: TFDQuery; LQ: TFDQuery;
begin begin
try try
@@ -894,6 +969,10 @@ begin
LNow := FormatDateTime('yyyy-mm-dd hh:nn:ss', Now); LNow := FormatDateTime('yyyy-mm-dd hh:nn:ss', Now);
LImported := 0; LImported := 0;
// Track newly-inserted IDs in input order so the client can upload
// attachments to the right entry afterwards. Skipped rows emit -1
// so the array remains positionally aligned with the input.
LIds := TJSONArray.Create;
DB.Lock; DB.Lock;
try try
@@ -905,14 +984,24 @@ begin
LQ.SQL.Text := LQ.SQL.Text :=
'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, created_at, updated_at) ' + ' folder, tags, totp_secret, totp_iv, kind, custom_fields, custom_fields_iv,' +
'VALUES (:uid, :s, :tt, :u, :e, :i, ''client'', :f, :t, :ts, :tiv, :c, :c2)'; ' icon_b64, template, created_at, updated_at) ' +
// Declare optional TOTP param types ONCE — the prepared statement 'VALUES (:uid, :s, :tt, :u, :e, :i, ''client'', :f, :t, :ts, :tiv, :k, ' +
// is reused across every imported entry, and FireDAC needs the ' :cf, :cfiv, :ic, :tpl, :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 // type set before the first .Clear call would otherwise fail
// for a row without TOTP. // for a row that omits the field. ftMemo (unlimited TEXT) is
LQ.ParamByName('ts').DataType := ftString; // mandatory for icon_b64 + custom_fields which often exceed
LQ.ParamByName('tiv').DataType := ftString; // FireDAC's default ftString cap (~4000 chars) — a 256 KB
// icon would otherwise be silently truncated to nothing on
// the next read.
LQ.ParamByName('ts').DataType := ftMemo;
LQ.ParamByName('tiv').DataType := ftMemo;
LQ.ParamByName('cf').DataType := ftMemo;
LQ.ParamByName('cfiv').DataType := ftMemo;
LQ.ParamByName('ic').DataType := ftMemo;
LQ.ParamByName('tpl').DataType := ftString;
for I := 0 to LArr.Count - 1 do for I := 0 to LArr.Count - 1 do
begin begin
@@ -926,11 +1015,27 @@ begin
LTags := Trim(LEntry.GetValue<string>('tags', '')); LTags := Trim(LEntry.GetValue<string>('tags', ''));
LTotpSec := LEntry.GetValue<string>('totp_secret', ''); LTotpSec := LEntry.GetValue<string>('totp_secret', '');
LTotpIv := LEntry.GetValue<string>('totp_iv', ''); LTotpIv := LEntry.GetValue<string>('totp_iv', '');
LKind := LEntry.GetValue<string>('kind', 'login');
if (LKind <> 'login') and (LKind <> 'note') then LKind := 'login';
LCf := LEntry.GetValue<string>('custom_fields', '');
LCfIv := LEntry.GetValue<string>('custom_fields_iv', '');
LIcon := LEntry.GetValue<string>('icon_b64', '');
LTemplate:= Trim(LEntry.GetValue<string>('template', ''));
// Skip silently if a row is missing the minimum required fields // Ciphertext is always required. Site is required only for
// (site + ciphertext). Better than failing the whole batch on // logins — notes legitimately have no site (their body lives
// one bad row when the user is importing 500+ entries. // inside the encrypted blob). Skip silently rather than fail
if (LSite = '') or (LEnc = '') or (LIV = '') then Continue; // the whole batch on one bad row.
if (LEnc = '') or (LIV = '') then
begin
LIds.AddElement(TJSONNumber.Create(-1));
Continue;
end;
if (LKind = 'login') and (LSite = '') then
begin
LIds.AddElement(TJSONNumber.Create(-1));
Continue;
end;
LQ.ParamByName('uid').AsInteger := LUserId; LQ.ParamByName('uid').AsInteger := LUserId;
LQ.ParamByName('s').AsString := LSite; LQ.ParamByName('s').AsString := LSite;
@@ -941,12 +1046,20 @@ begin
LQ.ParamByName('f').AsString := LFolder; LQ.ParamByName('f').AsString := LFolder;
LQ.ParamByName('t').AsString := LTags; LQ.ParamByName('t').AsString := LTags;
if LTotpSec = '' then LQ.ParamByName('ts').Clear if LTotpSec = '' then LQ.ParamByName('ts').Clear
else LQ.ParamByName('ts').AsString := LTotpSec; else LQ.ParamByName('ts').Value := LTotpSec;
if LTotpIv = '' then LQ.ParamByName('tiv').Clear if LTotpIv = '' then LQ.ParamByName('tiv').Clear
else LQ.ParamByName('tiv').AsString := LTotpIv; else LQ.ParamByName('tiv').Value := LTotpIv;
LQ.ParamByName('k').AsString := LKind;
if LCf = '' then LQ.ParamByName('cf').Clear else LQ.ParamByName('cf').Value := LCf;
if LCfIv = '' then LQ.ParamByName('cfiv').Clear else LQ.ParamByName('cfiv').Value := LCfIv;
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('c').AsString := LNow; LQ.ParamByName('c').AsString := LNow;
LQ.ParamByName('c2').AsString := LNow; LQ.ParamByName('c2').AsString := LNow;
LQ.ExecSQL; LQ.ExecSQL;
LNewId := DB.Connection.GetLastAutoGenValue('vault_entries');
LIds.AddElement(TJSONNumber.Create(LNewId));
Inc(LImported); Inc(LImported);
end; end;
finally finally
@@ -967,6 +1080,7 @@ begin
LogAudit(LUserId, Format('bulk_import %d entries', [LImported]), GetClientIP(ARequest)); LogAudit(LUserId, Format('bulk_import %d entries', [LImported]), GetClientIP(ARequest));
LObj := TJSONObject.Create; LObj := TJSONObject.Create;
LObj.AddPair('imported', TJSONNumber.Create(LImported)); LObj.AddPair('imported', TJSONNumber.Create(LImported));
LObj.AddPair('ids', LIds);
TJSONHelper.SendJSON(AResponse, LObj); TJSONHelper.SendJSON(AResponse, LObj);
end; end;
@@ -1026,6 +1140,7 @@ initialization
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);
Router.Register('POST', '/entries/(\d+)/touch', HandleTouchEntry); Router.Register('POST', '/entries/(\d+)/touch', HandleTouchEntry);
Router.Register('POST', '/entries/(\d+)/pin', HandleTogglePin);
Router.Register('POST', '/entries/(\d+)/icon', HandleSetEntryIcon); Router.Register('POST', '/entries/(\d+)/icon', HandleSetEntryIcon);
Router.Register('GET', '/entries/(\d+)/history', HandleGetEntryHistory); Router.Register('GET', '/entries/(\d+)/history', HandleGetEntryHistory);
Router.Register('GET', '/entries/count', HandleEntriesCount); Router.Register('GET', '/entries/count', HandleEntriesCount);
+5
View File
@@ -178,6 +178,11 @@ type
// the tray icon mute. Configured from the JS settings panel. // the tray icon mute. Configured from the JS settings panel.
property ShowNotifications: Boolean property ShowNotifications: Boolean
read FShowNotifications write FShowNotifications; read FShowNotifications write FShowNotifications;
// Already-shown flag for the one-time "still running in the tray"
// balloon. Exposed so the host can pre-mark it true on autostart
// launches (the user didn't actively minimise — no need to inform them).
property BalloonShown: Boolean
read FBalloonShown write FBalloonShown;
// Fired on main thread when the autofill hotkey fires. // Fired on main thread when the autofill hotkey fires.
// Args: (ATargetHWND, AWindowTitle). Handler calls ExecuteJavaScript // Args: (ATargetHWND, AWindowTitle). Handler calls ExecuteJavaScript
// to let JS match the title against vault entries. // to let JS match the title against vault entries.
+8
View File
@@ -306,6 +306,14 @@ begin
// the "aged password" badge. Legacy rows: NULL → JS falls back to // the "aged password" badge. Legacy rows: NULL → JS falls back to
// updated_at, then created_at. // updated_at, then created_at.
AddColumnIfMissing('vault_entries', 'password_changed_at', 'DATETIME'); AddColumnIfMissing('vault_entries', 'password_changed_at', 'DATETIME');
// Pinned entries float to the top of every view, regardless of sort.
// Independent from favorite (which is a filter, not a sort override).
AddColumnIfMissing('vault_entries', 'pinned', 'INTEGER DEFAULT 0');
// Template identifier: empty/NULL = generic login or note; otherwise a
// string like 'credit-card', 'ssh-key', 'server', 'recovery-codes'.
// 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');
// 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');
+2 -2
View File
@@ -1,4 +1,4 @@
unit PM.Favicon; unit PM.Favicon;
{ {
Favicon proxy — fetches a website's icon and returns a base64 data URI Favicon proxy — fetches a website's icon and returns a base64 data URI
@@ -44,7 +44,7 @@ const
HTTP_TIMEOUT_MS = 5000; HTTP_TIMEOUT_MS = 5000;
// DDG returns a generic placeholder for unknown domains. Bigger threshold // DDG returns a generic placeholder for unknown domains. Bigger threshold
// than 100 to avoid treating its blank globe glyph as a real icon. // than 100 to avoid treating its blank globe glyph as a real icon.
MIN_REAL_ICON_BYTES = 500; MIN_REAL_ICON_BYTES = 300;
// Privacy stance: DDG-only fetches. We don't fall back to the site's // Privacy stance: DDG-only fetches. We don't fall back to the site's
// own /favicon.ico because that would leak DNS to every domain stored // own /favicon.ico because that would leak DNS to every domain stored
// in the vault. For sites DDG doesn't index, the user can upload a // in the vault. For sites DDG doesn't index, the user can upload a
+29 -5
View File
@@ -1,4 +1,4 @@
unit PM.QuickUnlock; unit PM.QuickUnlock;
{ {
Quick unlock — persistent on-device cache of the vault key, encrypted Quick unlock — persistent on-device cache of the vault key, encrypted
@@ -85,18 +85,42 @@ function CryptUnprotectData(pDataIn: PDataBlob; ppszDataDescr: PPWideChar;
function LocalFree(hMem: HLOCAL): HLOCAL; stdcall; function LocalFree(hMem: HLOCAL): HLOCAL; stdcall;
external 'kernel32.dll' name 'LocalFree'; external 'kernel32.dll' name 'LocalFree';
var LoadedConfig:Boolean=False;
StorageDir_:String='';
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Storage helpers // Storage helpers
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
procedure LoadConfig;
begin
if LoadedConfig then
exit;
Var ConfigList := TStringList.Create;
try
StorageDir_ := TPath.Combine(ExtractFileDir(ParamStr(0)),'config.txt');
if TFile.Exists(StorageDir_) then
begin
ConfigList.LoadFromFile(StorageDir_);
StorageDir_ := ConfigList.Values['PathUnlock'];
if StorageDir_.ToLower.Equals('same') then
StorageDir_ := ExtractFileDir(ParamStr(0))
end
else
StorageDir_ := '';
finally
FreeAndNil(ConfigList);
LoadedConfig :=True;
end;
end;
function StorageDir: string; function StorageDir: string;
begin begin
// %LOCALAPPDATA%\PMServer — per-user, roaming-disabled. DPAPI keys live // %LOCALAPPDATA%\PMServer — per-user, roaming-disabled. DPAPI keys live
// alongside the user profile so they survive Windows updates but not // alongside the user profile so they survive Windows updates but not
// a profile reset. // a profile reset.
Result := TPath.Combine( LoadConfig;
GetEnvironmentVariable('LOCALAPPDATA'), if StorageDir_.IsEmpty then
'PMServer'); Result := TPath.Combine(GetEnvironmentVariable('LOCALAPPDATA'),'PMServer')
else
Result :=StorageDir_;
end; end;
function StorageFile: string; function StorageFile: string;
+29 -2
View File
@@ -13,6 +13,8 @@
interface 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,
@@ -33,7 +35,10 @@ uses
PM.HTTPServer, PM.Bridge, PM.QuickUnlock, PM.UserPrefs, PM.AutoStart, PM.HTTPServer, PM.Bridge, PM.QuickUnlock, PM.UserPrefs, PM.AutoStart,
PM.Favicon, PM.Favicon,
FMX.Platform.Win, FMX.Menus; // WindowHandleToPlatform → HWND for visibility check FMX.Platform.Win, FMX.Menus; // WindowHandleToPlatform → HWND for visibility check
const
// Bump on each release. Surfaced to JS via cmd://app/version, displayed
// in Settings → Account so users can report bugs with the right build.
APP_VERSION = '1.0.0';
type type
// Concrete class chosen at compile time. Both inherit from // Concrete class chosen at compile time. Both inherit from
// TTMSFNCCustomWebBrowser so we use that as the field type — events // TTMSFNCCustomWebBrowser so we use that as the field type — events
@@ -212,11 +217,15 @@ begin
// initial Show before we hide it — minimises the visible flash. // initial Show before we hide it — minimises the visible flash.
if FindCmdLineSwitch('tray', True) then if FindCmdLineSwitch('tray', True) then
begin begin
// Suppress the first-time tray balloon for autostart launches —
// it's noise when Windows itself put us in the tray (the user
// didn't actively minimise). Manual launches still see it once.
FBridge.BalloonShown := True;
TThread.ForceQueue(nil, TThread.ForceQueue(nil,
procedure procedure
begin begin
FBridge.MinimizeToTray; FBridge.MinimizeToTray;
LogLine('Launched with --tray, minimised at startup'); LogLine('Launched with --tray, minimised at startup (balloon suppressed)');
end); end);
end; end;
end; end;
@@ -742,6 +751,24 @@ begin
else if ACmd = 'app/theme' then else if ACmd = 'app/theme' then
FBridge.ApplyTitleBarTheme(GetParam('mode') = 'dark') FBridge.ApplyTitleBarTheme(GetParam('mode') = 'dark')
// Build/version string exposed to JS for the Settings → About panel.
// Hardcoded const — bump manually on releases. Kept simple to avoid
// pulling Windows resource version info at runtime.
else if ACmd = 'app/version' then
WebBrowser.ExecuteJavaScript(
'if(window.Bridge&&Bridge.onVersionResult)' +
'Bridge.onVersionResult("' + APP_VERSION + '")')
// Launch context: the HKCU Run entry passes -tray so we can tell
// "Windows started me at boot" from "user double-clicked the exe".
// Useful for Settings → Account display and for conditional behavior
// (e.g. skip first-time tooltips on autostart).
else if ACmd = 'app/launch-mode' then
WebBrowser.ExecuteJavaScript(
'if(window.Bridge&&Bridge.onLaunchModeResult)' +
'Bridge.onLaunchModeResult("' +
IfThen(FindCmdLineSwitch('tray', True), 'auto', 'manual') + '")')
// Open the entry's site in the user's default browser. We restrict the // Open the entry's site in the user's default browser. We restrict the
// scheme to http(s) so JS can't smuggle a file:// or other handler that // scheme to http(s) so JS can't smuggle a file:// or other handler that
// would invoke arbitrary Windows applications. // would invoke arbitrary Windows applications.
Binary file not shown.
+27 -2
View File
@@ -45,6 +45,7 @@
<symbol id="i-key" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m21 2-9.6 9.6"/><circle cx="7.5" cy="15.5" r="5.5"/><path d="m21 2-2 2 2 2-3 3-2-2"/></symbol> <symbol id="i-key" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m21 2-9.6 9.6"/><circle cx="7.5" cy="15.5" r="5.5"/><path d="m21 2-2 2 2 2-3 3-2-2"/></symbol>
<symbol id="i-user" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></symbol> <symbol id="i-user" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></symbol>
<symbol id="i-printer" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 6 2 18 2 18 9"/><path d="M6 18H4a2 2 0 0 1-2-2v-5a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2v5a2 2 0 0 1-2 2h-2"/><rect x="6" y="14" width="12" height="8"/></symbol> <symbol id="i-printer" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 6 2 18 2 18 9"/><path d="M6 18H4a2 2 0 0 1-2-2v-5a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2v5a2 2 0 0 1-2 2h-2"/><rect x="6" y="14" width="12" height="8"/></symbol>
<symbol id="i-pin" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 17v5"/><path d="M9 10.76a2 2 0 0 1-1.11 1.79l-1.78.9A2 2 0 0 0 5 15.24V16a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1v-.76a2 2 0 0 0-1.11-1.79l-1.78-.9A2 2 0 0 1 15 10.76V7a1 1 0 0 1 1-1 2 2 0 0 0 0-4H8a2 2 0 0 0 0 4 1 1 0 0 1 1 1z"/></symbol>
<symbol id="i-paperclip" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21.44 11.05 12.25 20.24a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48"/></symbol> <symbol id="i-paperclip" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21.44 11.05 12.25 20.24a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48"/></symbol>
<symbol id="i-download" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></symbol> <symbol id="i-download" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></symbol>
<symbol id="i-empty-vault" viewBox="0 0 120 120" fill="none"> <symbol id="i-empty-vault" viewBox="0 0 120 120" fill="none">
@@ -121,7 +122,7 @@
<svg><use href="#i-unlock"/></svg> <svg><use href="#i-unlock"/></svg>
Unlock Unlock
</button> </button>
<button id="passkeyBtn" type="button" class="btn btn-ghost btn-block"> <button id="passkeyBtn" type="button" class="btn btn-ghost btn-block" style="display:none">
<svg><use href="#i-lock"/></svg> <svg><use href="#i-lock"/></svg>
Use a passkey Use a passkey
</button> </button>
@@ -266,6 +267,7 @@
<svg><use href="#i-search"/></svg> <svg><use href="#i-search"/></svg>
<input id="searchInput" type="search" placeholder="Search…" autocomplete="off"> <input id="searchInput" type="search" placeholder="Search…" autocomplete="off">
<kbd>Ctrl+K</kbd> <kbd>Ctrl+K</kbd>
<div id="searchHistoryMenu" class="search-history is-hidden"></div>
</div> </div>
<div class="topbar-actions"> <div class="topbar-actions">
<div class="view-toggle"> <div class="view-toggle">
@@ -311,6 +313,24 @@
<svg><use href="#i-edit"/></svg> <svg><use href="#i-edit"/></svg>
<span>New note</span> <span>New note</span>
</button> </button>
<div class="dropdown-sep"></div>
<div class="dropdown-section">Templates</div>
<button class="dropdown-item" data-new-template="credit-card">
<svg><use href="#i-key"/></svg>
<span>Credit card</span>
</button>
<button class="dropdown-item" data-new-template="ssh-key">
<svg><use href="#i-shield"/></svg>
<span>SSH key</span>
</button>
<button class="dropdown-item" data-new-template="server">
<svg><use href="#i-globe"/></svg>
<span>Server credentials</span>
</button>
<button class="dropdown-item" data-new-template="recovery-codes">
<svg><use href="#i-shield"/></svg>
<span>Recovery codes (note)</span>
</button>
</div> </div>
</div> </div>
<div class="user-menu"> <div class="user-menu">
@@ -647,9 +667,12 @@
<div class="slideover-field"> <div class="slideover-field">
<div class="slideover-field-label">Account</div> <div class="slideover-field-label">Account</div>
<p style="font-size:12px;color:var(--text-dim);margin:0 0 8px"> <p style="font-size:12px;color:var(--text-dim);margin:0 0 4px">
Signed in as <b id="settingUser"></b> Signed in as <b id="settingUser"></b>
</p> </p>
<p style="font-size:11px;color:var(--text-faint);margin:0 0 8px" id="settingVersionRow">
PMServer <span id="settingVersion"></span>
</p>
<button class="btn btn-ghost btn-sm" id="changeMasterBtn"> <button class="btn btn-ghost btn-sm" id="changeMasterBtn">
<svg><use href="#i-lock"/></svg> Change master password <svg><use href="#i-lock"/></svg> Change master password
</button> </button>
@@ -711,6 +734,8 @@
<span>Master password</span> <span>Master password</span>
<input id="reauthPassword" type="password" required autofocus> <input id="reauthPassword" type="password" required autofocus>
</label> </label>
<p id="reauthError" class="is-hidden"
style="margin:8px 0 0;color:var(--danger);font-size:12px"></p>
</form> </form>
<footer class="modal-footer"> <footer class="modal-footer">
<button type="button" class="btn btn-ghost" data-close>Cancel</button> <button type="button" class="btn btn-ghost" data-close>Cancel</button>
+1104 -134
View File
File diff suppressed because it is too large Load Diff