feat: secure notes + password history + custom fields + quick-win bundle
Big feature trio
- Secure notes (kind='login'|'note') reusing the encrypted_password+iv
pipeline for the body. New sidebar entry, slideover variant (title +
multiline body), distinct card / table-view rendering, badge in name
column, copy-content button replacing the password copy on note rows.
- Password history: entries_password_history table keeps up to 20 prior
ciphertexts per entry. HandleUpdateEntry pushes the pre-update
encrypted_password into history ONLY when it actually differs from
the incoming one (JS reuses originalEncrypted bit-for-bit when the
plaintext is unchanged — avoids spamming history on title/folder edits).
GET /entries/{id}/history endpoint. Slideover modal lists versions
with mask/reveal/copy/revert. Master-pw rotation wipes history (old
ciphertext can't be decrypted with the new key).
- Custom fields: per-entry encrypted JSON array of {label, value,
is_secret}. Same crypto pipeline as the password. Slideover row UI
with label/value inputs, secret toggle (eye), copy, delete. Re-
encryption flows through bulk-import, change-master-password, and
duplicate.
Quick wins
- Cheatsheet overlay (press '?' or topbar button or Ctrl+K). Lists all
hotkeys + global / tray / card actions. SVG icons inline so the
cheatsheet matches the actual app glyphs (no emoji mismatch).
- Open URL button on entry cards: ShellExecute via cmd://app/open-url,
http(s) only, validates entry.site looks like a real hostname.
- Trash auto-purge: setting "Empty trash after N days" (never/7/30/90).
DELETE /entries/trash/old?days=N called at every unlock.
Favicon strategy
- Subdomains (chat.deepseek.com, app.X.com…) now try the SLD first
(deepseek.com.ico) before the full host. DDG often returns a generic
placeholder for subdomains that passes the byte threshold; the SLD-first
switch surfaces the real brand icon.
- Cap bumped 64 KB → 256 KB on all three sides (Delphi fetch, server
endpoint, JS upload). DDG sometimes serves the full-res asset.
UX polish
- Click-outside-slideover: stopPropagation everywhere it bites. Custom
fields buttons (add / delete / secret toggle / copy / eye) all stop
the click bubble so the document-level "close on outside click" handler
doesn't fire when rerender() detaches the target from the DOM.
- Native search-cancel button restyled: cyan accent X via mask-image,
cursor: pointer, breathing room before the Ctrl+K kbd chip.
- Password history modal: scrollable body, multiline wrapped passwords,
hover border highlight.
- Cheatsheet panel widened (560 → 720 px) so the descriptions no longer
ellipsis-clip.
- "+ New" topbar splits into a small dropdown: New login / New note.
- Notes show a "note" badge in table-view name column, italic
"Encrypted note" placeholder in the username column.
Internals
- duplicateEntry copies kind + custom_fields too (one-line forgotten
earlier).
- entries_password_history dropped on master-pw rotation — the old
ciphertexts are unrecoverable with the new key.
- bulk-import re-encryption path includes custom_fields.
CLAUDE.md
- "Entry payload — call sites à toucher ensemble" lists the 6 spots
to update when adding a new (en)crypted field. Notes the historical
miss of kind in duplicateEntry and custom_fields in the rotation +
duplicate.
Repo hygiene
- .gitattributes forces CRLF on Delphi sources (RAD Studio refuses LF).
text=auto for web frontend / docs, binary for .res / .exe / images.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,33 @@
|
|||||||
|
# Delphi / RAD Studio source files require CRLF line endings — the IDE
|
||||||
|
# warns ("Line endings are LF, but RAD Studio requires CRLF") and some
|
||||||
|
# packagers refuse LF outright. Force CRLF on both checkout and check-in
|
||||||
|
# so the working tree always has the right line endings regardless of
|
||||||
|
# core.autocrlf setting on the dev machine.
|
||||||
|
*.pas text eol=crlf
|
||||||
|
*.dpr text eol=crlf
|
||||||
|
*.dpk text eol=crlf
|
||||||
|
*.dproj text eol=crlf
|
||||||
|
*.fmx text eol=crlf
|
||||||
|
*.dfm text eol=crlf
|
||||||
|
*.inc text eol=crlf
|
||||||
|
*.rc text eol=crlf
|
||||||
|
|
||||||
|
# Resource files generated from web assets — keep them binary so Git
|
||||||
|
# doesn't try to normalise the embedded bytes.
|
||||||
|
*.res binary
|
||||||
|
|
||||||
|
# Web frontend & docs — let Git normalise to native (LF on Linux/macOS,
|
||||||
|
# CRLF on Windows via autocrlf). Explicit so .pas's eol=crlf doesn't
|
||||||
|
# cascade by accident.
|
||||||
|
*.html text
|
||||||
|
*.js text
|
||||||
|
*.css text
|
||||||
|
*.json text
|
||||||
|
*.md text
|
||||||
|
|
||||||
|
# Build outputs / archives — never normalise
|
||||||
|
*.exe binary
|
||||||
|
*.dll binary
|
||||||
|
*.ico binary
|
||||||
|
*.png binary
|
||||||
|
*.rar binary
|
||||||
@@ -353,6 +353,37 @@ viewMode change, pageSize change. Render via `renderPagination(total, totalPages
|
|||||||
Résout le cas "j'ai ajouté un mot de passe avec tri A-Z, où se loge-t-il ?"
|
Résout le cas "j'ai ajouté un mot de passe avec tri A-Z, où se loge-t-il ?"
|
||||||
— scroll + pulse trouvent la nouvelle entry dans la grille triée.
|
— scroll + pulse trouvent la nouvelle entry dans la grille triée.
|
||||||
|
|
||||||
|
## Entry payload — call sites à toucher ensemble
|
||||||
|
|
||||||
|
Une `vault_entries` row porte **plusieurs blobs chiffrés indépendants** :
|
||||||
|
`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,
|
||||||
|
username, folder, tags, kind`).
|
||||||
|
|
||||||
|
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
|
||||||
|
sur certaines actions :
|
||||||
|
|
||||||
|
1. **DB schema** : `PM.Database.CreateSchema` `AddColumnIfMissing(...)`
|
||||||
|
2. **GET /entries** : `PM.Handler.Entries.HandleGetEntries` — ajouter au
|
||||||
|
`LObj.AddPair(...)` (NULL → `TJSONNull`)
|
||||||
|
3. **POST + PUT /entries** : `HandleCreateEntry` + `HandleUpdateEntry` —
|
||||||
|
lire du body, binder le param, gérer `Clear` pour NULL
|
||||||
|
4. **Master pw rotation** : `PM.Handler.Auth.HandleChangeMasterPassword` —
|
||||||
|
UPDATE doit inclure la colonne, sinon la rotation l'écrase à NULL
|
||||||
|
5. **Master pw rotation JS** : `doChangeMasterPassword` → la boucle
|
||||||
|
`for (const e of state.entries)` re-chiffre chaque blob et push dans
|
||||||
|
`encrypted[]`. Manquer un champ chiffré = donnée perdue.
|
||||||
|
6. **`duplicateEntry`** (js/app.js) — copier le blob chiffré tel quel
|
||||||
|
(même vault key, pas besoin de re-chiffrer)
|
||||||
|
|
||||||
|
Bonus utile (pas critique) : `soDirtyCheck` doit comparer le nouveau
|
||||||
|
champ, et `openSlideOver` doit le déchiffrer et l'exposer via `soState`.
|
||||||
|
|
||||||
|
Historiquement on a oublié `kind` dans `duplicateEntry` (bug "Site required"
|
||||||
|
sur duplique-note), et `custom_fields` dans la rotation + duplicate. Cette
|
||||||
|
liste évite de répéter ces erreurs.
|
||||||
|
|
||||||
## Settings sync
|
## Settings sync
|
||||||
|
|
||||||
Per-user blob JSON dans `users.settings_json`, exposé via `GET/PUT
|
Per-user blob JSON dans `users.settings_json`, exposé via `GET/PUT
|
||||||
|
|||||||
+288
-1
@@ -564,7 +564,7 @@ input[type="range"]::-webkit-slider-thumb {
|
|||||||
}
|
}
|
||||||
.search input {
|
.search input {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: 8px 60px 8px 34px;
|
padding: 8px 70px 8px 34px;
|
||||||
background: var(--bg-elev);
|
background: var(--bg-elev);
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
border-radius: var(--radius-sm);
|
border-radius: var(--radius-sm);
|
||||||
@@ -576,6 +576,28 @@ input[type="range"]::-webkit-slider-thumb {
|
|||||||
border-color: var(--accent);
|
border-color: var(--accent);
|
||||||
box-shadow: 0 0 0 3px var(--accent-soft);
|
box-shadow: 0 0 0 3px var(--accent-soft);
|
||||||
}
|
}
|
||||||
|
/* Replace the native (blue/grey) search-cancel button with a custom one
|
||||||
|
tinted via background-color so it picks up the accent on hover and
|
||||||
|
matches the other X buttons in the app. mask-image keeps it crisp. */
|
||||||
|
.search input::-webkit-search-cancel-button {
|
||||||
|
appearance: none;
|
||||||
|
-webkit-appearance: none;
|
||||||
|
width: 16px; height: 16px;
|
||||||
|
margin-right: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
background-color: var(--accent);
|
||||||
|
-webkit-mask-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='currentColor' stroke-width='2.5' stroke-linecap='round' stroke-linejoin='round'><path d='M18 6 6 18M6 6l12 12'/></svg>");
|
||||||
|
mask-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='currentColor' stroke-width='2.5' stroke-linecap='round' stroke-linejoin='round'><path d='M18 6 6 18M6 6l12 12'/></svg>");
|
||||||
|
-webkit-mask-size: contain;
|
||||||
|
mask-size: contain;
|
||||||
|
-webkit-mask-repeat: no-repeat;
|
||||||
|
mask-repeat: no-repeat;
|
||||||
|
opacity: 0.85;
|
||||||
|
transition: opacity var(--t-fast);
|
||||||
|
}
|
||||||
|
.search input::-webkit-search-cancel-button:hover {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
.search kbd { position: absolute; right: 8px; }
|
.search kbd { position: absolute; right: 8px; }
|
||||||
.topbar-actions { display: flex; align-items: center; gap: 8px; margin-left: auto; }
|
.topbar-actions { display: flex; align-items: center; gap: 8px; margin-left: auto; }
|
||||||
|
|
||||||
@@ -700,6 +722,26 @@ input[type="range"]::-webkit-slider-thumb {
|
|||||||
|
|
||||||
.entry-grid.is-list .entry-head { display: contents; }
|
.entry-grid.is-list .entry-head { display: contents; }
|
||||||
.entry-grid.is-list .entry-pw-row { display: contents; }
|
.entry-grid.is-list .entry-pw-row { display: contents; }
|
||||||
|
.entry-grid.is-list .entry-note-row { display: contents; }
|
||||||
|
.entry-grid.is-list .entry-note-placeholder {
|
||||||
|
order: 3;
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-dim);
|
||||||
|
font-style: italic;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
padding: 4px 8px;
|
||||||
|
background: var(--bg);
|
||||||
|
border-radius: 6px;
|
||||||
|
border: 1px solid var(--border-soft);
|
||||||
|
}
|
||||||
|
.entry-grid.is-list .entry-note-row .icon-btn {
|
||||||
|
order: 4;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.entry-grid.is-list .entry-avatar {
|
.entry-grid.is-list .entry-avatar {
|
||||||
order: 1;
|
order: 1;
|
||||||
@@ -1258,6 +1300,251 @@ input[type="range"]::-webkit-slider-thumb {
|
|||||||
display: flex; flex-direction: column; gap: 6px;
|
display: flex; flex-direction: column; gap: 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ---- Password history modal --------------------- */
|
||||||
|
.so-history-wrap {
|
||||||
|
margin-top: -8px;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
.so-history-wrap .btn { padding: 4px 8px; font-size: 11px; }
|
||||||
|
|
||||||
|
/* Make the history body actually scrollable. The .modal-body's default
|
||||||
|
doesn't fix the height, so the panel grew to fit all rows → no scroll. */
|
||||||
|
#historyBody {
|
||||||
|
max-height: 65vh;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 12px 16px;
|
||||||
|
}
|
||||||
|
.history-loading, .history-empty {
|
||||||
|
padding: 24px;
|
||||||
|
text-align: center;
|
||||||
|
color: var(--text-dim);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
.history-list {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0; padding: 0;
|
||||||
|
display: flex; flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
.history-row {
|
||||||
|
background: var(--bg);
|
||||||
|
border: 1px solid var(--border-soft);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
padding: 10px 12px;
|
||||||
|
display: flex; flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
.history-meta {
|
||||||
|
display: flex; align-items: center; gap: 8px;
|
||||||
|
}
|
||||||
|
.history-date {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-dim);
|
||||||
|
font-family: 'JetBrains Mono', ui-monospace, monospace;
|
||||||
|
}
|
||||||
|
.history-preview {
|
||||||
|
display: flex; align-items: flex-start; gap: 6px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.history-value {
|
||||||
|
flex: 1 1 0;
|
||||||
|
min-width: 0;
|
||||||
|
font-family: 'JetBrains Mono', ui-monospace, monospace;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text);
|
||||||
|
/* Long revealed passwords wrap to multiple lines (no ellipsis) so
|
||||||
|
the user sees the full value. break-all is needed because real
|
||||||
|
passwords have no whitespace for the browser to break on. */
|
||||||
|
white-space: normal;
|
||||||
|
word-break: break-all;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
.history-row { min-width: 0; }
|
||||||
|
.history-actions {
|
||||||
|
display: flex; justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Cheatsheet overlay --------------------------- */
|
||||||
|
.cheatsheet-panel { max-width: 720px; width: 100%; }
|
||||||
|
.cheatsheet-body {
|
||||||
|
max-height: 60vh;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 18px 22px;
|
||||||
|
}
|
||||||
|
.cheatsheet-group { margin-bottom: 18px; }
|
||||||
|
.cheatsheet-group h4 {
|
||||||
|
margin: 0 0 8px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
color: var(--text-dim);
|
||||||
|
}
|
||||||
|
.cheatsheet-list {
|
||||||
|
display: flex; flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
.cheatsheet-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 180px 1fr;
|
||||||
|
gap: 16px;
|
||||||
|
align-items: start;
|
||||||
|
padding: 4px 0;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
.cheatsheet-desc {
|
||||||
|
color: var(--text-dim);
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
.cheatsheet-keys {
|
||||||
|
display: flex; align-items: center; gap: 4px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.cheatsheet-keys kbd {
|
||||||
|
font-family: 'JetBrains Mono', ui-monospace, monospace;
|
||||||
|
font-size: 11px;
|
||||||
|
padding: 2px 7px;
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--text);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 4px;
|
||||||
|
box-shadow: 0 1px 0 var(--border-soft);
|
||||||
|
}
|
||||||
|
.cheatsheet-plus {
|
||||||
|
color: var(--text-faint);
|
||||||
|
font-size: 10px;
|
||||||
|
}
|
||||||
|
.cheatsheet-icon-chip {
|
||||||
|
display: inline-flex; align-items: center; justify-content: center;
|
||||||
|
width: 24px; height: 22px;
|
||||||
|
padding: 2px;
|
||||||
|
background: var(--bg);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 4px;
|
||||||
|
box-shadow: 0 1px 0 var(--border-soft);
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
.cheatsheet-icon-chip svg { width: 14px; height: 14px; }
|
||||||
|
|
||||||
|
/* ---- Slideover custom-fields editor ------------------ */
|
||||||
|
.so-custom-list {
|
||||||
|
display: flex; flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
margin-top: 6px;
|
||||||
|
}
|
||||||
|
.so-custom-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1.4fr auto auto auto auto;
|
||||||
|
gap: 4px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
.so-custom-row .so-input {
|
||||||
|
padding: 4px 8px;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
.so-custom-secret-toggle {
|
||||||
|
background: var(--bg);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 4px;
|
||||||
|
width: 28px; height: 28px;
|
||||||
|
cursor: pointer;
|
||||||
|
color: var(--text-dim);
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
.so-custom-secret-toggle.is-on {
|
||||||
|
background: var(--accent-soft);
|
||||||
|
color: var(--accent);
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
.so-custom-add {
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
.so-custom-add .btn { padding: 4px 10px; font-size: 12px; }
|
||||||
|
|
||||||
|
/* ---- Note cards (kind=note) -------------------------- */
|
||||||
|
.entry-note-row {
|
||||||
|
display: flex; align-items: center;
|
||||||
|
padding: 4px 0 8px;
|
||||||
|
color: var(--text-dim);
|
||||||
|
font-size: 12px;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
.entry-note-placeholder {
|
||||||
|
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Note marker in table-view "Name" cell + faint placeholder in user cell */
|
||||||
|
.kind-badge {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 1px 6px;
|
||||||
|
margin-left: 6px;
|
||||||
|
border-radius: 3px;
|
||||||
|
background: var(--accent-soft);
|
||||||
|
color: var(--accent);
|
||||||
|
font-size: 9px;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
.col-user-note {
|
||||||
|
color: var(--text-faint);
|
||||||
|
font-style: italic;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Slideover note textarea ------------------------- */
|
||||||
|
.so-note-body {
|
||||||
|
min-height: 220px;
|
||||||
|
resize: vertical;
|
||||||
|
font-family: 'JetBrains Mono', ui-monospace, monospace;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.55;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- + New dropdown ---------------------------------- */
|
||||||
|
.new-entry-wrap {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
gap: 1px;
|
||||||
|
}
|
||||||
|
.new-entry-caret {
|
||||||
|
padding-left: 6px;
|
||||||
|
padding-right: 6px;
|
||||||
|
border-radius: 0 var(--radius-sm) var(--radius-sm) 0;
|
||||||
|
}
|
||||||
|
.new-entry-wrap > #newEntryBtn {
|
||||||
|
border-radius: var(--radius-sm) 0 0 var(--radius-sm);
|
||||||
|
}
|
||||||
|
.new-entry-menu {
|
||||||
|
position: absolute;
|
||||||
|
top: calc(100% + 4px); right: 0;
|
||||||
|
background: var(--bg-elev);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
box-shadow: var(--shadow-lg);
|
||||||
|
z-index: 60;
|
||||||
|
min-width: 160px;
|
||||||
|
padding: 4px;
|
||||||
|
}
|
||||||
|
.new-entry-menu .dropdown-item {
|
||||||
|
width: 100%;
|
||||||
|
display: flex; align-items: center; gap: 10px;
|
||||||
|
padding: 8px 10px;
|
||||||
|
background: none; border: none;
|
||||||
|
color: var(--text);
|
||||||
|
font: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
border-radius: 4px;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
.new-entry-menu .dropdown-item:hover { background: var(--accent-soft); }
|
||||||
|
.new-entry-menu .dropdown-item svg { width: 14px; height: 14px; }
|
||||||
|
|
||||||
/* ---- Quick search modal (tray menu) ------------------ */
|
/* ---- Quick search modal (tray menu) ------------------ */
|
||||||
.quick-search-panel {
|
.quick-search-panel {
|
||||||
padding: 0;
|
padding: 0;
|
||||||
|
|||||||
@@ -918,6 +918,7 @@ begin
|
|||||||
'UPDATE vault_entries SET ' +
|
'UPDATE vault_entries SET ' +
|
||||||
' encrypted_password = :ep, iv = :iv, ' +
|
' encrypted_password = :ep, iv = :iv, ' +
|
||||||
' totp_secret = :ts, totp_iv = :tiv, ' +
|
' totp_secret = :ts, totp_iv = :tiv, ' +
|
||||||
|
' custom_fields = :cf, custom_fields_iv = :cfiv, ' +
|
||||||
' updated_at = CURRENT_TIMESTAMP ' +
|
' updated_at = CURRENT_TIMESTAMP ' +
|
||||||
'WHERE id = :id AND user_id = :uid';
|
'WHERE id = :id AND user_id = :uid';
|
||||||
|
|
||||||
@@ -929,6 +930,8 @@ begin
|
|||||||
LIv := LEntry.GetValue<string>('iv', '');
|
LIv := LEntry.GetValue<string>('iv', '');
|
||||||
LTotpSec := LEntry.GetValue<string>('totp_secret', '');
|
LTotpSec := LEntry.GetValue<string>('totp_secret', '');
|
||||||
LTotpIv := LEntry.GetValue<string>('totp_iv', '');
|
LTotpIv := LEntry.GetValue<string>('totp_iv', '');
|
||||||
|
var LCf := LEntry.GetValue<string>('custom_fields', '');
|
||||||
|
var LCfIv := LEntry.GetValue<string>('custom_fields_iv', '');
|
||||||
if (LEntryId <= 0) or (LEncPwd = '') or (LIv = '') then
|
if (LEntryId <= 0) or (LEncPwd = '') or (LIv = '') then
|
||||||
raise Exception.CreateFmt('Invalid entry payload at index %d', [I]);
|
raise Exception.CreateFmt('Invalid entry payload at index %d', [I]);
|
||||||
|
|
||||||
@@ -936,18 +939,31 @@ begin
|
|||||||
LQ.ParamByName('uid').AsInteger := LUserId;
|
LQ.ParamByName('uid').AsInteger := LUserId;
|
||||||
LQ.ParamByName('ep').AsString := LEncPwd;
|
LQ.ParamByName('ep').AsString := LEncPwd;
|
||||||
LQ.ParamByName('iv').AsString := LIv;
|
LQ.ParamByName('iv').AsString := LIv;
|
||||||
// TOTP fields are optional per entry — clear when empty so
|
// TOTP / custom_fields are optional per entry — clear when
|
||||||
// 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 := ftString;
|
||||||
LQ.ParamByName('tiv').DataType := ftString;
|
LQ.ParamByName('tiv').DataType := ftString;
|
||||||
if LTotpSec.IsEmpty then
|
LQ.ParamByName('cf').DataType := ftString;
|
||||||
LQ.ParamByName('ts').Clear
|
LQ.ParamByName('cfiv').DataType := ftString;
|
||||||
else
|
if LTotpSec.IsEmpty then LQ.ParamByName('ts').Clear
|
||||||
LQ.ParamByName('ts').AsString := LTotpSec;
|
else LQ.ParamByName('ts').AsString := 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').AsString := LTotpIv;
|
||||||
|
if LCf = '' then LQ.ParamByName('cf').Clear
|
||||||
|
else LQ.ParamByName('cf').AsString := LCf;
|
||||||
|
if LCfIv = '' then LQ.ParamByName('cfiv').Clear
|
||||||
|
else LQ.ParamByName('cfiv').AsString := LCfIv;
|
||||||
LQ.ExecSQL;
|
LQ.ExecSQL;
|
||||||
end;
|
end;
|
||||||
|
// Password history is encrypted with the OLD vault key — we
|
||||||
|
// don't ship the plaintext server-side to re-encrypt it under
|
||||||
|
// the new key. Drop the history rows so a future "Show history"
|
||||||
|
// doesn't surface undecryptable garbage. The user accepts this
|
||||||
|
// as a consequence of rotating their master password.
|
||||||
|
LQ.SQL.Text :=
|
||||||
|
'DELETE FROM entries_password_history WHERE user_id = :uid';
|
||||||
|
LQ.ParamByName('uid').AsInteger := LUserId;
|
||||||
|
LQ.ExecSQL;
|
||||||
finally
|
finally
|
||||||
LQ.Free;
|
LQ.Free;
|
||||||
end;
|
end;
|
||||||
|
|||||||
@@ -122,6 +122,21 @@ begin
|
|||||||
LObj.AddPair('icon_b64', TJSONNull.Create)
|
LObj.AddPair('icon_b64', TJSONNull.Create)
|
||||||
else
|
else
|
||||||
LObj.AddPair('icon_b64', LQ.FieldByName('icon_b64').AsString);
|
LObj.AddPair('icon_b64', LQ.FieldByName('icon_b64').AsString);
|
||||||
|
// Entry kind. Legacy / unset → 'login'.
|
||||||
|
var LKindVal := LQ.FieldByName('kind').AsString;
|
||||||
|
if LKindVal = '' then LKindVal := 'login';
|
||||||
|
LObj.AddPair('kind', LKindVal);
|
||||||
|
// Custom fields: opaque ciphertext + IV, treated identically to
|
||||||
|
// password / totp_secret. NULL → JSON null so the client can
|
||||||
|
// distinguish "never set" from "empty array stored".
|
||||||
|
if LQ.FieldByName('custom_fields').IsNull then
|
||||||
|
LObj.AddPair('custom_fields', TJSONNull.Create)
|
||||||
|
else
|
||||||
|
LObj.AddPair('custom_fields', LQ.FieldByName('custom_fields').AsString);
|
||||||
|
if LQ.FieldByName('custom_fields_iv').IsNull then
|
||||||
|
LObj.AddPair('custom_fields_iv', TJSONNull.Create)
|
||||||
|
else
|
||||||
|
LObj.AddPair('custom_fields_iv', LQ.FieldByName('custom_fields_iv').AsString);
|
||||||
LObj.AddPair('created_at', ISODateTimeField(LQ.FieldByName('created_at')));
|
LObj.AddPair('created_at', ISODateTimeField(LQ.FieldByName('created_at')));
|
||||||
LObj.AddPair('updated_at', ISODateTimeField(LQ.FieldByName('updated_at')));
|
LObj.AddPair('updated_at', ISODateTimeField(LQ.FieldByName('updated_at')));
|
||||||
LArr.Add(LObj);
|
LArr.Add(LObj);
|
||||||
@@ -143,7 +158,8 @@ procedure HandleCreateEntry(ARequest: TIdHTTPRequestInfo;
|
|||||||
var
|
var
|
||||||
LUserId, LNewId: Integer;
|
LUserId, LNewId: Integer;
|
||||||
LBody, LObj: TJSONObject;
|
LBody, LObj: TJSONObject;
|
||||||
LSite, LTitle, LUser, LFolder, LEnc, LIV, LTags, LNow, LTotpSec, LTotpIv: string;
|
LSite, LTitle, LUser, LFolder, LEnc, LIV, LTags, LNow, LTotpSec, LTotpIv,
|
||||||
|
LKind, LCf, LCfIv: string;
|
||||||
LQ: TFDQuery;
|
LQ: TFDQuery;
|
||||||
begin
|
begin
|
||||||
try
|
try
|
||||||
@@ -165,13 +181,24 @@ begin
|
|||||||
// TOTP secret + IV — optional. Empty string = no TOTP configured.
|
// TOTP secret + IV — optional. Empty string = no TOTP configured.
|
||||||
LTotpSec := LBody.GetValue<string>('totp_secret', '');
|
LTotpSec := LBody.GetValue<string>('totp_secret', '');
|
||||||
LTotpIv := LBody.GetValue<string>('totp_iv', '');
|
LTotpIv := LBody.GetValue<string>('totp_iv', '');
|
||||||
|
LKind := LBody.GetValue<string>('kind', 'login');
|
||||||
|
LCf := LBody.GetValue<string>('custom_fields', '');
|
||||||
|
LCfIv := LBody.GetValue<string>('custom_fields_iv', '');
|
||||||
finally
|
finally
|
||||||
LBody.Free;
|
LBody.Free;
|
||||||
end;
|
end;
|
||||||
|
|
||||||
if (LSite = '') or (LEnc = '') then
|
if (LKind <> 'login') and (LKind <> 'note') then LKind := 'login';
|
||||||
|
|
||||||
|
// 'login' entries require a site; 'note' only needs encrypted body.
|
||||||
|
if LEnc = '' then
|
||||||
begin
|
begin
|
||||||
TJSONHelper.SendError(AResponse, 400, 'Site & password required');
|
TJSONHelper.SendError(AResponse, 400, 'Content required');
|
||||||
|
Exit;
|
||||||
|
end;
|
||||||
|
if (LKind = 'login') and (LSite = '') then
|
||||||
|
begin
|
||||||
|
TJSONHelper.SendError(AResponse, 400, 'Site required');
|
||||||
Exit;
|
Exit;
|
||||||
end;
|
end;
|
||||||
|
|
||||||
@@ -185,8 +212,10 @@ 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)';
|
' created_at, updated_at) ' +
|
||||||
|
'VALUES (:uid, :s, :tt, :u, :e, :i, ''client'', :f, :t, :ts, :tiv, :k, ' +
|
||||||
|
' :cf, :cfiv, :c, :c2)';
|
||||||
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;
|
||||||
@@ -211,6 +240,11 @@ begin
|
|||||||
LQ.ParamByName('tiv').Clear
|
LQ.ParamByName('tiv').Clear
|
||||||
else
|
else
|
||||||
LQ.ParamByName('tiv').AsString := LTotpIv;
|
LQ.ParamByName('tiv').AsString := LTotpIv;
|
||||||
|
LQ.ParamByName('k').AsString := LKind;
|
||||||
|
LQ.ParamByName('cf').DataType := ftString;
|
||||||
|
LQ.ParamByName('cfiv').DataType := ftString;
|
||||||
|
if LCf = '' then LQ.ParamByName('cf').Clear else LQ.ParamByName('cf').AsString := LCf;
|
||||||
|
if LCfIv = '' then LQ.ParamByName('cfiv').Clear else LQ.ParamByName('cfiv').AsString := LCfIv;
|
||||||
LQ.ParamByName('c').AsString := LNow;
|
LQ.ParamByName('c').AsString := LNow;
|
||||||
LQ.ParamByName('c2').AsString := LNow;
|
LQ.ParamByName('c2').AsString := LNow;
|
||||||
LQ.ExecSQL;
|
LQ.ExecSQL;
|
||||||
@@ -230,6 +264,7 @@ begin
|
|||||||
LObj.AddPair('username', LUser);
|
LObj.AddPair('username', LUser);
|
||||||
LObj.AddPair('folder', LFolder);
|
LObj.AddPair('folder', LFolder);
|
||||||
LObj.AddPair('tags', LTags);
|
LObj.AddPair('tags', LTags);
|
||||||
|
LObj.AddPair('kind', LKind);
|
||||||
TJSONHelper.SendJSON(AResponse, LObj);
|
TJSONHelper.SendJSON(AResponse, LObj);
|
||||||
end;
|
end;
|
||||||
|
|
||||||
@@ -240,7 +275,8 @@ procedure HandleUpdateEntry(ARequest: TIdHTTPRequestInfo;
|
|||||||
var
|
var
|
||||||
LUserId, LId: Integer;
|
LUserId, LId: Integer;
|
||||||
LBody: TJSONObject;
|
LBody: TJSONObject;
|
||||||
LSite, LTitle, LUser, LFolder, LEnc, LIV, LTags, LNow, LTotpSec, LTotpIv: string;
|
LSite, LTitle, LUser, LFolder, LEnc, LIV, LTags, LNow, LTotpSec, LTotpIv,
|
||||||
|
LKind, LCf, LCfIv: string;
|
||||||
LQ: TFDQuery;
|
LQ: TFDQuery;
|
||||||
begin
|
begin
|
||||||
try
|
try
|
||||||
@@ -268,13 +304,23 @@ begin
|
|||||||
LTags := Trim(LBody.GetValue<string>('tags', ''));
|
LTags := Trim(LBody.GetValue<string>('tags', ''));
|
||||||
LTotpSec := LBody.GetValue<string>('totp_secret', '');
|
LTotpSec := LBody.GetValue<string>('totp_secret', '');
|
||||||
LTotpIv := LBody.GetValue<string>('totp_iv', '');
|
LTotpIv := LBody.GetValue<string>('totp_iv', '');
|
||||||
|
LKind := LBody.GetValue<string>('kind', 'login');
|
||||||
|
LCf := LBody.GetValue<string>('custom_fields', '');
|
||||||
|
LCfIv := LBody.GetValue<string>('custom_fields_iv', '');
|
||||||
finally
|
finally
|
||||||
LBody.Free;
|
LBody.Free;
|
||||||
end;
|
end;
|
||||||
|
|
||||||
if (LSite = '') or (LEnc = '') then
|
if (LKind <> 'login') and (LKind <> 'note') then LKind := 'login';
|
||||||
|
|
||||||
|
if LEnc = '' then
|
||||||
begin
|
begin
|
||||||
TJSONHelper.SendError(AResponse, 400, 'Site & password required');
|
TJSONHelper.SendError(AResponse, 400, 'Content required');
|
||||||
|
Exit;
|
||||||
|
end;
|
||||||
|
if (LKind = 'login') and (LSite = '') then
|
||||||
|
begin
|
||||||
|
TJSONHelper.SendError(AResponse, 400, 'Site required');
|
||||||
Exit;
|
Exit;
|
||||||
end;
|
end;
|
||||||
|
|
||||||
@@ -284,10 +330,36 @@ begin
|
|||||||
LQ := TFDQuery.Create(nil);
|
LQ := TFDQuery.Create(nil);
|
||||||
try
|
try
|
||||||
LQ.Connection := DB.Connection;
|
LQ.Connection := DB.Connection;
|
||||||
|
// Insert pre-update ciphertext into history ONLY when it actually
|
||||||
|
// changed (JS reuses originalEncrypted bit-for-bit otherwise).
|
||||||
|
LQ.SQL.Text :=
|
||||||
|
'INSERT INTO entries_password_history ' +
|
||||||
|
' (entry_id, user_id, encrypted_password, iv, kind, changed_at) ' +
|
||||||
|
'SELECT id, user_id, encrypted_password, iv, ' +
|
||||||
|
' COALESCE(NULLIF(kind, ''''), ''login''), :now ' +
|
||||||
|
'FROM vault_entries ' +
|
||||||
|
'WHERE id = :id AND user_id = :uid ' +
|
||||||
|
' AND encrypted_password <> :newenc';
|
||||||
|
LQ.ParamByName('now').AsString := LNow;
|
||||||
|
LQ.ParamByName('id').AsInteger := LId;
|
||||||
|
LQ.ParamByName('uid').AsInteger := LUserId;
|
||||||
|
LQ.ParamByName('newenc').AsString := LEnc;
|
||||||
|
LQ.ExecSQL;
|
||||||
|
// Cap to last 20 versions.
|
||||||
|
LQ.SQL.Text :=
|
||||||
|
'DELETE FROM entries_password_history WHERE id IN (' +
|
||||||
|
' SELECT id FROM entries_password_history ' +
|
||||||
|
' WHERE entry_id = :id ' +
|
||||||
|
' ORDER BY changed_at DESC ' +
|
||||||
|
' LIMIT -1 OFFSET 20)';
|
||||||
|
LQ.ParamByName('id').AsInteger := LId;
|
||||||
|
LQ.ExecSQL;
|
||||||
|
|
||||||
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, ' +
|
||||||
' folder=:f, tags=:t, totp_secret=:ts, totp_iv=:tiv, ' +
|
' folder=:f, tags=:t, totp_secret=:ts, totp_iv=:tiv, kind=:k, ' +
|
||||||
|
' custom_fields=:cf, custom_fields_iv=:cfiv, ' +
|
||||||
' updated_at=:c ' +
|
' updated_at=:c ' +
|
||||||
'WHERE id=:id AND user_id=:uid';
|
'WHERE id=:id AND user_id=:uid';
|
||||||
LQ.ParamByName('s').AsString := LSite;
|
LQ.ParamByName('s').AsString := LSite;
|
||||||
@@ -311,6 +383,11 @@ begin
|
|||||||
LQ.ParamByName('tiv').Clear
|
LQ.ParamByName('tiv').Clear
|
||||||
else
|
else
|
||||||
LQ.ParamByName('tiv').AsString := LTotpIv;
|
LQ.ParamByName('tiv').AsString := LTotpIv;
|
||||||
|
LQ.ParamByName('k').AsString := LKind;
|
||||||
|
LQ.ParamByName('cf').DataType := ftString;
|
||||||
|
LQ.ParamByName('cfiv').DataType := ftString;
|
||||||
|
if LCf = '' then LQ.ParamByName('cf').Clear else LQ.ParamByName('cf').AsString := LCf;
|
||||||
|
if LCfIv = '' then LQ.ParamByName('cfiv').Clear else LQ.ParamByName('cfiv').AsString := LCfIv;
|
||||||
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;
|
||||||
@@ -505,7 +582,10 @@ begin
|
|||||||
|
|
||||||
// Soft cap to prevent a misbehaving fetcher from ballooning the DB.
|
// Soft cap to prevent a misbehaving fetcher from ballooning the DB.
|
||||||
// 32x32 PNG favicons rarely exceed 4 KB; 64 KB leaves room for SVG / 64x64.
|
// 32x32 PNG favicons rarely exceed 4 KB; 64 KB leaves room for SVG / 64x64.
|
||||||
if Length(LIcon) > 65536 then
|
// Soft cap. Most favicons are < 10 KB; bumped to 256 KB because DDG
|
||||||
|
// occasionally serves the brand's full-resolution PNG (deepseek.com
|
||||||
|
// came back at 200+ KB) and we want those to be cacheable too.
|
||||||
|
if Length(LIcon) > 262144 then
|
||||||
begin
|
begin
|
||||||
TJSONHelper.SendError(AResponse, 413, 'Icon too large');
|
TJSONHelper.SendError(AResponse, 413, 'Icon too large');
|
||||||
Exit;
|
Exit;
|
||||||
@@ -571,6 +651,118 @@ begin
|
|||||||
TJSONHelper.SendOK(AResponse, 'Icons cleared');
|
TJSONHelper.SendOK(AResponse, 'Icons cleared');
|
||||||
end;
|
end;
|
||||||
|
|
||||||
|
// ===== GET /entries/{id}/history =============================================
|
||||||
|
// Returns up to 20 prior versions of one entry's encrypted_password+iv.
|
||||||
|
// The client decrypts with the current vault key (rotation re-encrypts the
|
||||||
|
// whole history table, see HandleChangeMasterPassword in PM.Handler.Auth).
|
||||||
|
procedure HandleGetEntryHistory(ARequest: TIdHTTPRequestInfo;
|
||||||
|
AResponse: TIdHTTPResponseInfo; const AParams: TArray<string>);
|
||||||
|
var
|
||||||
|
LUserId, LId: Integer;
|
||||||
|
LQ: TFDQuery;
|
||||||
|
LArr: TJSONArray;
|
||||||
|
LObj: TJSONObject;
|
||||||
|
begin
|
||||||
|
try
|
||||||
|
LUserId := Authenticate(ARequest, AResponse);
|
||||||
|
except
|
||||||
|
on ESessionRejected do Exit;
|
||||||
|
end;
|
||||||
|
|
||||||
|
LId := StrToIntDef(AParams[0], 0);
|
||||||
|
if LId = 0 then
|
||||||
|
begin
|
||||||
|
TJSONHelper.SendError(AResponse, 400, 'Invalid id');
|
||||||
|
Exit;
|
||||||
|
end;
|
||||||
|
|
||||||
|
LArr := TJSONArray.Create;
|
||||||
|
DB.Lock;
|
||||||
|
try
|
||||||
|
LQ := TFDQuery.Create(nil);
|
||||||
|
try
|
||||||
|
LQ.Connection := DB.Connection;
|
||||||
|
LQ.SQL.Text :=
|
||||||
|
'SELECT id, encrypted_password, iv, kind, changed_at ' +
|
||||||
|
'FROM entries_password_history ' +
|
||||||
|
'WHERE entry_id = :id AND user_id = :uid ' +
|
||||||
|
'ORDER BY changed_at DESC';
|
||||||
|
LQ.ParamByName('id').AsInteger := LId;
|
||||||
|
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('encrypted_password', LQ.FieldByName('encrypted_password').AsString);
|
||||||
|
LObj.AddPair('iv', LQ.FieldByName('iv').AsString);
|
||||||
|
LObj.AddPair('kind', LQ.FieldByName('kind').AsString);
|
||||||
|
LObj.AddPair('changed_at', ISODateTimeField(LQ.FieldByName('changed_at')));
|
||||||
|
LArr.Add(LObj);
|
||||||
|
LQ.Next;
|
||||||
|
end;
|
||||||
|
finally
|
||||||
|
LQ.Free;
|
||||||
|
end;
|
||||||
|
finally
|
||||||
|
DB.Unlock;
|
||||||
|
end;
|
||||||
|
TJSONHelper.SendJSON(AResponse, LArr);
|
||||||
|
end;
|
||||||
|
|
||||||
|
// ===== DELETE /entries/trash/old?days=N ======================================
|
||||||
|
// Permanently deletes trashed entries whose deleted_at is older than N days.
|
||||||
|
// Driven by the user's "Auto-purge trash" setting; called from JS at login.
|
||||||
|
procedure HandleAutoPurgeTrash(ARequest: TIdHTTPRequestInfo;
|
||||||
|
AResponse: TIdHTTPResponseInfo; const AParams: TArray<string>);
|
||||||
|
var
|
||||||
|
LUserId, LDays, LPurged: Integer;
|
||||||
|
LQ: TFDQuery;
|
||||||
|
LObj: TJSONObject;
|
||||||
|
begin
|
||||||
|
try
|
||||||
|
LUserId := Authenticate(ARequest, AResponse);
|
||||||
|
RequireCSRF(ARequest, AResponse, LUserId);
|
||||||
|
except
|
||||||
|
on ESessionRejected do Exit;
|
||||||
|
end;
|
||||||
|
|
||||||
|
LDays := StrToIntDef(GetQueryParam(ARequest, 'days', '0'), 0);
|
||||||
|
if (LDays <= 0) or (LDays > 3650) then
|
||||||
|
begin
|
||||||
|
TJSONHelper.SendError(AResponse, 400, 'Invalid days');
|
||||||
|
Exit;
|
||||||
|
end;
|
||||||
|
|
||||||
|
DB.Lock;
|
||||||
|
try
|
||||||
|
LQ := TFDQuery.Create(nil);
|
||||||
|
try
|
||||||
|
LQ.Connection := DB.Connection;
|
||||||
|
LQ.SQL.Text :=
|
||||||
|
'DELETE FROM vault_entries ' +
|
||||||
|
'WHERE user_id = :uid AND deleted = 1 ' +
|
||||||
|
' 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;
|
||||||
|
LPurged := LQ.RowsAffected;
|
||||||
|
finally
|
||||||
|
LQ.Free;
|
||||||
|
end;
|
||||||
|
finally
|
||||||
|
DB.Unlock;
|
||||||
|
end;
|
||||||
|
|
||||||
|
if LPurged > 0 then
|
||||||
|
LogAudit(LUserId, Format('auto_purge_trash %d entries (> %d days)',
|
||||||
|
[LPurged, LDays]), GetClientIP(ARequest));
|
||||||
|
LObj := TJSONObject.Create;
|
||||||
|
LObj.AddPair('purged', TJSONNumber.Create(LPurged));
|
||||||
|
TJSONHelper.SendJSON(AResponse, LObj);
|
||||||
|
end;
|
||||||
|
|
||||||
// ===== DELETE /entries/trash/empty ===========================================
|
// ===== DELETE /entries/trash/empty ===========================================
|
||||||
|
|
||||||
procedure HandleEmptyTrash(ARequest: TIdHTTPRequestInfo;
|
procedure HandleEmptyTrash(ARequest: TIdHTTPRequestInfo;
|
||||||
@@ -771,11 +963,13 @@ initialization
|
|||||||
// /entries/trash/empty must be registered BEFORE /entries/{id} to win the regex match.
|
// /entries/trash/empty must be registered BEFORE /entries/{id} to win the regex match.
|
||||||
// Same logic for /entries/bulk-import — register before the catch-all /entries/{id}.
|
// Same logic for /entries/bulk-import — register before the catch-all /entries/{id}.
|
||||||
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/icons/all', HandleClearAllIcons);
|
Router.Register('DELETE', '/entries/icons/all', HandleClearAllIcons);
|
||||||
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);
|
||||||
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/count', HandleEntriesCount);
|
Router.Register('GET', '/entries/count', HandleEntriesCount);
|
||||||
Router.Register('GET', '/entries', HandleGetEntries);
|
Router.Register('GET', '/entries', HandleGetEntries);
|
||||||
Router.Register('POST', '/entries', HandleCreateEntry);
|
Router.Register('POST', '/entries', HandleCreateEntry);
|
||||||
|
|||||||
@@ -189,6 +189,25 @@ begin
|
|||||||
' created_at DATETIME DEFAULT CURRENT_TIMESTAMP,' +
|
' created_at DATETIME DEFAULT CURRENT_TIMESTAMP,' +
|
||||||
' FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE' +
|
' FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE' +
|
||||||
')');
|
')');
|
||||||
|
// Password history — keeps the last N versions of each entry's
|
||||||
|
// encrypted_password + iv. Populated by HandleUpdateEntry before each
|
||||||
|
// PUT overwrites the row; pruned to 20 entries per row after each insert.
|
||||||
|
// kind mirrors vault_entries.kind so notes can be restored too.
|
||||||
|
FConn.ExecSQL(
|
||||||
|
'CREATE TABLE IF NOT EXISTS entries_password_history (' +
|
||||||
|
' id INTEGER PRIMARY KEY AUTOINCREMENT,' +
|
||||||
|
' entry_id INTEGER NOT NULL,' +
|
||||||
|
' user_id INTEGER NOT NULL,' +
|
||||||
|
' encrypted_password TEXT NOT NULL,' +
|
||||||
|
' iv TEXT NOT NULL,' +
|
||||||
|
' kind TEXT NOT NULL DEFAULT ''login'',' +
|
||||||
|
' changed_at DATETIME DEFAULT CURRENT_TIMESTAMP,' +
|
||||||
|
' FOREIGN KEY (entry_id) REFERENCES vault_entries(id) ON DELETE CASCADE,' +
|
||||||
|
' FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE' +
|
||||||
|
')');
|
||||||
|
FConn.ExecSQL(
|
||||||
|
'CREATE INDEX IF NOT EXISTS idx_history_entry ' +
|
||||||
|
' ON entries_password_history(entry_id, changed_at DESC)');
|
||||||
end;
|
end;
|
||||||
|
|
||||||
function TPMDatabase.ColumnExists(const ATable, AColumn: string): Boolean;
|
function TPMDatabase.ColumnExists(const ATable, AColumn: string): Boolean;
|
||||||
@@ -241,6 +260,17 @@ begin
|
|||||||
// sees the plaintext secret. NULL = no TOTP configured for this entry.
|
// sees the plaintext secret. NULL = no TOTP configured for this entry.
|
||||||
AddColumnIfMissing('vault_entries', 'totp_secret', 'TEXT');
|
AddColumnIfMissing('vault_entries', 'totp_secret', 'TEXT');
|
||||||
AddColumnIfMissing('vault_entries', 'totp_iv', 'TEXT');
|
AddColumnIfMissing('vault_entries', 'totp_iv', 'TEXT');
|
||||||
|
// Entry kind: 'login' (default — site/user/encrypted_password/iv/totp)
|
||||||
|
// or 'note' (free-text secure note — body stored in encrypted_password
|
||||||
|
// + iv, site/username/totp_* unused). Legacy rows default to 'login'.
|
||||||
|
AddColumnIfMissing('vault_entries', 'kind', 'TEXT DEFAULT ''login''');
|
||||||
|
// Custom fields: opaque encrypted JSON array of
|
||||||
|
// [{label, value, is_secret}, ...]
|
||||||
|
// Same crypto pipeline as encrypted_password (AES-GCM with the vault
|
||||||
|
// key). NULL = no custom fields configured. The server treats both
|
||||||
|
// columns as opaque ciphertext + IV.
|
||||||
|
AddColumnIfMissing('vault_entries', 'custom_fields', 'TEXT');
|
||||||
|
AddColumnIfMissing('vault_entries', 'custom_fields_iv', 'TEXT');
|
||||||
// Cached favicon as a base64 data URI (e.g. "data:image/png;base64,...").
|
// Cached favicon as a base64 data URI (e.g. "data:image/png;base64,...").
|
||||||
// Fetched on demand by the Delphi favicon proxy when the user opts in.
|
// Fetched on demand by the Delphi favicon proxy when the user opts in.
|
||||||
// NULL = no icon cached → JS falls back to the first-letter avatar.
|
// NULL = no icon cached → JS falls back to the first-letter avatar.
|
||||||
|
|||||||
@@ -39,7 +39,8 @@ uses
|
|||||||
|
|
||||||
const
|
const
|
||||||
ICON_URL_TEMPLATE = 'https://icons.duckduckgo.com/ip3/%s.ico';
|
ICON_URL_TEMPLATE = 'https://icons.duckduckgo.com/ip3/%s.ico';
|
||||||
MAX_ICON_BYTES = 65536; // 64 KB cap (matches handler's SetEntryIcon limit)
|
MAX_ICON_BYTES = 262144; // 256 KB cap (DDG sometimes serves full-res
|
||||||
|
// assets; matches handler + JS upload limits)
|
||||||
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.
|
||||||
@@ -153,48 +154,51 @@ begin
|
|||||||
Exit;
|
Exit;
|
||||||
end;
|
end;
|
||||||
|
|
||||||
// Strategy: prefer DDG (privacy-centralising) but fall back to the
|
// Strategy: prefer the SLD (brand domain) when the host has a subdomain,
|
||||||
// site's own /favicon.ico for domains DDG doesn't index (self-hosted
|
// because DDG often returns a generic placeholder for chat.X.com / app.X.com
|
||||||
// tools, niche services, fresh subdomains, etc.). The user already
|
// / etc. (passes our byte threshold but looks wrong) while having the real
|
||||||
// opted into "fetch icons" so the DNS leak to one extra host they
|
// brand icon under X.com. For bare 2-label hosts we go straight to step 2.
|
||||||
// already visit is an acceptable trade-off for actually getting an icon.
|
|
||||||
|
|
||||||
LSld := ExtractSLD(LHost);
|
LSld := ExtractSLD(LHost);
|
||||||
LOk := False;
|
LOk := False;
|
||||||
|
|
||||||
// 1) DDG full host.
|
// 1) DDG SLD first when host has a subdomain (e.g. chat.deepseek.com →
|
||||||
LUrl := Format(ICON_URL_TEMPLATE, [LHost]);
|
// try deepseek.com.ico first). Skipped for bare hosts.
|
||||||
|
if LSld <> '' then
|
||||||
|
begin
|
||||||
|
LUrl := Format(ICON_URL_TEMPLATE, [LSld]);
|
||||||
if FetchOneIcon(LUrl, LBytes) then
|
if FetchOneIcon(LUrl, LBytes) then
|
||||||
begin
|
begin
|
||||||
if Length(LBytes) >= MIN_REAL_ICON_BYTES then
|
if Length(LBytes) >= MIN_REAL_ICON_BYTES then
|
||||||
begin
|
begin
|
||||||
LOk := True;
|
LOk := True;
|
||||||
Trace(Format('OK step1 DDG host: %s (%d bytes)', [LUrl, Length(LBytes)]));
|
Trace(Format('OK step1 DDG sld: %s (%d bytes)', [LUrl, Length(LBytes)]));
|
||||||
end
|
end
|
||||||
else
|
else
|
||||||
Trace(Format('skip step1 DDG host: %s only %d bytes (< %d)',
|
Trace(Format('skip step1 DDG sld: %s only %d bytes', [LUrl, Length(LBytes)]));
|
||||||
[LUrl, Length(LBytes), MIN_REAL_ICON_BYTES]));
|
|
||||||
end
|
end
|
||||||
else
|
else
|
||||||
Trace('fail step1 DDG host: ' + LUrl);
|
Trace('fail step1 DDG sld: ' + LUrl);
|
||||||
|
end;
|
||||||
|
|
||||||
// 2) DDG SLD (e.g. "deepseek.com" when "chat.deepseek.com" 404s).
|
// 2) DDG full host as fallback (covers brands whose subdomain has its own
|
||||||
if (not LOk) and (LSld <> '') then
|
// distinct icon, OR plain hosts like github.com that have no SLD step).
|
||||||
|
if not LOk then
|
||||||
begin
|
begin
|
||||||
var LTry: TBytes;
|
var LTry: TBytes;
|
||||||
LUrl := Format(ICON_URL_TEMPLATE, [LSld]);
|
LUrl := Format(ICON_URL_TEMPLATE, [LHost]);
|
||||||
if FetchOneIcon(LUrl, LTry) then
|
if FetchOneIcon(LUrl, LTry) then
|
||||||
begin
|
begin
|
||||||
if Length(LTry) >= MIN_REAL_ICON_BYTES then
|
if Length(LTry) >= MIN_REAL_ICON_BYTES then
|
||||||
begin
|
begin
|
||||||
LBytes := LTry; LOk := True;
|
LBytes := LTry; LOk := True;
|
||||||
Trace(Format('OK step2 DDG sld: %s (%d bytes)', [LUrl, Length(LTry)]));
|
Trace(Format('OK step2 DDG host: %s (%d bytes)', [LUrl, Length(LTry)]));
|
||||||
end
|
end
|
||||||
else
|
else
|
||||||
Trace(Format('skip step2 DDG sld: %s only %d bytes', [LUrl, Length(LTry)]));
|
Trace(Format('skip step2 DDG host: %s only %d bytes', [LUrl, Length(LTry)]));
|
||||||
end
|
end
|
||||||
else
|
else
|
||||||
Trace('fail step2 DDG sld: ' + LUrl);
|
Trace('fail step2 DDG host: ' + LUrl);
|
||||||
end;
|
end;
|
||||||
|
|
||||||
if (not LOk) or (Length(LBytes) = 0) then
|
if (not LOk) or (Length(LBytes) = 0) then
|
||||||
|
|||||||
@@ -16,7 +16,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.StrUtils, System.Generics.Collections,
|
||||||
Winapi.Windows,
|
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,
|
||||||
FMX.Dialogs, FMX.DialogService,
|
FMX.Dialogs, FMX.DialogService,
|
||||||
@@ -742,6 +742,22 @@ begin
|
|||||||
else if ACmd = 'app/theme' then
|
else if ACmd = 'app/theme' then
|
||||||
FBridge.ApplyTitleBarTheme(GetParam('mode') = 'dark')
|
FBridge.ApplyTitleBarTheme(GetParam('mode') = 'dark')
|
||||||
|
|
||||||
|
// 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
|
||||||
|
// would invoke arbitrary Windows applications.
|
||||||
|
else if ACmd = 'app/open-url' then
|
||||||
|
begin
|
||||||
|
var LUrl := GetParam('url');
|
||||||
|
if (LUrl <> '') and
|
||||||
|
(LUrl.ToLower.StartsWith('http://') or LUrl.ToLower.StartsWith('https://')) then
|
||||||
|
begin
|
||||||
|
ShellExecute(0, 'open', PChar(LUrl), nil, nil, 1); // SW_SHOWNORMAL = 1
|
||||||
|
LogLine('Opened URL: ' + LUrl);
|
||||||
|
end
|
||||||
|
else
|
||||||
|
LogLine('Refused to open non-http(s) URL: ' + LUrl);
|
||||||
|
end
|
||||||
|
|
||||||
// ---- Device-bound prefs (DPAPI key/value) ----------------------------
|
// ---- Device-bound prefs (DPAPI key/value) ----------------------------
|
||||||
// Used for prefs that must survive the ephemeral-port reset of the
|
// Used for prefs that must survive the ephemeral-port reset of the
|
||||||
// WebView2 localStorage (rememberedUsername, etc.).
|
// WebView2 localStorage (rememberedUsername, etc.).
|
||||||
|
|||||||
Binary file not shown.
+76
@@ -168,6 +168,11 @@
|
|||||||
<span>Favorites</span>
|
<span>Favorites</span>
|
||||||
<span class="nav-count" id="countFav">0</span>
|
<span class="nav-count" id="countFav">0</span>
|
||||||
</button>
|
</button>
|
||||||
|
<button class="nav-item" data-view="notes">
|
||||||
|
<svg><use href="#i-edit"/></svg>
|
||||||
|
<span>Notes</span>
|
||||||
|
<span class="nav-count" id="countNotes">0</span>
|
||||||
|
</button>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<div class="sidebar-section" data-section="folders">
|
<div class="sidebar-section" data-section="folders">
|
||||||
@@ -263,14 +268,33 @@
|
|||||||
<svg><use href="#i-table"/></svg>
|
<svg><use href="#i-table"/></svg>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
<button class="icon-btn" id="cheatsheetBtn" title="Keyboard shortcuts (?)">
|
||||||
|
<span style="font-weight:700;font-size:14px">?</span>
|
||||||
|
</button>
|
||||||
<button class="icon-btn" id="themeBtn" title="Toggle theme">
|
<button class="icon-btn" id="themeBtn" title="Toggle theme">
|
||||||
<svg class="theme-icon theme-icon-dark"><use href="#i-sun"/></svg>
|
<svg class="theme-icon theme-icon-dark"><use href="#i-sun"/></svg>
|
||||||
<svg class="theme-icon theme-icon-light"><use href="#i-moon"/></svg>
|
<svg class="theme-icon theme-icon-light"><use href="#i-moon"/></svg>
|
||||||
</button>
|
</button>
|
||||||
|
<div class="new-entry-wrap">
|
||||||
<button class="btn btn-primary btn-sm" id="newEntryBtn">
|
<button class="btn btn-primary btn-sm" id="newEntryBtn">
|
||||||
<svg><use href="#i-plus"/></svg>
|
<svg><use href="#i-plus"/></svg>
|
||||||
New
|
New
|
||||||
</button>
|
</button>
|
||||||
|
<button class="btn btn-primary btn-sm new-entry-caret" id="newEntryCaretBtn"
|
||||||
|
title="Choose entry type">
|
||||||
|
<svg><use href="#i-chevron-down"/></svg>
|
||||||
|
</button>
|
||||||
|
<div class="new-entry-menu is-hidden" id="newEntryMenu">
|
||||||
|
<button class="dropdown-item" data-new-kind="login">
|
||||||
|
<svg><use href="#i-key"/></svg>
|
||||||
|
<span>New login</span>
|
||||||
|
</button>
|
||||||
|
<button class="dropdown-item" data-new-kind="note">
|
||||||
|
<svg><use href="#i-edit"/></svg>
|
||||||
|
<span>New note</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div class="user-menu">
|
<div class="user-menu">
|
||||||
<button class="user-chip" id="userChip">
|
<button class="user-chip" id="userChip">
|
||||||
<span id="userName">user</span>
|
<span id="userName">user</span>
|
||||||
@@ -416,6 +440,22 @@
|
|||||||
<span class="toggle-slider"></span>
|
<span class="toggle-slider"></span>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="setting-row">
|
||||||
|
<span>
|
||||||
|
Auto-purge trash after
|
||||||
|
<small class="setting-hint">
|
||||||
|
Permanently delete entries that have been in
|
||||||
|
the trash for longer than this. Runs at every
|
||||||
|
unlock.
|
||||||
|
</small>
|
||||||
|
</span>
|
||||||
|
<select id="settingTrashPurge">
|
||||||
|
<option value="0">Never</option>
|
||||||
|
<option value="7">7 days</option>
|
||||||
|
<option value="30">30 days</option>
|
||||||
|
<option value="90">90 days</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
<div class="setting-row">
|
<div class="setting-row">
|
||||||
<span>
|
<span>
|
||||||
Check passwords against breach database (HIBP)
|
Check passwords against breach database (HIBP)
|
||||||
@@ -723,6 +763,42 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- ============================================================ -->
|
||||||
|
<!-- MODAL: Password history — list previous versions + revert -->
|
||||||
|
<!-- ============================================================ -->
|
||||||
|
<div id="historyModal" class="modal is-hidden" role="dialog" aria-modal="true">
|
||||||
|
<div class="modal-backdrop" data-close></div>
|
||||||
|
<div class="modal-panel modal-panel-sm">
|
||||||
|
<header class="modal-header">
|
||||||
|
<h3 id="historyTitle">Previous versions</h3>
|
||||||
|
<button class="icon-btn" data-close><svg><use href="#i-x"/></svg></button>
|
||||||
|
</header>
|
||||||
|
<div class="modal-body" id="historyBody"></div>
|
||||||
|
<footer class="modal-footer">
|
||||||
|
<span style="font-size:11px;color:var(--text-faint);line-height:1.4">
|
||||||
|
Up to 20 versions kept. Master-password change clears the history.
|
||||||
|
</span>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ============================================================ -->
|
||||||
|
<!-- CHEATSHEET overlay — press '?' to discover hotkeys -->
|
||||||
|
<!-- ============================================================ -->
|
||||||
|
<div id="cheatsheetModal" class="modal is-hidden" role="dialog" aria-modal="true">
|
||||||
|
<div class="modal-backdrop" data-close></div>
|
||||||
|
<div class="modal-panel cheatsheet-panel">
|
||||||
|
<header class="modal-header">
|
||||||
|
<h3>Keyboard shortcuts</h3>
|
||||||
|
<button class="icon-btn" data-close><svg><use href="#i-x"/></svg></button>
|
||||||
|
</header>
|
||||||
|
<div class="modal-body cheatsheet-body" id="cheatsheetBody"></div>
|
||||||
|
<footer class="modal-footer">
|
||||||
|
<span style="font-size:11px;color:var(--text-faint)">Press <kbd>?</kbd> anytime to reopen.</span>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- ============================================================ -->
|
<!-- ============================================================ -->
|
||||||
<!-- MODAL: Quick search (tray menu → fast password copy) -->
|
<!-- MODAL: Quick search (tray menu → fast password copy) -->
|
||||||
<!-- ============================================================ -->
|
<!-- ============================================================ -->
|
||||||
|
|||||||
Reference in New Issue
Block a user