feat: native save + auto-backup + folder customization + attachments + UX bundle

- File: native Save As dialog via Bridge.saveFile (replaces WebView2
  browser download popup) for encrypted JSON + CSV exports.
- Auto-backup: silent periodic encrypted JSON to a chosen folder,
  user-set interval + retention, separate DPAPI-stored password, runs
  5s after unlock if due. New file/* bridge cmds (folder/pick,
  file/write, file/listMatch, file/delete).
- Folders: per-folder color + icon (8-swatch palette, 8 icon presets),
  drag-reorder via HTML5 DnD with insert-line indicators, edit pencil
  on hover. New POST /folders/reorder + PUT /folders/{name}. Folder
  chip on cards inherits custom icon + color.
- Recently used: vault_entries.accessed_at + POST /entries/{id}/touch
  (debounced 2s), sidebar Tools entry showing top-10 by accessed_at.
- Encrypted attachments: per-entry file storage (5MB cap), AES-GCM
  with vault key, native Save As download, paperclip upload in
  slideover. New entry_attachments table + PM.Handler.Attachments.
- Password expiry: vault_entries.password_changed_at (conditional bump
  via SQL CASE only when ciphertext differs), passwordExpiryDays
  setting, "Aged" badge on cards + matching Filters chip.
- Recovery: Print button on generated code modal (A4 printable sheet
  via @media print, code in 32px monospace + instructions).
- Audit log viewer (sidebar Tools, GET /audit with pagination cursor).
- Plaintext CSV export + Filters dropdown with 9 predicates.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-06-21 23:12:06 +01:00
parent 63fac5b3b7
commit fa7ea191be
13 changed files with 3927 additions and 1378 deletions
+52
View File
@@ -384,6 +384,58 @@ Historiquement on a oublié `kind` dans `duplicateEntry` (bug "Site required"
sur duplique-note), et `custom_fields` dans la rotation + duplicate. Cette sur duplique-note), et `custom_fields` dans la rotation + duplicate. Cette
liste évite de répéter ces erreurs. liste évite de répéter ces erreurs.
## Encrypted attachments
Per-entry file storage (PDFs, images of backup codes, etc.) encrypted
client-side with the vault key.
- **Table** : `entry_attachments` (id, user_id, entry_id, filename, mime,
size_bytes, encrypted_blob TEXT base64, iv, created_at). FK cascade on
user + entry delete.
- **Endpoints** ([PM.Handler.Attachments.pas](delphi-backend/Handlers/PM.Handler.Attachments.pas)) :
- `GET /entries/{id}/attachments` → metadata array (no blob)
- `POST /entries/{id}/attachments` → full upload, ciphertext capped at
~10 MB base64 (~7.5 MB raw)
- `GET /attachments/{id}` → metadata + blob (fetched on Download click)
- `DELETE /attachments/{id}` → permanent (no trash)
- **Crypto** : `encryptBlobBytes(uint8)` / `decryptBlobBytes(b64, iv)`
use the same AES-GCM 256 + `state.cryptoKey` as passwords. Filename,
mime, size are stored in cleartext (leaked metadata) so the listing
doesn't have to decrypt all rows on slideover-open.
- **Cap** : 5 MB raw client-side check, ~10 MB base64 server-side.
- **Master pw rotation** : attachments are NOT re-encrypted on rotation
→ they become inaccessible. Known limitation, document for users who
rotate master pw (rotate before adding heavy attachments).
- **UI** : `soAttachmentsField(entryId)` rendered in slideover (existing
entries only, never on new). Upload via hidden file input + paperclip
button. Download reuses `Bridge.saveFile` (native Save As dialog).
## Auto-backup (encrypted JSON, silent)
Silent periodic encrypted export. Triggered on unlock (5s defer) if
`autoBackupInterval` days écoulés depuis `autoBackupLast`. Bouton
"Backup now" dans Settings pour trigger manuel.
- **State** (DPAPI prefs via `Bridge.getPref/setPref`) — survivent au
port-change : `autoBackupEnabled`, `autoBackupDir`, `autoBackupInterval`,
`autoBackupKeep`, `autoBackupLast` (ISO), `autoBackupPwd` (prompté une
fois à l'enable).
- **Pwd** : indépendant du master pw, choisi par l'user au premier toggle.
Stocké DPAPI, utilisé silencieusement à chaque run. User le retape pour
restaurer via l'import standard. **Pourquoi pas dérivé du cryptoKey** :
master pw rotation re-génère cryptoKey → backups antérieurs deviennent
inaccessibles. Pwd séparée découple du cycle de vie de la vault key.
- **Filename** : `vault-autobackup-yyyymmdd-HHmmss.json` — sort lexical
= chronologique pour la rétention. Container = même format que
`doExport` user-driven → restore via "Import vault" classique.
- **Bridge cmds** ([UMainForm.pas](delphi-backend/UMainForm.pas)) :
- `folder/pick``SelectDirectory` FMX, callback `Bridge.onFolderPickResult(reqId, path)`
- `file/write?path=&data=<b64>` → silent write (no dialog)
- `file/listMatch?dir=&prefix=` → JSON `[{name,size,mtime}]`
- `file/delete?path=` → single delete
- **Retention** : après chaque write OK, list dir + sort name desc,
delete au-delà de `keep`. Best-effort.
## 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
+373
View File
@@ -537,6 +537,12 @@ 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);
} }
/* Folder reorder: insertion line above or below the hovered item. */
.nav-item.drop-before { box-shadow: inset 0 2px 0 0 var(--accent); }
.nav-item.drop-after { box-shadow: inset 0 -2px 0 0 var(--accent); }
.nav-item.is-dragging { opacity: 0.4; }
.nav-item[draggable="true"] { cursor: grab; }
.nav-item[draggable="true"]:active { cursor: grabbing; }
/* ---- 8. MAIN --------------------------------------------- */ /* ---- 8. MAIN --------------------------------------------- */
@@ -1055,6 +1061,12 @@ input[type="range"]::-webkit-slider-thumb {
background: var(--accent-soft); background: var(--accent-soft);
font-weight: 600; font-weight: 600;
} }
.entry-chip.is-aged {
color: #b45309;
background: rgba(245, 158, 11, 0.15);
font-weight: 600;
}
.entry-chip.is-aged svg { stroke: #b45309; }
/* TOTP live-code panel (inside slide-over Two-factor field) */ /* TOTP live-code panel (inside slide-over Two-factor field) */
.totp-panel { .totp-panel {
@@ -1464,6 +1476,183 @@ input[type="range"]::-webkit-slider-thumb {
} }
.so-custom-add .btn { padding: 4px 10px; font-size: 12px; } .so-custom-add .btn { padding: 4px 10px; font-size: 12px; }
/* ---- Audit log viewer -------------------------------- */
.entry-grid.is-audit {
display: flex;
flex-direction: column;
gap: 10px;
}
.audit-loading, .audit-empty {
color: var(--text-dim);
padding: 24px;
text-align: center;
font-size: 13px;
}
.audit-header {
display: flex; gap: 8px;
align-items: center;
}
.audit-search {
flex: 1;
padding: 6px 12px;
background: var(--bg-elev);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
color: var(--text);
font-size: 13px;
}
.audit-search:focus {
outline: none;
border-color: var(--accent);
box-shadow: 0 0 0 3px var(--accent-soft);
}
.audit-note {
margin: 0;
font-size: 11px;
color: var(--text-faint);
font-style: italic;
}
.audit-list {
display: flex;
flex-direction: column;
border: 1px solid var(--border);
border-radius: var(--radius-sm);
background: var(--bg-elev);
max-height: 70vh;
overflow-y: auto;
}
.audit-row {
display: grid;
grid-template-columns: 160px 1fr 120px;
gap: 12px;
padding: 8px 14px;
font-size: 12px;
border-bottom: 1px solid var(--border-soft);
align-items: center;
}
.audit-row:last-child { border-bottom: none; }
.audit-row:hover { background: var(--bg); }
.audit-date {
color: var(--text-dim);
font-family: 'JetBrains Mono', ui-monospace, monospace;
white-space: nowrap;
}
.audit-action {
color: var(--text);
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.audit-ip {
color: var(--text-faint);
font-family: 'JetBrains Mono', ui-monospace, monospace;
text-align: right;
}
/* ---- Filters dropdown + chips ------------------------ */
.filters-wrap { position: relative; }
.filters-count {
position: absolute;
top: -2px; right: -4px;
background: var(--accent);
color: white;
font-size: 9px;
font-weight: 700;
padding: 1px 5px;
border-radius: 8px;
line-height: 1.3;
pointer-events: none;
}
.filters-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: 240px;
padding: 6px;
}
.filters-menu-title {
padding: 4px 8px 6px;
font-size: 10px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.5px;
color: var(--text-faint);
}
.filters-menu-row {
display: flex; align-items: center; gap: 8px;
padding: 6px 8px;
cursor: pointer;
border-radius: 4px;
font-size: 13px;
color: var(--text);
}
.filters-menu-row:hover { background: var(--accent-soft); }
.filters-menu-row svg { width: 14px; height: 14px; color: var(--text-dim); }
.filters-menu-cb {
margin: 0;
accent-color: var(--accent);
cursor: pointer;
}
.filters-menu-clear {
width: 100%;
margin-top: 6px;
padding: 6px 8px;
background: none;
border: 1px solid var(--border);
border-radius: 4px;
color: var(--text-dim);
font-size: 12px;
cursor: pointer;
}
.filters-menu-clear:hover {
color: var(--text);
border-color: var(--accent);
}
.filter-chips {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 6px;
padding: 8px 0;
margin-bottom: 4px;
}
.filter-chip {
display: inline-flex; align-items: center; gap: 6px;
background: var(--accent-soft);
color: var(--accent);
border: 1px solid var(--accent);
border-radius: 14px;
padding: 3px 4px 3px 10px;
font-size: 11px;
font-weight: 600;
}
.filter-chip svg { width: 12px; height: 12px; }
.filter-chip-x {
display: inline-flex; align-items: center; justify-content: center;
width: 16px; height: 16px;
background: none;
border: none;
cursor: pointer;
color: var(--accent);
border-radius: 50%;
padding: 0;
}
.filter-chip-x:hover { background: var(--accent); color: white; }
.filter-chip-x svg { width: 10px; height: 10px; }
.filter-chips-clear {
background: none;
border: none;
color: var(--text-faint);
font-size: 11px;
cursor: pointer;
padding: 3px 8px;
text-decoration: underline;
}
.filter-chips-clear:hover { color: var(--text-dim); }
/* ---- Note cards (kind=note) -------------------------- */ /* ---- Note cards (kind=note) -------------------------- */
.entry-note-row { .entry-note-row {
display: flex; align-items: center; display: flex; align-items: center;
@@ -2168,6 +2357,140 @@ input[type="password"]::-ms-clear {
} }
.nav-item:hover .nav-count { display: none; } .nav-item:hover .nav-count { display: none; }
/* Folder edit (pencil) button — same hover-reveal as the delete X. */
.nav-item .folder-edit {
width: 20px; height: 20px;
padding: 0;
line-height: 0;
display: inline-flex; align-items: center; justify-content: center;
background: transparent; border: none;
color: var(--text-faint);
border-radius: 4px;
opacity: 0;
transition: opacity var(--t-fast), color var(--t-fast), background var(--t-fast);
cursor: pointer;
}
.nav-item .folder-edit svg { width: 13px; height: 13px; display: block; }
.nav-item:hover .folder-edit { opacity: 0.7; }
.nav-item .folder-edit:hover {
opacity: 1;
color: var(--accent);
background: var(--accent-soft);
}
/* When folder-edit is rendered before folder-delete in the DOM, push delete
to the far right so the layout stays {icon} {label} … {edit} {delete}. */
.nav-item .folder-edit + .folder-delete { margin-left: 2px; }
/* ============================================================
FOLDER CUSTOMIZATION MODAL
============================================================ */
#folderModal .form-field { margin-bottom: 14px; }
#folderModal .form-field label {
display: block;
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
color: var(--text-faint);
margin-bottom: 4px;
}
#folderModal .so-input { width: 100%; }
/* ============================================================
ATTACHMENTS list inside the slideover
============================================================ */
.so-attach-list {
margin-top: 8px;
display: flex;
flex-direction: column;
gap: 6px;
}
.so-attach-empty {
font-size: 12px;
color: var(--text-faint);
padding: 6px 2px;
font-style: italic;
}
.so-attach-row {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 10px;
background: var(--bg);
border: 1px solid var(--border);
border-radius: 6px;
}
.so-attach-info {
flex: 1;
min-width: 0;
}
.so-attach-name {
font-size: 13px;
color: var(--text);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.so-attach-meta {
font-size: 11px;
color: var(--text-faint);
margin-top: 1px;
}
.so-attach-row .icon-btn { flex-shrink: 0; }
.folder-swatch-row,
.folder-icon-row {
display: flex;
gap: 8px;
flex-wrap: wrap;
margin-top: 6px;
}
.folder-swatch {
width: 26px; height: 26px;
border-radius: 50%;
border: 2px solid transparent;
background: var(--bg-elev);
cursor: pointer;
padding: 0;
transition: transform var(--t-fast), border-color var(--t-fast);
}
.folder-swatch:hover { transform: scale(1.1); }
.folder-swatch.is-active {
border-color: var(--text);
box-shadow: 0 0 0 2px var(--bg);
}
.folder-swatch.is-default {
background:
linear-gradient(135deg, transparent 47%, var(--danger) 47% 53%, transparent 53%),
var(--bg-elev);
}
.folder-icon-btn {
width: 36px; height: 36px;
display: inline-flex; align-items: center; justify-content: center;
border: 1px solid var(--border);
background: var(--bg-elev);
border-radius: 6px;
cursor: pointer;
color: var(--text-dim);
transition: background var(--t-fast), border-color var(--t-fast), color var(--t-fast);
}
.folder-icon-btn svg { width: 18px; height: 18px; }
.folder-icon-btn:hover { background: var(--bg-elev-2); color: var(--text); }
.folder-icon-btn.is-active {
border-color: var(--accent);
color: var(--accent);
background: var(--accent-soft);
}
.folder-preview {
display: flex; align-items: center; gap: 10px;
margin: 16px 0 4px;
padding: 12px 14px;
background: var(--bg-elev);
border-radius: 6px;
color: var(--text);
font-weight: 500;
}
.folder-preview svg { width: 18px; height: 18px; }
/* Remember username checkbox row on auth screen. */ /* Remember username checkbox row on auth screen. */
.remember-row { .remember-row {
display: flex; align-items: center; gap: 8px; display: flex; align-items: center; gap: 8px;
@@ -2241,3 +2564,53 @@ input[type="password"]::-ms-clear {
font-size: 12px; font-size: 12px;
cursor: pointer; cursor: pointer;
} }
/* ============================================================
PRINTABLE RECOVERY CODE — only renders on the printed page
============================================================ */
#printRecoveryArea { display: none; }
@media print {
/* Hide the running app entirely; show only the printable sheet. */
body > *:not(#printRecoveryArea) { display: none !important; }
#printRecoveryArea {
display: block !important;
position: static;
background: #fff !important;
color: #000 !important;
}
.prc-sheet {
font-family: Georgia, 'Times New Roman', serif;
padding: 40px;
max-width: 700px;
margin: 0 auto;
}
.prc-sheet h1 {
font-size: 22px;
margin: 0 0 18px;
border-bottom: 2px solid #000;
padding-bottom: 8px;
}
.prc-meta {
display: flex;
justify-content: space-between;
font-size: 12px;
margin-bottom: 28px;
}
.prc-code {
font-family: 'Courier New', monospace;
font-size: 32px;
letter-spacing: 6px;
text-align: center;
padding: 24px;
border: 2px dashed #000;
margin: 28px 0;
font-weight: bold;
}
.prc-instructions {
font-size: 13px;
line-height: 1.6;
}
.prc-instructions ol { padding-left: 22px; }
@page { margin: 1.5cm; }
}
@@ -0,0 +1,317 @@
unit PM.Handler.Attachments;
(*
Encrypted file attachments per entry.
GET /entries/{id}/attachments -> [{id, filename, mime, size_bytes, created_at}, ...]
POST /entries/{id}/attachments body {filename, mime, encrypted_blob, iv, size_bytes}
-> {id, filename, mime, size_bytes, created_at}
GET /attachments/{id} -> {id, filename, mime, size_bytes, encrypted_blob, iv}
DELETE /attachments/{id} -> {message}
encrypted_blob is the base64-encoded AES-GCM ciphertext of the raw file
bytes, produced by the JS client with the per-user vault key. Server
never sees plaintext.
Per-attachment cap: ~10 MB of ciphertext-as-base64 (≈ 7.5 MB raw file).
Heavier attachments aren't appropriate for SQLite TEXT storage anyway.
*)
interface
implementation
uses
System.SysUtils, System.JSON,
FireDAC.Comp.Client, FireDAC.Stan.Param,
IdCustomHTTPServer,
PM.Router, PM.JSON, PM.Database, PM.Session, PM.Audit;
const
MAX_ATTACHMENT_B64 = 10 * 1024 * 1024; // 10 MB of base64 text
function GetClientIP(ARequest: TIdHTTPRequestInfo): string;
begin
Result := ARequest.RemoteIP;
if Result = '' then Result := '127.0.0.1';
end;
// Ownership check: returns True iff the entry exists and belongs to LUserId.
function EntryBelongsToUser(LEntryId, LUserId: Integer): Boolean;
var
LQ: TFDQuery;
begin
LQ := TFDQuery.Create(nil);
try
LQ.Connection := DB.Connection;
LQ.SQL.Text :=
'SELECT 1 FROM vault_entries WHERE id = :id AND user_id = :uid';
LQ.ParamByName('id').AsInteger := LEntryId;
LQ.ParamByName('uid').AsInteger := LUserId;
LQ.Open;
Result := not LQ.Eof;
finally
LQ.Free;
end;
end;
// ===== GET /entries/{id}/attachments =========================================
procedure HandleListAttachments(ARequest: TIdHTTPRequestInfo;
AResponse: TIdHTTPResponseInfo; const AParams: TArray<string>);
var
LUserId, LEntryId: Integer;
LQ: TFDQuery;
LArr: TJSONArray;
LObj: TJSONObject;
begin
try
LUserId := Authenticate(ARequest, AResponse);
except
on ESessionRejected do Exit;
end;
LEntryId := StrToIntDef(AParams[0], 0);
if LEntryId = 0 then
begin
TJSONHelper.SendError(AResponse, 400, 'Invalid entry id');
Exit;
end;
LArr := TJSONArray.Create;
DB.Lock;
try
if not EntryBelongsToUser(LEntryId, LUserId) then
begin
TJSONHelper.SendError(AResponse, 404, 'Entry not found');
LArr.Free;
Exit;
end;
LQ := TFDQuery.Create(nil);
try
LQ.Connection := DB.Connection;
LQ.SQL.Text :=
'SELECT id, filename, mime, size_bytes, created_at ' +
'FROM entry_attachments WHERE entry_id = :eid AND user_id = :uid ' +
'ORDER BY created_at DESC';
LQ.ParamByName('eid').AsInteger := LEntryId;
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('filename', LQ.FieldByName('filename').AsString);
LObj.AddPair('mime', LQ.FieldByName('mime').AsString);
LObj.AddPair('size_bytes', TJSONNumber.Create(LQ.FieldByName('size_bytes').AsInteger));
LObj.AddPair('created_at',
FormatDateTime('yyyy-mm-dd"T"hh:nn:ss', LQ.FieldByName('created_at').AsDateTime));
LArr.Add(LObj);
LQ.Next;
end;
finally
LQ.Free;
end;
finally
DB.Unlock;
end;
TJSONHelper.SendJSON(AResponse, LArr);
end;
// ===== POST /entries/{id}/attachments ========================================
procedure HandleCreateAttachment(ARequest: TIdHTTPRequestInfo;
AResponse: TIdHTTPResponseInfo; const AParams: TArray<string>);
var
LUserId, LEntryId, LNewId: Integer;
LBody: TJSONObject;
LFilename, LMime, LBlob, LIv: string;
LSize: Integer;
LQ: TFDQuery;
LObj: TJSONObject;
begin
try
LUserId := Authenticate(ARequest, AResponse);
RequireCSRF(ARequest, AResponse, LUserId);
except
on ESessionRejected do Exit;
end;
LEntryId := StrToIntDef(AParams[0], 0);
if LEntryId = 0 then
begin
TJSONHelper.SendError(AResponse, 400, 'Invalid entry id');
Exit;
end;
LBody := TJSONHelper.ReadBody(ARequest);
try
LFilename := Trim(LBody.GetValue<string>('filename', ''));
LMime := LBody.GetValue<string>('mime', '');
LBlob := LBody.GetValue<string>('encrypted_blob', '');
LIv := LBody.GetValue<string>('iv', '');
LSize := LBody.GetValue<Integer>('size_bytes', 0);
finally
LBody.Free;
end;
if (LFilename = '') or (LBlob = '') or (LIv = '') then
begin
TJSONHelper.SendError(AResponse, 400, 'Missing required fields');
Exit;
end;
if Length(LBlob) > MAX_ATTACHMENT_B64 then
begin
TJSONHelper.SendError(AResponse, 413, 'Attachment too large (max ~7.5 MB raw)');
Exit;
end;
DB.Lock;
try
if not EntryBelongsToUser(LEntryId, LUserId) then
begin
TJSONHelper.SendError(AResponse, 404, 'Entry not found');
Exit;
end;
LQ := TFDQuery.Create(nil);
try
LQ.Connection := DB.Connection;
LQ.SQL.Text :=
'INSERT INTO entry_attachments ' +
'(user_id, entry_id, filename, mime, size_bytes, encrypted_blob, iv) ' +
'VALUES (:uid, :eid, :name, :mime, :sz, :blob, :iv)';
LQ.ParamByName('uid').AsInteger := LUserId;
LQ.ParamByName('eid').AsInteger := LEntryId;
LQ.ParamByName('name').AsString := LFilename;
LQ.ParamByName('mime').AsString := LMime;
LQ.ParamByName('sz').AsInteger := LSize;
LQ.ParamByName('blob').AsString := LBlob;
LQ.ParamByName('iv').AsString := LIv;
LQ.ExecSQL;
LNewId := DB.Connection.GetLastAutoGenValue('entry_attachments');
finally
LQ.Free;
end;
finally
DB.Unlock;
end;
LogAudit(LUserId, 'add_attachment', GetClientIP(ARequest));
LObj := TJSONObject.Create;
LObj.AddPair('id', TJSONNumber.Create(LNewId));
LObj.AddPair('filename', LFilename);
LObj.AddPair('mime', LMime);
LObj.AddPair('size_bytes', TJSONNumber.Create(LSize));
LObj.AddPair('created_at', FormatDateTime('yyyy-mm-dd"T"hh:nn:ss', Now));
TJSONHelper.SendJSON(AResponse, LObj);
end;
// ===== GET /attachments/{id} =================================================
procedure HandleGetAttachment(ARequest: TIdHTTPRequestInfo;
AResponse: TIdHTTPResponseInfo; const AParams: TArray<string>);
var
LUserId, LId: Integer;
LQ: TFDQuery;
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;
DB.Lock;
try
LQ := TFDQuery.Create(nil);
try
LQ.Connection := DB.Connection;
LQ.SQL.Text :=
'SELECT id, filename, mime, size_bytes, encrypted_blob, iv ' +
'FROM entry_attachments WHERE id = :id AND user_id = :uid';
LQ.ParamByName('id').AsInteger := LId;
LQ.ParamByName('uid').AsInteger := LUserId;
LQ.Open;
if LQ.Eof then
begin
TJSONHelper.SendError(AResponse, 404, 'Not found');
Exit;
end;
LObj := TJSONObject.Create;
LObj.AddPair('id', TJSONNumber.Create(LQ.FieldByName('id').AsInteger));
LObj.AddPair('filename', LQ.FieldByName('filename').AsString);
LObj.AddPair('mime', LQ.FieldByName('mime').AsString);
LObj.AddPair('size_bytes', TJSONNumber.Create(LQ.FieldByName('size_bytes').AsInteger));
LObj.AddPair('encrypted_blob', LQ.FieldByName('encrypted_blob').AsString);
LObj.AddPair('iv', LQ.FieldByName('iv').AsString);
TJSONHelper.SendJSON(AResponse, LObj);
finally
LQ.Free;
end;
finally
DB.Unlock;
end;
end;
// ===== DELETE /attachments/{id} ==============================================
procedure HandleDeleteAttachment(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 :=
'DELETE FROM entry_attachments WHERE id = :id AND user_id = :uid';
LQ.ParamByName('id').AsInteger := LId;
LQ.ParamByName('uid').AsInteger := LUserId;
LQ.ExecSQL;
if LQ.RowsAffected = 0 then
begin
TJSONHelper.SendError(AResponse, 404, 'Not found');
Exit;
end;
finally
LQ.Free;
end;
finally
DB.Unlock;
end;
LogAudit(LUserId, 'delete_attachment', GetClientIP(ARequest));
TJSONHelper.SendOK(AResponse, 'Deleted');
end;
initialization
Router.Register('GET', '/entries/(\d+)/attachments', HandleListAttachments);
Router.Register('POST', '/entries/(\d+)/attachments', HandleCreateAttachment);
Router.Register('GET', '/attachments/(\d+)', HandleGetAttachment);
Router.Register('DELETE', '/attachments/(\d+)', HandleDeleteAttachment);
end.
+66 -7
View File
@@ -1,13 +1,11 @@
unit PM.Handler.Audit; unit PM.Handler.Audit;
(* (*
POST /audit body {action, site} -> {ok:true} POST /audit body {action, site} -> {ok:true}
GET /audit?limit=N&before=<id> -> [{id, action, ip, created_at}, ...]
Light-weight endpoint that lets the JS layer append an entry to audit_log audit_log is auto-purged after 30 days by Database init. The viewer
without going through the full entries pipeline. Used by the autofill reads page-by-page via the `before` cursor (id < before).
feature to record which site was filled (action = "autofill:<site>").
The bearer token identifies the user — no data beyond the action string
is stored.
*) *)
interface interface
@@ -17,7 +15,8 @@ implementation
uses uses
System.SysUtils, System.JSON, System.SysUtils, System.JSON,
IdCustomHTTPServer, IdCustomHTTPServer,
PM.Router, PM.JSON, PM.Session, PM.Audit; Data.DB, FireDAC.Comp.Client, FireDAC.Stan.Param,
PM.Router, PM.JSON, PM.Session, PM.Audit, PM.Database;
function GetClientIP(ARequest: TIdHTTPRequestInfo): string; function GetClientIP(ARequest: TIdHTTPRequestInfo): string;
begin begin
@@ -59,7 +58,67 @@ begin
TJSONHelper.SendOK(AResponse); TJSONHelper.SendOK(AResponse);
end; end;
// GET /audit — return up to `limit` log entries for the current user,
// optionally newer-than-cursor (`before` = id). Most-recent first.
procedure HandleGetAudit(ARequest: TIdHTTPRequestInfo;
AResponse: TIdHTTPResponseInfo; const AParams: TArray<string>);
var
LUserId, LLimit, LBefore: Integer;
LQ: TFDQuery;
LArr: TJSONArray;
LObj: TJSONObject;
begin
LUserId := Authenticate(ARequest, AResponse);
LLimit := StrToIntDef(ARequest.Params.Values['limit'], 100);
if LLimit <= 0 then LLimit := 100;
if LLimit > 500 then LLimit := 500;
LBefore := StrToIntDef(ARequest.Params.Values['before'], 0);
LArr := TJSONArray.Create;
DB.Lock;
try
LQ := TFDQuery.Create(nil);
try
LQ.Connection := DB.Connection;
if LBefore > 0 then
LQ.SQL.Text :=
'SELECT id, action, ip, created_at FROM audit_log ' +
'WHERE user_id = :uid AND id < :b ' +
'ORDER BY id DESC LIMIT :l'
else
LQ.SQL.Text :=
'SELECT id, action, ip, created_at FROM audit_log ' +
'WHERE user_id = :uid ' +
'ORDER BY id DESC LIMIT :l';
LQ.ParamByName('uid').AsInteger := LUserId;
LQ.ParamByName('l').AsInteger := LLimit;
if LBefore > 0 then
LQ.ParamByName('b').AsInteger := LBefore;
LQ.Open;
while not LQ.Eof do
begin
LObj := TJSONObject.Create;
LObj.AddPair('id', TJSONNumber.Create(LQ.FieldByName('id').AsInteger));
LObj.AddPair('action', LQ.FieldByName('action').AsString);
LObj.AddPair('ip', LQ.FieldByName('ip').AsString);
LObj.AddPair('created_at',
FormatDateTime('yyyy-mm-dd hh:nn:ss',
LQ.FieldByName('created_at').AsDateTime));
LArr.Add(LObj);
LQ.Next;
end;
finally
LQ.Free;
end;
finally
DB.Unlock;
end;
TJSONHelper.SendJSON(AResponse, LArr);
end;
initialization initialization
Router.Register('POST', '/audit', HandlePostAudit); Router.Register('POST', '/audit', HandlePostAudit);
Router.Register('GET', '/audit', HandleGetAudit);
end. end.
+61 -3
View File
@@ -139,6 +139,14 @@ begin
LObj.AddPair('custom_fields_iv', LQ.FieldByName('custom_fields_iv').AsString); 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')));
if LQ.FieldByName('accessed_at').IsNull then
LObj.AddPair('accessed_at', TJSONNull.Create)
else
LObj.AddPair('accessed_at', ISODateTimeField(LQ.FieldByName('accessed_at')));
if LQ.FieldByName('password_changed_at').IsNull then
LObj.AddPair('password_changed_at', TJSONNull.Create)
else
LObj.AddPair('password_changed_at', ISODateTimeField(LQ.FieldByName('password_changed_at')));
LArr.Add(LObj); LArr.Add(LObj);
LQ.Next; LQ.Next;
end; end;
@@ -213,9 +221,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) ' + ' 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)'; ' :cf, :cfiv, :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;
@@ -355,12 +363,16 @@ begin
LQ.ParamByName('id').AsInteger := LId; LQ.ParamByName('id').AsInteger := LId;
LQ.ExecSQL; LQ.ExecSQL;
// password_changed_at fires only when the ciphertext actually
// changes — same conditional used above for history insertion.
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, kind=:k, ' + ' folder=:f, tags=:t, totp_secret=:ts, totp_iv=:tiv, kind=:k, ' +
' 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 ' +
' THEN :c ELSE password_changed_at END ' +
'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;
@@ -546,6 +558,51 @@ begin
TJSONHelper.SendOK(AResponse, 'Toggled'); TJSONHelper.SendOK(AResponse, 'Toggled');
end; end;
// ===== POST /entries/{id}/touch ==============================================
// 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
// requirement — this is a write but harmless to forge across sessions, and
// the call is fire-and-forget from clipboard handlers where blocking on
// CSRF would be visibly laggy).
procedure HandleTouchEntry(ARequest: TIdHTTPRequestInfo;
AResponse: TIdHTTPResponseInfo; const AParams: TArray<string>);
var
LUserId, LId: Integer;
LQ: TFDQuery;
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;
DB.Lock;
try
LQ := TFDQuery.Create(nil);
try
LQ.Connection := DB.Connection;
LQ.SQL.Text :=
'UPDATE vault_entries SET accessed_at = CURRENT_TIMESTAMP ' +
'WHERE id = :id AND user_id = :uid AND deleted = 0';
LQ.ParamByName('id').AsInteger := LId;
LQ.ParamByName('uid').AsInteger := LUserId;
LQ.ExecSQL;
finally
LQ.Free;
end;
finally
DB.Unlock;
end;
TJSONHelper.SendOK(AResponse);
end;
// ===== POST /entries/{id}/icon =============================================== // ===== POST /entries/{id}/icon ===============================================
// Stores (or clears) a cached favicon for one entry. Separate endpoint so the // Stores (or clears) a cached favicon for one entry. Separate endpoint so the
// client can save the icon without re-PUT-ing the full entry (which would // client can save the icon without re-PUT-ing the full entry (which would
@@ -968,6 +1025,7 @@ initialization
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+)/touch', HandleTouchEntry);
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);
+180 -11
View File
@@ -1,9 +1,10 @@
unit PM.Handler.Folders; unit PM.Handler.Folders;
(* (*
GET /folders -> JSON array of folder names GET /folders -> [{name, color, icon}, ...]
POST /folders body {name} -> {message,name} POST /folders body {name, color?, icon?} -> {message, name}
DELETE /folders/{name} -> {message} PUT /folders/{name} body {color?, icon?} -> {message}
DELETE /folders/{name} -> {message}
*) *)
interface interface
@@ -12,6 +13,7 @@ implementation
uses uses
System.SysUtils, System.JSON, System.NetEncoding, System.SysUtils, System.JSON, System.NetEncoding,
System.Generics.Collections,
FireDAC.Comp.Client, FireDAC.Stan.Param, FireDAC.Comp.Client, FireDAC.Stan.Param,
IdCustomHTTPServer, IdCustomHTTPServer,
PM.Router, PM.JSON, PM.Database, PM.Session, PM.Audit, PM.RateLimit; PM.Router, PM.JSON, PM.Database, PM.Session, PM.Audit, PM.RateLimit;
@@ -24,6 +26,7 @@ var
LUserId: Integer; LUserId: Integer;
LQ: TFDQuery; LQ: TFDQuery;
LArr: TJSONArray; LArr: TJSONArray;
LObj: TJSONObject;
begin begin
try try
LUserId := Authenticate(ARequest, AResponse); LUserId := Authenticate(ARequest, AResponse);
@@ -37,12 +40,18 @@ begin
LQ := TFDQuery.Create(nil); LQ := TFDQuery.Create(nil);
try try
LQ.Connection := DB.Connection; LQ.Connection := DB.Connection;
LQ.SQL.Text := 'SELECT name FROM folders WHERE user_id = :uid ORDER BY name'; LQ.SQL.Text :=
'SELECT name, color, icon FROM folders ' +
'WHERE user_id = :uid ORDER BY sort_order, name';
LQ.ParamByName('uid').AsInteger := LUserId; LQ.ParamByName('uid').AsInteger := LUserId;
LQ.Open; LQ.Open;
while not LQ.Eof do while not LQ.Eof do
begin begin
LArr.Add(LQ.FieldByName('name').AsString); LObj := TJSONObject.Create;
LObj.AddPair('name', LQ.FieldByName('name').AsString);
LObj.AddPair('color', LQ.FieldByName('color').AsString);
LObj.AddPair('icon', LQ.FieldByName('icon').AsString);
LArr.Add(LObj);
LQ.Next; LQ.Next;
end; end;
finally finally
@@ -61,7 +70,7 @@ procedure HandleCreateFolder(ARequest: TIdHTTPRequestInfo;
var var
LUserId: Integer; LUserId: Integer;
LBody: TJSONObject; LBody: TJSONObject;
LName: string; LName, LColor, LIcon: string;
LQ: TFDQuery; LQ: TFDQuery;
LObj: TJSONObject; LObj: TJSONObject;
begin begin
@@ -74,7 +83,9 @@ begin
LBody := TJSONHelper.ReadBody(ARequest); LBody := TJSONHelper.ReadBody(ARequest);
try try
LName := Trim(LBody.GetValue<string>('name', '')); LName := Trim(LBody.GetValue<string>('name', ''));
LColor := Trim(LBody.GetValue<string>('color', ''));
LIcon := Trim(LBody.GetValue<string>('icon', ''));
finally finally
LBody.Free; LBody.Free;
end; end;
@@ -95,9 +106,15 @@ begin
LQ := TFDQuery.Create(nil); LQ := TFDQuery.Create(nil);
try try
LQ.Connection := DB.Connection; LQ.Connection := DB.Connection;
LQ.SQL.Text := 'INSERT INTO folders (user_id, name) VALUES (:uid, :name)'; LQ.SQL.Text :=
LQ.ParamByName('uid').AsInteger := LUserId; 'INSERT INTO folders (user_id, name, color, icon) ' +
LQ.ParamByName('name').AsString := LName; 'VALUES (:uid, :name, :color, :icon)';
LQ.ParamByName('uid').AsInteger := LUserId;
LQ.ParamByName('name').AsString := LName;
if LColor = '' then LQ.ParamByName('color').Clear
else LQ.ParamByName('color').AsString := LColor;
if LIcon = '' then LQ.ParamByName('icon').Clear
else LQ.ParamByName('icon').AsString := LIcon;
try try
LQ.ExecSQL; LQ.ExecSQL;
except except
@@ -121,6 +138,156 @@ begin
TJSONHelper.SendJSON(AResponse, LObj); TJSONHelper.SendJSON(AResponse, LObj);
end; end;
// ===== PUT /folders/{name} ===================================================
// Body: {color?, icon?} — pass empty string to clear.
procedure HandleUpdateFolder(ARequest: TIdHTTPRequestInfo;
AResponse: TIdHTTPResponseInfo; const AParams: TArray<string>);
var
LUserId: Integer;
LBody: TJSONObject;
LName, LColor, LIcon: string;
LHasColor, LHasIcon: Boolean;
LQ: TFDQuery;
begin
try
LUserId := Authenticate(ARequest, AResponse);
RequireCSRF(ARequest, AResponse, LUserId);
except
on ESessionRejected do Exit;
end;
if Length(AParams) < 1 then
begin
TJSONHelper.SendError(AResponse, 400, 'Folder name required');
Exit;
end;
LName := TNetEncoding.URL.Decode(AParams[0]);
if SameText(LName, 'All') then
begin
TJSONHelper.SendError(AResponse, 400, 'Cannot customise All');
Exit;
end;
LBody := TJSONHelper.ReadBody(ARequest);
try
LHasColor := LBody.GetValue('color') <> nil;
LHasIcon := LBody.GetValue('icon') <> nil;
LColor := LBody.GetValue<string>('color', '');
LIcon := LBody.GetValue<string>('icon', '');
finally
LBody.Free;
end;
if not (LHasColor or LHasIcon) then
begin
TJSONHelper.SendOK(AResponse, 'No change');
Exit;
end;
DB.Lock;
try
LQ := TFDQuery.Create(nil);
try
LQ.Connection := DB.Connection;
// Build SET clause dynamically based on which fields the caller sent.
var LSet := '';
if LHasColor then LSet := 'color = :color';
if LHasIcon then
begin
if LSet <> '' then LSet := LSet + ', ';
LSet := LSet + 'icon = :icon';
end;
LQ.SQL.Text :=
'UPDATE folders SET ' + LSet +
' WHERE user_id = :uid AND name = :name';
LQ.ParamByName('uid').AsInteger := LUserId;
LQ.ParamByName('name').AsString := LName;
if LHasColor then
begin
if LColor = '' then LQ.ParamByName('color').Clear
else LQ.ParamByName('color').AsString := LColor;
end;
if LHasIcon then
begin
if LIcon = '' then LQ.ParamByName('icon').Clear
else LQ.ParamByName('icon').AsString := LIcon;
end;
LQ.ExecSQL;
if LQ.RowsAffected = 0 then
begin
TJSONHelper.SendError(AResponse, 404, 'Not found');
Exit;
end;
finally
LQ.Free;
end;
finally
DB.Unlock;
end;
LogAudit(LUserId, 'update_folder', GetClientIP(ARequest));
TJSONHelper.SendOK(AResponse, 'Updated');
end;
// ===== POST /folders/reorder =================================================
// Body: {names: ["Work", "Personal", "Misc"]} — write sort_order = index+1
// for each. Names not in the list keep their previous sort_order (so a
// partial reorder still works after another tab created a folder).
procedure HandleReorderFolders(ARequest: TIdHTTPRequestInfo;
AResponse: TIdHTTPResponseInfo; const AParams: TArray<string>);
var
LUserId: Integer;
LBody: TJSONObject;
LArr: TJSONArray;
LQ: TFDQuery;
I: Integer;
begin
try
LUserId := Authenticate(ARequest, AResponse);
RequireCSRF(ARequest, AResponse, LUserId);
except
on ESessionRejected do Exit;
end;
LBody := TJSONHelper.ReadBody(ARequest);
try
LArr := LBody.GetValue<TJSONArray>('names');
if (LArr = nil) or (LArr.Count = 0) then
begin
TJSONHelper.SendError(AResponse, 400, 'names array required');
Exit;
end;
DB.Lock;
try
LQ := TFDQuery.Create(nil);
try
LQ.Connection := DB.Connection;
LQ.SQL.Text :=
'UPDATE folders SET sort_order = :ord ' +
'WHERE user_id = :uid AND name = :name';
for I := 0 to LArr.Count - 1 do
begin
LQ.ParamByName('uid').AsInteger := LUserId;
LQ.ParamByName('ord').AsInteger := I + 1;
LQ.ParamByName('name').AsString := LArr.Items[I].Value;
LQ.ExecSQL;
end;
finally
LQ.Free;
end;
finally
DB.Unlock;
end;
finally
LBody.Free;
end;
LogAudit(LUserId, 'reorder_folders', GetClientIP(ARequest));
TJSONHelper.SendOK(AResponse, 'Reordered');
end;
// ===== DELETE /folders/{name} ================================================ // ===== DELETE /folders/{name} ================================================
procedure HandleDeleteFolder(ARequest: TIdHTTPRequestInfo; procedure HandleDeleteFolder(ARequest: TIdHTTPRequestInfo;
@@ -194,7 +361,9 @@ end;
initialization initialization
Router.Register('GET', '/folders', HandleGetFolders); Router.Register('GET', '/folders', HandleGetFolders);
Router.Register('POST', '/folders', HandleCreateFolder); Router.Register('POST', '/folders', HandleCreateFolder);
Router.Register('POST', '/folders/reorder', HandleReorderFolders);
Router.Register('PUT', '/folders/(.+)', HandleUpdateFolder);
Router.Register('DELETE', '/folders/(.+)', HandleDeleteFolder); Router.Register('DELETE', '/folders/(.+)', HandleDeleteFolder);
end. end.
+2 -1
View File
@@ -28,7 +28,8 @@ uses
PM.Handler.Passkey in 'Handlers\PM.Handler.Passkey.pas', PM.Handler.Passkey in 'Handlers\PM.Handler.Passkey.pas',
PM.Handler.Recovery in 'Handlers\PM.Handler.Recovery.pas', PM.Handler.Recovery in 'Handlers\PM.Handler.Recovery.pas',
PM.Handler.Audit in 'Handlers\PM.Handler.Audit.pas', PM.Handler.Audit in 'Handlers\PM.Handler.Audit.pas',
PM.Handler.Settings in 'Handlers\PM.Handler.Settings.pas'; PM.Handler.Settings in 'Handlers\PM.Handler.Settings.pas',
PM.Handler.Attachments in 'Handlers\PM.Handler.Attachments.pas';
{$R *.res} {$R *.res}
{$R assets\assets.res} {$R assets\assets.res}
+1260 -1259
View File
File diff suppressed because it is too large Load Diff
+38
View File
@@ -208,6 +208,28 @@ begin
FConn.ExecSQL( FConn.ExecSQL(
'CREATE INDEX IF NOT EXISTS idx_history_entry ' + 'CREATE INDEX IF NOT EXISTS idx_history_entry ' +
' ON entries_password_history(entry_id, changed_at DESC)'); ' ON entries_password_history(entry_id, changed_at DESC)');
// Per-entry encrypted file attachments (PDFs, images of backup codes,
// etc.). encrypted_blob is the AES-GCM ciphertext of the raw file bytes,
// base64-encoded. Filename + mime + size_bytes are stored in cleartext
// for the listing UI — knowingly leaked metadata in exchange for not
// having to decrypt every entry on list render.
FConn.ExecSQL(
'CREATE TABLE IF NOT EXISTS entry_attachments (' +
' id INTEGER PRIMARY KEY AUTOINCREMENT,' +
' user_id INTEGER NOT NULL,' +
' entry_id INTEGER NOT NULL,' +
' filename TEXT NOT NULL,' +
' mime TEXT,' +
' size_bytes INTEGER NOT NULL,' +
' encrypted_blob TEXT NOT NULL,' +
' iv TEXT NOT NULL,' +
' created_at DATETIME DEFAULT CURRENT_TIMESTAMP,' +
' FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,' +
' FOREIGN KEY (entry_id) REFERENCES vault_entries(id) ON DELETE CASCADE' +
')');
FConn.ExecSQL(
'CREATE INDEX IF NOT EXISTS idx_attachments_entry ' +
' ON entry_attachments(entry_id)');
end; end;
function TPMDatabase.ColumnExists(const ATable, AColumn: string): Boolean; function TPMDatabase.ColumnExists(const ATable, AColumn: string): Boolean;
@@ -275,6 +297,22 @@ begin
// 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.
AddColumnIfMissing('vault_entries', 'icon_b64', 'TEXT'); AddColumnIfMissing('vault_entries', 'icon_b64', 'TEXT');
// Tracked client-side via POST /entries/:id/touch on copy/open. Powers
// the sidebar "Recent" view. NULL = never accessed since the column
// landed (legacy rows).
AddColumnIfMissing('vault_entries', 'accessed_at', 'DATETIME');
// Bumped to CURRENT_TIMESTAMP only when encrypted_password actually
// changes (distinct from updated_at which fires on any edit). Powers
// the "aged password" badge. Legacy rows: NULL → JS falls back to
// updated_at, then created_at.
AddColumnIfMissing('vault_entries', 'password_changed_at', 'DATETIME');
// Per-folder customisation. NULL = no override → JS uses the default
// accent + i-folder symbol.
AddColumnIfMissing('folders', 'color', 'TEXT');
AddColumnIfMissing('folders', 'icon', 'TEXT');
// Manual sort order from drag-reorder. 0 = legacy/never reordered →
// falls back to alphabetical secondary sort in GET /folders.
AddColumnIfMissing('folders', 'sort_order', 'INTEGER DEFAULT 0');
AddColumnIfMissing('users', 'hash_algo', 'TEXT DEFAULT ''pbkdf2'''); AddColumnIfMissing('users', 'hash_algo', 'TEXT DEFAULT ''pbkdf2''');
// PBKDF2 iteration count per user. Legacy rows (predating this column) // PBKDF2 iteration count per user. Legacy rows (predating this column)
// default to 100000 — the value used by api.php / the early Delphi build. // default to 100000 — the value used by api.php / the early Delphi build.
+177 -1
View File
@@ -15,7 +15,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, System.IOUtils, System.JSON,
Winapi.Windows, Winapi.ShellAPI, Winapi.Windows, Winapi.ShellAPI,
FMX.Forms, FMX.Controls, FMX.Controls.Presentation, FMX.StdCtrls, FMX.Forms, FMX.Controls, FMX.Controls.Presentation, FMX.StdCtrls,
FMX.Memo, FMX.Memo.Types, FMX.ScrollBox, FMX.Edit, FMX.Layouts, FMX.Types, FMX.Memo, FMX.Memo.Types, FMX.ScrollBox, FMX.Edit, FMX.Layouts, FMX.Types,
@@ -860,6 +860,182 @@ begin
BoolToStr(PM.AutoStart.IsAutoStartEnabled, True).ToLower + ')'); BoolToStr(PM.AutoStart.IsAutoStartEnabled, True).ToLower + ')');
end end
// ---- Native file save (bypasses WebView2's browser download UI) ------
// JS sends: cmd://file/save?name=<filename>&data=<base64>&reqId=<id>
// Delphi opens GetSaveFileName, writes the decoded bytes, then calls
// Bridge.onFileSaveResult(reqId, ok, path). All synchronous on the UI
// thread — payloads are small (a vault JSON export is well under 1 MB).
else if ACmd = 'file/save' then
begin
var LName := GetParam('name');
var LData := GetParam('data');
var LReqId := GetParam('reqId');
var LOk := False;
var LPath := '';
var LErr := '';
try
var LBytes := TNetEncoding.Base64.DecodeStringToBytes(LData);
var LDlg := TSaveDialog.Create(nil);
try
LDlg.FileName := LName;
var LExt := ExtractFileExt(LName);
if LExt = '.json' then LDlg.Filter := 'JSON file (*.json)|*.json|All files (*.*)|*.*'
else if LExt = '.csv' then LDlg.Filter := 'CSV file (*.csv)|*.csv|All files (*.*)|*.*'
else LDlg.Filter := 'All files (*.*)|*.*';
LDlg.DefaultExt := LExt.TrimLeft(['.']);
LDlg.Options := LDlg.Options + [TOpenOption.ofOverwritePrompt];
if LDlg.Execute then
begin
LPath := LDlg.FileName;
var LStream := TFileStream.Create(LPath, fmCreate);
try
if Length(LBytes) > 0 then
LStream.WriteBuffer(LBytes[0], Length(LBytes));
finally
LStream.Free;
end;
LOk := True;
LogLine(Format('File saved: %s (%d bytes)', [LPath, Length(LBytes)]));
end
else
LogLine('File save cancelled by user');
finally
LDlg.Free;
end;
except
on E: Exception do
begin
LErr := E.Message;
LogLine('File save FAILED: ' + LErr);
end;
end;
var LEscReq := StringReplace(LReqId, '"', '\"', [rfReplaceAll]);
var LEscPath := StringReplace(LPath, '\', '\\', [rfReplaceAll]);
LEscPath := StringReplace(LEscPath, '"', '\"', [rfReplaceAll]);
var LEscErr := StringReplace(LErr, '\', '\\', [rfReplaceAll]);
LEscErr := StringReplace(LEscErr, '"', '\"', [rfReplaceAll]);
WebBrowser.ExecuteJavaScript(
'if(window.Bridge&&Bridge.onFileSaveResult)' +
'Bridge.onFileSaveResult("' + LEscReq + '",' +
BoolToStr(LOk, True).ToLower + ',"' + LEscPath + '","' + LEscErr + '")');
end
// ---- Auto-backup: folder picker (modal Win32 dialog) -----------------
// cmd://folder/pick?reqId=<id>
// Callback: Bridge.onFolderPickResult(reqId, path) (path = '' on cancel)
else if ACmd = 'folder/pick' then
begin
var LReqId := GetParam('reqId');
var LDir := '';
SelectDirectory('Choose backup folder', '', LDir);
var LEscReq := StringReplace(LReqId, '"', '\"', [rfReplaceAll]);
var LEscDir := StringReplace(LDir, '\', '\\', [rfReplaceAll]);
LEscDir := StringReplace(LEscDir, '"', '\"', [rfReplaceAll]);
WebBrowser.ExecuteJavaScript(
'if(window.Bridge&&Bridge.onFolderPickResult)' +
'Bridge.onFolderPickResult("' + LEscReq + '","' + LEscDir + '")');
if LDir <> '' then LogLine('Folder picked: ' + LDir);
end
// ---- Auto-backup: silent file write (no dialog) ----------------------
// cmd://file/write?path=<full>&data=<base64>&reqId=<id>
// Callback: Bridge.onFileWriteResult(reqId, ok, error)
else if ACmd = 'file/write' then
begin
var LPath := GetParam('path');
var LData := GetParam('data');
var LReqId := GetParam('reqId');
var LOk := False;
var LErr := '';
try
var LBytes := TNetEncoding.Base64.DecodeStringToBytes(LData);
var LStream := TFileStream.Create(LPath, fmCreate);
try
if Length(LBytes) > 0 then
LStream.WriteBuffer(LBytes[0], Length(LBytes));
finally
LStream.Free;
end;
LOk := True;
LogLine(Format('File written: %s (%d bytes)', [LPath, Length(LBytes)]));
except
on E: Exception do
begin
LErr := E.Message;
LogLine('File write FAILED for "' + LPath + '": ' + LErr);
end;
end;
var LEscReq := StringReplace(LReqId, '"', '\"', [rfReplaceAll]);
var LEscErr := StringReplace(LErr, '\', '\\', [rfReplaceAll]);
LEscErr := StringReplace(LEscErr, '"', '\"', [rfReplaceAll]);
WebBrowser.ExecuteJavaScript(
'if(window.Bridge&&Bridge.onFileWriteResult)' +
'Bridge.onFileWriteResult("' + LEscReq + '",' +
BoolToStr(LOk, True).ToLower + ',"' + LEscErr + '")');
end
// ---- Auto-backup: list files in dir matching name prefix --------------
// cmd://file/listMatch?dir=<full>&prefix=<str>&reqId=<id>
// Callback: Bridge.onFileListResult(reqId, jsonArr)
// Each item: {name, size, mtime} (mtime = ISO).
else if ACmd = 'file/listMatch' then
begin
var LDir := GetParam('dir');
var LPrefix := GetParam('prefix');
var LReqId := GetParam('reqId');
var LArr := TJSONArray.Create;
try
if (LDir <> '') and TDirectory.Exists(LDir) then
begin
var LFiles := TDirectory.GetFiles(LDir, LPrefix + '*');
for var F in LFiles do
begin
var LObj := TJSONObject.Create;
LObj.AddPair('name', ExtractFileName(F));
LObj.AddPair('size', TJSONNumber.Create(TFile.GetSize(F)));
LObj.AddPair('mtime',
FormatDateTime('yyyy-mm-dd"T"hh:nn:ss', TFile.GetLastWriteTime(F)));
LArr.Add(LObj);
end;
end;
var LJSON := LArr.ToString;
var LEscReq := StringReplace(LReqId, '"', '\"', [rfReplaceAll]);
var LEscJson := StringReplace(LJSON, '\', '\\', [rfReplaceAll]);
LEscJson := StringReplace(LEscJson, '"', '\"', [rfReplaceAll]);
WebBrowser.ExecuteJavaScript(
'if(window.Bridge&&Bridge.onFileListResult)' +
'Bridge.onFileListResult("' + LEscReq + '","' + LEscJson + '")');
finally
LArr.Free;
end;
end
// ---- Auto-backup: delete a single file (for retention pruning) -------
// cmd://file/delete?path=<full>&reqId=<id>
// Callback: Bridge.onFileDeleteResult(reqId, ok)
else if ACmd = 'file/delete' then
begin
var LPath := GetParam('path');
var LReqId := GetParam('reqId');
var LOk := False;
try
if TFile.Exists(LPath) then
begin
TFile.Delete(LPath);
LOk := True;
LogLine('File deleted: ' + LPath);
end;
except
on E: Exception do
LogLine('File delete FAILED for "' + LPath + '": ' + E.Message);
end;
var LEscReq := StringReplace(LReqId, '"', '\"', [rfReplaceAll]);
WebBrowser.ExecuteJavaScript(
'if(window.Bridge&&Bridge.onFileDeleteResult)' +
'Bridge.onFileDeleteResult("' + LEscReq + '",' +
BoolToStr(LOk, True).ToLower + ')');
end
else else
LogLine('Bridge: unknown command "' + ACmd + '"'); LogLine('Bridge: unknown command "' + ACmd + '"');
end; end;
Binary file not shown.
+86 -3
View File
@@ -44,6 +44,9 @@
<symbol id="i-shield" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/></symbol> <symbol id="i-shield" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/></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-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-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-empty-vault" viewBox="0 0 120 120" fill="none"> <symbol id="i-empty-vault" viewBox="0 0 120 120" fill="none">
<circle cx="60" cy="60" r="50" fill="var(--accent-soft)"/> <circle cx="60" cy="60" r="50" fill="var(--accent-soft)"/>
<rect x="40" y="50" width="40" height="35" rx="4" fill="var(--bg-elev)" stroke="var(--accent)" stroke-width="2"/> <rect x="40" y="50" width="40" height="35" rx="4" fill="var(--bg-elev)" stroke="var(--accent)" stroke-width="2"/>
@@ -220,10 +223,18 @@
<svg><use href="#i-key"/></svg> <svg><use href="#i-key"/></svg>
<span>TOTP generator</span> <span>TOTP generator</span>
</button> </button>
<button class="nav-item" id="sidebarRecentBtn">
<svg><use href="#i-rotate-ccw"/></svg>
<span>Recently used</span>
</button>
<button class="nav-item" id="sidebarHealthBtn"> <button class="nav-item" id="sidebarHealthBtn">
<svg><use href="#i-alert"/></svg> <svg><use href="#i-alert"/></svg>
<span>Vault health</span> <span>Vault health</span>
</button> </button>
<button class="nav-item" id="sidebarAuditBtn">
<svg><use href="#i-list"/></svg>
<span>Audit log</span>
</button>
<button class="nav-item" id="sidebarImportBtn"> <button class="nav-item" id="sidebarImportBtn">
<svg><use href="#i-log-in"/></svg> <svg><use href="#i-log-in"/></svg>
<span>Import vault</span> <span>Import vault</span>
@@ -268,6 +279,13 @@
<svg><use href="#i-table"/></svg> <svg><use href="#i-table"/></svg>
</button> </button>
</div> </div>
<div class="filters-wrap">
<button class="icon-btn" id="filtersBtn" title="Filters">
<svg><use href="#i-list"/></svg>
<span class="filters-count" id="filtersCount" style="display:none">0</span>
</button>
<div class="filters-menu is-hidden" id="filtersMenu"></div>
</div>
<button class="icon-btn" id="cheatsheetBtn" title="Keyboard shortcuts (?)"> <button class="icon-btn" id="cheatsheetBtn" title="Keyboard shortcuts (?)">
<span style="font-weight:700;font-size:14px">?</span> <span style="font-weight:700;font-size:14px">?</span>
</button> </button>
@@ -320,6 +338,9 @@
<span class="content-meta" id="contentMeta">0 items</span> <span class="content-meta" id="contentMeta">0 items</span>
</div> </div>
<!-- Active filter chips (populated by renderFilterChips) -->
<div id="filterChips" class="filter-chips is-hidden"></div>
<!-- Entry grid --> <!-- Entry grid -->
<div id="entryGrid" class="entry-grid"></div> <div id="entryGrid" class="entry-grid"></div>
@@ -456,6 +477,19 @@
<option value="90">90 days</option> <option value="90">90 days</option>
</select> </select>
</div> </div>
<div class="setting-row">
<span>
Flag aged passwords
<small class="setting-hint">Show an "Aged" badge on entries whose password hasn't changed in this many days.</small>
</span>
<select id="settingPasswordExpiry">
<option value="0">Off</option>
<option value="90">90 days</option>
<option value="180">180 days</option>
<option value="365">1 year</option>
<option value="730">2 years</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)
@@ -545,9 +579,58 @@
choose a password independent of your master password — choose a password independent of your master password —
save it carefully, you need it to restore. save it carefully, you need it to restore.
</p> </p>
<button class="btn btn-ghost btn-sm" id="exportBtn"> <div style="display:flex;gap:6px;flex-wrap:wrap">
<svg><use href="#i-log-out"/></svg> Export vault <button class="btn btn-ghost btn-sm" id="exportBtn">
</button> <svg><use href="#i-log-out"/></svg> Encrypted JSON
</button>
<button class="btn btn-ghost btn-sm is-danger" id="exportCsvBtn"
title="Plaintext! Use only for migration">
<svg><use href="#i-alert"/></svg> Plaintext CSV
</button>
</div>
</div>
<div class="slideover-field" id="autoBackupField" style="display:none">
<div class="slideover-field-label">Auto-backup</div>
<p style="font-size:12px;color:var(--text-dim);margin:0 0 8px;line-height:1.5">
Encrypted JSON dropped silently into a folder of your
choice, on a schedule. Old backups beyond the retention
count are pruned automatically.
</p>
<label style="display:flex;align-items:center;gap:8px;margin-bottom:8px">
<input type="checkbox" id="settingAutoBackupEnabled">
<span>Enable auto-backup</span>
</label>
<div id="autoBackupConfig" style="display:none;border-left:2px solid var(--border);padding-left:10px;margin-left:4px">
<div style="margin-bottom:8px">
<div style="font-size:11px;color:var(--text-faint);margin-bottom:4px">Folder</div>
<div style="display:flex;gap:6px;align-items:center;flex-wrap:wrap">
<code id="autoBackupDir" style="font-size:11px;flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;background:var(--bg-2);padding:4px 6px;border-radius:4px">(not set)</code>
<button class="btn btn-ghost btn-sm" id="autoBackupPickDirBtn">Choose…</button>
</div>
</div>
<div style="display:flex;gap:12px;flex-wrap:wrap;margin-bottom:8px">
<label style="display:flex;flex-direction:column;gap:2px">
<span style="font-size:11px;color:var(--text-faint)">Every (days)</span>
<input type="number" id="settingAutoBackupInterval" min="1" max="365" value="7" style="width:80px">
</label>
<label style="display:flex;flex-direction:column;gap:2px">
<span style="font-size:11px;color:var(--text-faint)">Keep last N</span>
<input type="number" id="settingAutoBackupKeep" min="1" max="100" value="10" style="width:80px">
</label>
</div>
<p style="font-size:11px;color:var(--text-faint);margin:0 0 8px;line-height:1.4">
Backups are encrypted with a password derived from your
master password (so restore needs the master at the
time of backup). Filename pattern: <code>vault-autobackup-YYYYMMDD-HHmmss.json</code>.
</p>
<div style="display:flex;gap:6px;flex-wrap:wrap;align-items:center">
<button class="btn btn-ghost btn-sm" id="autoBackupNowBtn">
<svg><use href="#i-log-out"/></svg> Backup now
</button>
<span id="autoBackupLast" style="font-size:11px;color:var(--text-faint)"></span>
</div>
</div>
</div> </div>
<div class="slideover-field"> <div class="slideover-field">
+1315 -93
View File
File diff suppressed because it is too large Load Diff