feat(notes): trash + restore + DOMPurify sanitizer + 9 contract tests
Soft-delete for notes — Daniel asked for "deleted notes go to trash"
so a slip of the finger doesn't lose work.
Schema: migrations/1777090000000_notes-trash.js adds a deleted_at
timestamptz column to personal_notes (NULL = active) plus an index
on (user_id, deleted_at).
Server (src/routes/notes.js):
GET /api/notes now filters deleted_at IS NULL
GET /api/notes/trash new — list trashed items, newest-
deleted first
DELETE /api/notes/:id now soft-deletes (sets deleted_at)
DELETE /api/notes/:id?hard=1 hard-delete, only allowed on items
already in trash (UI bug can't
erase an active note)
POST /api/notes/:id/restore pull a note out of trash
POST /api/notes/trash/empty hard-delete every trashed note for
the user
Frontend (public/components/notes.html + public/js/notes.js +
public/css/styles.css):
• Sidebar gets two tabs — "Notes" / "Trash (n)" with live count
• Trash tab shows deleted-at timestamps, Restore + delete-forever
per row, Empty-trash button at the bottom
• Active list and trash count refresh in parallel after every
save / delete / restore
• Delete button in the editor now says "Move to trash" and uses
the showConfirm helper (no native dialogs)
Sanitizer swap (public/js/notes.js):
Replaced the homegrown allowlist walker with DOMPurify (already
loaded from cdnjs in index.html, used by learningHub.js too).
Custom HTML sanitizers historically have bypasses; DOMPurify is
the right primitive.
Tests (test/notes-sanitize.test.js — node:test + jsdom + dompurify
as new dev deps):
9 contract tests covering script-tag stripping, inline event
handlers, img onerror, iframe/object, style attributes, every
preserved tag in the allowlist, javascript: URI rejection,
null/undefined input, and nested-script-inside-paragraph. Total
test count: 37 → 46 passing.
SW cache bumped to pedscribe-v12-notes5.
This commit is contained in:
parent
ed516adb7a
commit
ab3ce11539
9 changed files with 1474 additions and 71 deletions
21
migrations/1777090000000_notes-trash.js
Normal file
21
migrations/1777090000000_notes-trash.js
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
/**
|
||||||
|
* Soft-delete for personal_notes — Daniel asked for "deleted notes go to
|
||||||
|
* trash" so a slip of the finger doesn't lose work. Adds a deleted_at
|
||||||
|
* timestamp; NULL means active. Trash listing filters by NOT NULL,
|
||||||
|
* regular listing filters by NULL.
|
||||||
|
*
|
||||||
|
* Restore = clear deleted_at. Empty Trash = real DELETE. No retention
|
||||||
|
* policy yet — items stay in trash until the user empties it.
|
||||||
|
*/
|
||||||
|
|
||||||
|
exports.up = (pgm) => {
|
||||||
|
pgm.addColumn('personal_notes', {
|
||||||
|
deleted_at: { type: 'timestamptz', notNull: false, default: null },
|
||||||
|
});
|
||||||
|
pgm.createIndex('personal_notes', ['user_id', 'deleted_at']);
|
||||||
|
};
|
||||||
|
|
||||||
|
exports.down = (pgm) => {
|
||||||
|
pgm.dropIndex('personal_notes', ['user_id', 'deleted_at']);
|
||||||
|
pgm.dropColumn('personal_notes', 'deleted_at');
|
||||||
|
};
|
||||||
1067
package-lock.json
generated
1067
package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
|
@ -25,8 +25,8 @@
|
||||||
"@tiptap/extension-text-style": "^3.20.4",
|
"@tiptap/extension-text-style": "^3.20.4",
|
||||||
"@tiptap/extension-underline": "^3.20.4",
|
"@tiptap/extension-underline": "^3.20.4",
|
||||||
"@tiptap/starter-kit": "^3.20.4",
|
"@tiptap/starter-kit": "^3.20.4",
|
||||||
"axios": "^1.7.7",
|
|
||||||
"argon2": "^0.41.1",
|
"argon2": "^0.41.1",
|
||||||
|
"axios": "^1.7.7",
|
||||||
"bcryptjs": "^2.4.3",
|
"bcryptjs": "^2.4.3",
|
||||||
"cookie-parser": "^1.4.7",
|
"cookie-parser": "^1.4.7",
|
||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
|
|
@ -54,5 +54,9 @@
|
||||||
"@aws-sdk/client-transcribe-streaming": "^3.1017.0",
|
"@aws-sdk/client-transcribe-streaming": "^3.1017.0",
|
||||||
"@aws-sdk/s3-request-presigner": "^3.700.0",
|
"@aws-sdk/s3-request-presigner": "^3.700.0",
|
||||||
"@google-cloud/vertexai": "^1.9.0"
|
"@google-cloud/vertexai": "^1.9.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"dompurify": "^3.4.1",
|
||||||
|
"jsdom": "^29.0.2"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@
|
||||||
visible and the right pane flips between reader/editor. -->
|
visible and the right pane flips between reader/editor. -->
|
||||||
<div class="notes-layout" id="notes-layout" data-view="list">
|
<div class="notes-layout" id="notes-layout" data-view="list">
|
||||||
|
|
||||||
<!-- Left pane: list + new-note button + search -->
|
<!-- Left pane: list + new-note button + search + trash toggle -->
|
||||||
<aside class="notes-sidebar">
|
<aside class="notes-sidebar">
|
||||||
<div class="notes-sidebar-head">
|
<div class="notes-sidebar-head">
|
||||||
<button id="btn-notes-new" class="btn-primary notes-new-btn" type="button">
|
<button id="btn-notes-new" class="btn-primary notes-new-btn" type="button">
|
||||||
|
|
@ -18,10 +18,24 @@
|
||||||
<i class="fas fa-search"></i>
|
<i class="fas fa-search"></i>
|
||||||
<input type="text" id="notes-search" placeholder="Search" autocomplete="off">
|
<input type="text" id="notes-search" placeholder="Search" autocomplete="off">
|
||||||
</div>
|
</div>
|
||||||
|
<div class="notes-tabs">
|
||||||
|
<button id="btn-notes-tab-active" class="notes-tab-btn active" type="button" data-view="active">
|
||||||
|
Notes
|
||||||
|
</button>
|
||||||
|
<button id="btn-notes-tab-trash" class="notes-tab-btn" type="button" data-view="trash">
|
||||||
|
<i class="fas fa-trash"></i> Trash <span class="notes-trash-count" id="notes-trash-count"></span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div id="notes-list" class="notes-list">
|
<div id="notes-list" class="notes-list">
|
||||||
<div class="notes-empty">Loading…</div>
|
<div class="notes-empty">Loading…</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div id="notes-trash-foot" class="notes-trash-foot hidden">
|
||||||
|
<button id="btn-notes-trash-empty" class="btn-sm" type="button"
|
||||||
|
style="background:var(--red-light);color:var(--red);border:1px solid var(--red);">
|
||||||
|
<i class="fas fa-fire"></i> Empty trash
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
<!-- Reader pane — shown by default after opening any saved note -->
|
<!-- Reader pane — shown by default after opening any saved note -->
|
||||||
|
|
|
||||||
|
|
@ -1022,3 +1022,26 @@ textarea.full-input{resize:vertical;}
|
||||||
@media (min-width:901px){
|
@media (min-width:901px){
|
||||||
.notes-layout[data-view] .notes-sidebar{display:flex !important;}
|
.notes-layout[data-view] .notes-sidebar{display:flex !important;}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Notes: trash + tabs ──────────────────────────────────────── */
|
||||||
|
.notes-tabs{display:flex;gap:4px;margin-top:6px;}
|
||||||
|
.notes-tab-btn{flex:1;padding:6px 10px;background:transparent;border:1px solid var(--g300);border-radius:6px;font-size:12px;font-weight:500;color:var(--g600);cursor:pointer;font-family:inherit;display:inline-flex;align-items:center;justify-content:center;gap:4px;transition:background 0.1s;}
|
||||||
|
.notes-tab-btn:hover{background:var(--g100);}
|
||||||
|
.notes-tab-btn.active{background:var(--blue-light);color:var(--blue);border-color:var(--blue);}
|
||||||
|
.notes-trash-count{font-size:11px;color:var(--g500);margin-left:2px;}
|
||||||
|
.notes-tab-btn.active .notes-trash-count{color:var(--blue);}
|
||||||
|
|
||||||
|
.notes-trash-foot{padding:8px 10px;border-top:1px solid var(--g200);background:var(--g50);}
|
||||||
|
.notes-trash-foot.hidden{display:none;}
|
||||||
|
.notes-trash-foot .btn-sm{width:100%;justify-content:center;}
|
||||||
|
|
||||||
|
.notes-list-row{position:relative;display:flex;align-items:stretch;gap:0;}
|
||||||
|
.notes-list-row .notes-list-item{flex:1;min-width:0;}
|
||||||
|
.notes-trash-actions{display:flex;flex-direction:column;gap:4px;padding:6px 6px 6px 0;align-items:stretch;}
|
||||||
|
.notes-restore-btn,.notes-hard-delete-btn{font-size:11px;font-weight:500;padding:4px 8px;border:1px solid var(--g300);border-radius:6px;background:white;color:var(--g700);cursor:pointer;font-family:inherit;display:inline-flex;align-items:center;gap:4px;}
|
||||||
|
.notes-restore-btn:hover{background:var(--blue-light);color:var(--blue);border-color:var(--blue);}
|
||||||
|
.notes-hard-delete-btn{padding:4px 6px;color:var(--red);border-color:var(--red-light);}
|
||||||
|
.notes-hard-delete-btn:hover{background:var(--red);color:white;border-color:var(--red);}
|
||||||
|
|
||||||
|
/* In trash view, list items aren't clickable as drafts — make that visible */
|
||||||
|
.notes-list[data-pane="trash"] .notes-list-item{cursor:default;opacity:0.85;}
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,9 @@
|
||||||
|
|
||||||
(function() {
|
(function() {
|
||||||
var _inited = false;
|
var _inited = false;
|
||||||
var _notes = [];
|
var _notes = []; // active notes (deleted_at IS NULL)
|
||||||
|
var _trash = []; // trashed notes (deleted_at IS NOT NULL)
|
||||||
|
var _pane = 'active'; // 'active' | 'trash' — drives the list view
|
||||||
var _activeId = null; // currently open note id (reader OR editor)
|
var _activeId = null; // currently open note id (reader OR editor)
|
||||||
var _editor = null; // Tiptap instance
|
var _editor = null; // Tiptap instance
|
||||||
var _search = '';
|
var _search = '';
|
||||||
|
|
@ -67,12 +69,39 @@
|
||||||
renderList();
|
renderList();
|
||||||
});
|
});
|
||||||
listEl.addEventListener('click', function(e) {
|
listEl.addEventListener('click', function(e) {
|
||||||
|
// Trash-view actions get handled by their own classes; route them
|
||||||
|
// first so a click on Restore or × doesn't also open the note.
|
||||||
|
var restoreBtn = e.target.closest('.notes-restore-btn');
|
||||||
|
if (restoreBtn) {
|
||||||
|
e.stopPropagation();
|
||||||
|
restoreNote(parseInt(restoreBtn.dataset.id));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var hardBtn = e.target.closest('.notes-hard-delete-btn');
|
||||||
|
if (hardBtn) {
|
||||||
|
e.stopPropagation();
|
||||||
|
hardDeleteNote(parseInt(hardBtn.dataset.id));
|
||||||
|
return;
|
||||||
|
}
|
||||||
var item = e.target.closest('.notes-list-item');
|
var item = e.target.closest('.notes-list-item');
|
||||||
if (!item) return;
|
if (!item) return;
|
||||||
var id = parseInt(item.dataset.id);
|
var id = parseInt(item.dataset.id);
|
||||||
if (id) openReader(id);
|
if (!id) return;
|
||||||
|
if (_pane === 'active') openReader(id);
|
||||||
|
// In trash, clicking the row body is a no-op — explicit Restore /
|
||||||
|
// delete-forever buttons are the only actions, so a stray click
|
||||||
|
// can't accidentally open a stale draft.
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Active <-> Trash tab toggle
|
||||||
|
var tabActive = $('btn-notes-tab-active');
|
||||||
|
var tabTrash = $('btn-notes-tab-trash');
|
||||||
|
if (tabActive) tabActive.addEventListener('click', function() { switchPane('active'); });
|
||||||
|
if (tabTrash) tabTrash.addEventListener('click', function() { switchPane('trash'); });
|
||||||
|
|
||||||
|
var emptyTrashBtn = $('btn-notes-trash-empty');
|
||||||
|
if (emptyTrashBtn) emptyTrashBtn.addEventListener('click', emptyTrash);
|
||||||
|
|
||||||
// Empty-state CTA
|
// Empty-state CTA
|
||||||
var newFromEmpty = $('btn-notes-new-empty');
|
var newFromEmpty = $('btn-notes-new-empty');
|
||||||
if (newFromEmpty) newFromEmpty.addEventListener('click', function() { startCreate(); });
|
if (newFromEmpty) newFromEmpty.addEventListener('click', function() { startCreate(); });
|
||||||
|
|
@ -179,50 +208,140 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Data fetching ─────────────────────────────────────────
|
// ── Data fetching ─────────────────────────────────────────
|
||||||
|
// Always pulls both lists in parallel so the trash-count badge stays
|
||||||
|
// current regardless of which pane the user has open.
|
||||||
function refreshList() {
|
function refreshList() {
|
||||||
var listEl = $('notes-list');
|
var listEl = $('notes-list');
|
||||||
if (!listEl) return;
|
if (!listEl) return;
|
||||||
listEl.innerHTML = '<div class="notes-empty">Loading…</div>';
|
listEl.innerHTML = '<div class="notes-empty">Loading…</div>';
|
||||||
fetch('/api/notes', { headers: getAuthHeaders(), credentials: 'include' })
|
Promise.all([
|
||||||
.then(function(r) { return r.json(); })
|
fetch('/api/notes', { headers: getAuthHeaders(), credentials: 'include' }).then(function(r) { return r.json(); }),
|
||||||
.then(function(data) {
|
fetch('/api/notes/trash', { headers: getAuthHeaders(), credentials: 'include' }).then(function(r) { return r.json(); }),
|
||||||
if (!data.success) { listEl.innerHTML = '<div class="notes-empty">' + esc(data.error || 'Failed to load') + '</div>'; return; }
|
])
|
||||||
_notes = data.notes || [];
|
.then(function(results) {
|
||||||
|
var active = results[0], trash = results[1];
|
||||||
|
if (active && active.success) _notes = active.notes || [];
|
||||||
|
if (trash && trash.success) _trash = trash.notes || [];
|
||||||
renderList();
|
renderList();
|
||||||
|
updateTrashCount();
|
||||||
})
|
})
|
||||||
.catch(function(err) {
|
.catch(function(err) {
|
||||||
listEl.innerHTML = '<div class="notes-empty">' + esc(err.message || 'Load failed') + '</div>';
|
listEl.innerHTML = '<div class="notes-empty">' + esc(err.message || 'Load failed') + '</div>';
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function switchPane(pane) {
|
||||||
|
if (pane === _pane) return;
|
||||||
|
_pane = pane;
|
||||||
|
var tabA = $('btn-notes-tab-active');
|
||||||
|
var tabT = $('btn-notes-tab-trash');
|
||||||
|
if (tabA) tabA.classList.toggle('active', pane === 'active');
|
||||||
|
if (tabT) tabT.classList.toggle('active', pane === 'trash');
|
||||||
|
var foot = $('notes-trash-foot');
|
||||||
|
if (foot) foot.classList.toggle('hidden', pane !== 'trash');
|
||||||
|
// Switching to trash clears any open editor — trashed notes are
|
||||||
|
// read-only previews, opening them is intentionally disabled.
|
||||||
|
if (pane === 'trash') closeToList();
|
||||||
|
renderList();
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateTrashCount() {
|
||||||
|
var el = $('notes-trash-count');
|
||||||
|
if (!el) return;
|
||||||
|
el.textContent = _trash.length ? '(' + _trash.length + ')' : '';
|
||||||
|
}
|
||||||
|
|
||||||
function renderList() {
|
function renderList() {
|
||||||
var listEl = $('notes-list');
|
var listEl = $('notes-list');
|
||||||
if (!listEl) return;
|
if (!listEl) return;
|
||||||
var filtered = _notes;
|
var source = _pane === 'trash' ? _trash : _notes;
|
||||||
|
var filtered = source;
|
||||||
if (_search) {
|
if (_search) {
|
||||||
filtered = _notes.filter(function(n) {
|
filtered = source.filter(function(n) {
|
||||||
var hay = (n.title + ' ' + stripTags(n.body || '')).toLowerCase();
|
var hay = (n.title + ' ' + stripTags(n.body || '')).toLowerCase();
|
||||||
return hay.indexOf(_search) !== -1;
|
return hay.indexOf(_search) !== -1;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (filtered.length === 0) {
|
if (filtered.length === 0) {
|
||||||
listEl.innerHTML = '<div class="notes-empty">'
|
listEl.innerHTML = '<div class="notes-empty">'
|
||||||
+ (_search ? 'No matches' : '')
|
+ (_search ? 'No matches' : (_pane === 'trash' ? 'Trash is empty' : ''))
|
||||||
+ '</div>';
|
+ '</div>';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
listEl.innerHTML = filtered.map(function(n) {
|
listEl.innerHTML = filtered.map(function(n) {
|
||||||
var snippet = stripTags(n.body || '').substring(0, 110);
|
var snippet = stripTags(n.body || '').substring(0, 110);
|
||||||
var when = formatWhen(n.updated_at);
|
var when = formatWhen(_pane === 'trash' ? n.deleted_at : n.updated_at);
|
||||||
var active = (n.id === _activeId) ? ' active' : '';
|
var active = (n.id === _activeId && _pane === 'active') ? ' active' : '';
|
||||||
return '<button type="button" class="notes-list-item' + active + '" data-id="' + n.id + '">'
|
var trashActions = _pane === 'trash'
|
||||||
+ '<div class="notes-list-title">' + esc(n.title || 'Untitled') + '</div>'
|
? '<div class="notes-trash-actions">'
|
||||||
+ '<div class="notes-list-snippet">' + esc(snippet) + '</div>'
|
+ '<button type="button" class="notes-restore-btn" data-id="' + n.id + '" title="Restore">'
|
||||||
+ '<div class="notes-list-when">' + esc(when) + '</div>'
|
+ '<i class="fas fa-rotate-left"></i> Restore'
|
||||||
+ '</button>';
|
+ '</button>'
|
||||||
|
+ '<button type="button" class="notes-hard-delete-btn" data-id="' + n.id + '" title="Delete forever">'
|
||||||
|
+ '<i class="fas fa-times"></i>'
|
||||||
|
+ '</button>'
|
||||||
|
+ '</div>'
|
||||||
|
: '';
|
||||||
|
return '<div class="notes-list-row">'
|
||||||
|
+ '<button type="button" class="notes-list-item' + active + '" data-id="' + n.id + '">'
|
||||||
|
+ '<div class="notes-list-title">' + esc(n.title || 'Untitled') + '</div>'
|
||||||
|
+ '<div class="notes-list-snippet">' + esc(snippet) + '</div>'
|
||||||
|
+ '<div class="notes-list-when">'
|
||||||
|
+ (_pane === 'trash' ? 'Deleted ' : '') + esc(when)
|
||||||
|
+ '</div>'
|
||||||
|
+ '</button>'
|
||||||
|
+ trashActions
|
||||||
|
+ '</div>';
|
||||||
}).join('');
|
}).join('');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function restoreNote(id) {
|
||||||
|
fetch('/api/notes/' + id + '/restore', {
|
||||||
|
method: 'POST', credentials: 'include', headers: getAuthHeaders(),
|
||||||
|
})
|
||||||
|
.then(function(r) { return r.json(); })
|
||||||
|
.then(function(data) {
|
||||||
|
if (!data.success) { if (typeof showToast === 'function') showToast(data.error || 'Restore failed', 'error'); return; }
|
||||||
|
if (typeof showToast === 'function') showToast('Note restored', 'success');
|
||||||
|
refreshList();
|
||||||
|
})
|
||||||
|
.catch(function(err) {
|
||||||
|
if (typeof showToast === 'function') showToast(err.message || 'Restore failed', 'error');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function hardDeleteNote(id) {
|
||||||
|
if (typeof showConfirm !== 'function') return;
|
||||||
|
showConfirm('Delete forever? This cannot be undone.', function() {
|
||||||
|
fetch('/api/notes/' + id + '?hard=1', {
|
||||||
|
method: 'DELETE', credentials: 'include', headers: getAuthHeaders(),
|
||||||
|
})
|
||||||
|
.then(function(r) { return r.json(); })
|
||||||
|
.then(function(data) {
|
||||||
|
if (!data.success) { if (typeof showToast === 'function') showToast(data.error || 'Delete failed', 'error'); return; }
|
||||||
|
refreshList();
|
||||||
|
});
|
||||||
|
}, { danger: true, confirmText: 'Delete forever' });
|
||||||
|
}
|
||||||
|
|
||||||
|
function emptyTrash() {
|
||||||
|
if (typeof showConfirm !== 'function') return;
|
||||||
|
if (_trash.length === 0) return;
|
||||||
|
showConfirm('Empty the trash? This will permanently delete ' + _trash.length + ' note' + (_trash.length === 1 ? '' : 's') + '.',
|
||||||
|
function() {
|
||||||
|
fetch('/api/notes/trash/empty', {
|
||||||
|
method: 'POST', credentials: 'include', headers: getAuthHeaders(),
|
||||||
|
})
|
||||||
|
.then(function(r) { return r.json(); })
|
||||||
|
.then(function(data) {
|
||||||
|
if (!data.success) { if (typeof showToast === 'function') showToast(data.error || 'Empty trash failed', 'error'); return; }
|
||||||
|
if (typeof showToast === 'function') showToast('Trash emptied', 'success');
|
||||||
|
refreshList();
|
||||||
|
});
|
||||||
|
},
|
||||||
|
{ danger: true, confirmText: 'Empty trash' });
|
||||||
|
}
|
||||||
|
|
||||||
// ── Reader mode (default after opening or saving) ─────────
|
// ── Reader mode (default after opening or saving) ─────────
|
||||||
function openReader(id) {
|
function openReader(id) {
|
||||||
flushAutosave();
|
flushAutosave();
|
||||||
|
|
@ -386,29 +505,27 @@
|
||||||
var note = _notes.find(function(n) { return n.id === _activeId; });
|
var note = _notes.find(function(n) { return n.id === _activeId; });
|
||||||
var label = (note && note.title) ? note.title : 'this note';
|
var label = (note && note.title) ? note.title : 'this note';
|
||||||
|
|
||||||
var doDelete = function() {
|
// Soft-delete: row gets deleted_at timestamp and moves to Trash.
|
||||||
|
// The user can Restore from there or Empty trash to actually erase.
|
||||||
|
var doMove = function() {
|
||||||
flushAutosave();
|
flushAutosave();
|
||||||
fetch('/api/notes/' + _activeId, { method: 'DELETE', headers: getAuthHeaders(), credentials: 'include' })
|
fetch('/api/notes/' + _activeId, { method: 'DELETE', headers: getAuthHeaders(), credentials: 'include' })
|
||||||
.then(function(r) { return r.json(); })
|
.then(function(r) { return r.json(); })
|
||||||
.then(function(data) {
|
.then(function(data) {
|
||||||
if (!data.success) { updateStatus(data.error || 'Delete failed', 'err'); return; }
|
if (!data.success) { updateStatus(data.error || 'Move to trash failed', 'err'); return; }
|
||||||
_notes = _notes.filter(function(n) { return n.id !== _activeId; });
|
_notes = _notes.filter(function(n) { return n.id !== _activeId; });
|
||||||
_activeId = null;
|
_activeId = null;
|
||||||
_dirty = false;
|
_dirty = false;
|
||||||
if (_editor) { _editor.destroy(); _editor = null; }
|
if (_editor) { _editor.destroy(); _editor = null; }
|
||||||
closeToList();
|
closeToList();
|
||||||
if (typeof showToast === 'function') showToast('Note deleted', 'success');
|
if (typeof showToast === 'function') showToast('Moved to trash', 'success');
|
||||||
|
refreshList();
|
||||||
})
|
})
|
||||||
.catch(function(err) { updateStatus(err.message || 'Delete failed', 'err'); });
|
.catch(function(err) { updateStatus(err.message || 'Move to trash failed', 'err'); });
|
||||||
};
|
};
|
||||||
|
|
||||||
// showConfirm is loaded by app.js before any tab activates, so the
|
|
||||||
// "if missing" fallback used to be a native window.confirm() —
|
|
||||||
// dropped here because Daniel's rule is no native dialogs anywhere
|
|
||||||
// in the frontend. If showConfirm is somehow unavailable, the click
|
|
||||||
// silently no-ops; the user can try again after a reload.
|
|
||||||
if (typeof showConfirm === 'function') {
|
if (typeof showConfirm === 'function') {
|
||||||
showConfirm('Delete "' + label + '"?', doDelete, { danger: true, confirmText: 'Delete' });
|
showConfirm('Move "' + label + '" to trash?', doMove, { confirmText: 'Move to trash' });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -713,36 +830,26 @@
|
||||||
var d = document.createElement('div'); d.innerHTML = html;
|
var d = document.createElement('div'); d.innerHTML = html;
|
||||||
return (d.textContent || d.innerText || '').replace(/\s+/g, ' ').trim();
|
return (d.textContent || d.innerText || '').replace(/\s+/g, ' ').trim();
|
||||||
}
|
}
|
||||||
// Allowlist-based sanitizer for reader view. The body is also
|
// Sanitize via DOMPurify (loaded from cdnjs in index.html, also used by
|
||||||
// encrypted in the DB and only rendered back to the same user
|
// learningHub.js). DOMPurify's allowlist + attribute filter is the right
|
||||||
// who wrote it, so the threat model here is "clean up your own
|
// primitive — homegrown regex/walker sanitizers historically have bypasses.
|
||||||
// pasted HTML" rather than hostile content — but we still drop
|
// If DOMPurify somehow fails to load, refuse to render HTML rather than
|
||||||
// script/event-handler/iframe/object to be safe.
|
// falling back to a hand-rolled walker.
|
||||||
var ALLOWED_TAGS = /^(p|br|strong|em|b|i|u|s|a|ul|ol|li|blockquote|h2|h3|h4|code|pre|hr)$/i;
|
|
||||||
function sanitizeHtml(html) {
|
function sanitizeHtml(html) {
|
||||||
var doc = document.createElement('div'); doc.innerHTML = html;
|
if (!window.DOMPurify || typeof window.DOMPurify.sanitize !== 'function') {
|
||||||
(function walk(node) {
|
console.warn('[notes] DOMPurify unavailable — rendering as plain text.');
|
||||||
var children = Array.from(node.children);
|
var d = document.createElement('div');
|
||||||
children.forEach(function(el) {
|
d.textContent = String(html == null ? '' : html);
|
||||||
if (!ALLOWED_TAGS.test(el.tagName)) {
|
return d.innerHTML;
|
||||||
// Replace with its text content
|
}
|
||||||
var text = document.createTextNode(el.textContent || '');
|
return window.DOMPurify.sanitize(html, {
|
||||||
el.parentNode.replaceChild(text, el);
|
ALLOWED_TAGS: ['p','br','strong','em','b','i','u','s','h2','h3','h4',
|
||||||
return;
|
'ul','ol','li','a','blockquote','code','pre','hr'],
|
||||||
}
|
ALLOWED_ATTR: ['href','target','rel'],
|
||||||
// Strip every attribute except href on <a>
|
ADD_ATTR: ['target'],
|
||||||
Array.from(el.attributes || []).forEach(function(attr) {
|
FORBID_ATTR: ['style','onerror','onload','onclick','onmouseover'],
|
||||||
if (el.tagName.toLowerCase() === 'a' && attr.name === 'href') {
|
ALLOW_DATA_ATTR: false
|
||||||
if (!/^(https?:|mailto:|#)/i.test(attr.value)) el.removeAttribute('href');
|
});
|
||||||
} else {
|
|
||||||
el.removeAttribute(attr.name);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
if (el.tagName.toLowerCase() === 'a') { el.setAttribute('target', '_blank'); el.setAttribute('rel', 'noopener noreferrer'); }
|
|
||||||
walk(el);
|
|
||||||
});
|
|
||||||
})(doc);
|
|
||||||
return doc.innerHTML;
|
|
||||||
}
|
}
|
||||||
function formatWhen(iso) {
|
function formatWhen(iso) {
|
||||||
if (!iso) return '';
|
if (!iso) return '';
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@
|
||||||
// API calls always fresh (critical for medical data accuracy)
|
// API calls always fresh (critical for medical data accuracy)
|
||||||
// ============================================================
|
// ============================================================
|
||||||
|
|
||||||
var CACHE_NAME = 'pedscribe-v12-notes4';
|
var CACHE_NAME = 'pedscribe-v12-notes5';
|
||||||
var SHELL_ASSETS = [
|
var SHELL_ASSETS = [
|
||||||
'/',
|
'/',
|
||||||
'/index.html',
|
'/index.html',
|
||||||
|
|
|
||||||
|
|
@ -27,14 +27,11 @@ function decryptRow(row) {
|
||||||
return row;
|
return row;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── GET list ────────────────────────────────────────────────
|
// ── GET list (active only — items in trash are filtered out) ────
|
||||||
// Returns all notes for the current user, newest-updated first.
|
|
||||||
// Title + body are decrypted on the way out; caller renders body
|
|
||||||
// HTML through the existing sanitize-html pipeline on display.
|
|
||||||
router.get('/notes', async function (req, res) {
|
router.get('/notes', async function (req, res) {
|
||||||
try {
|
try {
|
||||||
var rows = await db.all(
|
var rows = await db.all(
|
||||||
'SELECT id, title, body, created_at, updated_at FROM personal_notes WHERE user_id = $1 ORDER BY updated_at DESC',
|
'SELECT id, title, body, created_at, updated_at FROM personal_notes WHERE user_id = $1 AND deleted_at IS NULL ORDER BY updated_at DESC',
|
||||||
[req.user.id]
|
[req.user.id]
|
||||||
);
|
);
|
||||||
rows.forEach(decryptRow);
|
rows.forEach(decryptRow);
|
||||||
|
|
@ -45,6 +42,21 @@ router.get('/notes', async function (req, res) {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── GET trash list — items the user soft-deleted, newest-deleted first ──
|
||||||
|
router.get('/notes/trash', async function (req, res) {
|
||||||
|
try {
|
||||||
|
var rows = await db.all(
|
||||||
|
'SELECT id, title, body, created_at, updated_at, deleted_at FROM personal_notes WHERE user_id = $1 AND deleted_at IS NOT NULL ORDER BY deleted_at DESC',
|
||||||
|
[req.user.id]
|
||||||
|
);
|
||||||
|
rows.forEach(decryptRow);
|
||||||
|
res.json({ success: true, notes: rows });
|
||||||
|
} catch (e) {
|
||||||
|
logger.error('GET /notes/trash', e.message);
|
||||||
|
res.status(500).json({ error: 'Request failed' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// ── GET one ─────────────────────────────────────────────────
|
// ── GET one ─────────────────────────────────────────────────
|
||||||
router.get('/notes/:id', async function (req, res) {
|
router.get('/notes/:id', async function (req, res) {
|
||||||
try {
|
try {
|
||||||
|
|
@ -114,18 +126,71 @@ router.put('/notes/:id', async function (req, res) {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── DELETE ──────────────────────────────────────────────────
|
// ── DELETE = soft-delete (move to trash) ───────────────────────
|
||||||
|
// Sets deleted_at = NOW(); the row stays in personal_notes but is
|
||||||
|
// filtered out of the active list. Use POST /notes/:id/restore to
|
||||||
|
// undo, or DELETE /notes/:id?hard=1 to actually remove.
|
||||||
router.delete('/notes/:id', async function (req, res) {
|
router.delete('/notes/:id', async function (req, res) {
|
||||||
try {
|
try {
|
||||||
await db.run('DELETE FROM personal_notes WHERE id = $1 AND user_id = $2', [req.params.id, req.user.id]);
|
var hard = req.query.hard === '1' || req.query.hard === 'true';
|
||||||
|
if (hard) {
|
||||||
|
// Hard-delete only items already in trash, so a UI bug can't
|
||||||
|
// accidentally erase an active note.
|
||||||
|
await db.run(
|
||||||
|
'DELETE FROM personal_notes WHERE id = $1 AND user_id = $2 AND deleted_at IS NOT NULL',
|
||||||
|
[req.params.id, req.user.id]
|
||||||
|
);
|
||||||
|
logger.audit(req.user.id, 'delete_note_hard', 'Permanently deleted personal note', req, { category: 'notes' });
|
||||||
|
} else {
|
||||||
|
await db.run(
|
||||||
|
'UPDATE personal_notes SET deleted_at = NOW() WHERE id = $1 AND user_id = $2 AND deleted_at IS NULL',
|
||||||
|
[req.params.id, req.user.id]
|
||||||
|
);
|
||||||
|
logger.audit(req.user.id, 'delete_note', 'Moved personal note to trash', req, { category: 'notes' });
|
||||||
|
}
|
||||||
res.json({ success: true });
|
res.json({ success: true });
|
||||||
logger.audit(req.user.id, 'delete_note', 'Deleted personal note', req, { category: 'notes' });
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
logger.error('DELETE /notes/:id', e.message);
|
logger.error('DELETE /notes/:id', e.message);
|
||||||
res.status(500).json({ error: 'Request failed' });
|
res.status(500).json({ error: 'Request failed' });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── POST /notes/:id/restore — pull a note out of trash ─────────
|
||||||
|
router.post('/notes/:id/restore', async function (req, res) {
|
||||||
|
try {
|
||||||
|
var existing = await db.get(
|
||||||
|
'SELECT id FROM personal_notes WHERE id = $1 AND user_id = $2 AND deleted_at IS NOT NULL',
|
||||||
|
[req.params.id, req.user.id]
|
||||||
|
);
|
||||||
|
if (!existing) return res.status(404).json({ error: 'Note not in trash' });
|
||||||
|
|
||||||
|
await db.run(
|
||||||
|
'UPDATE personal_notes SET deleted_at = NULL, updated_at = NOW() WHERE id = $1 AND user_id = $2',
|
||||||
|
[req.params.id, req.user.id]
|
||||||
|
);
|
||||||
|
res.json({ success: true });
|
||||||
|
logger.audit(req.user.id, 'restore_note', 'Restored personal note from trash', req, { category: 'notes' });
|
||||||
|
} catch (e) {
|
||||||
|
logger.error('POST /notes/:id/restore', e.message);
|
||||||
|
res.status(500).json({ error: 'Request failed' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── POST /notes/trash/empty — hard-delete every trashed note for the user ─
|
||||||
|
router.post('/notes/trash/empty', async function (req, res) {
|
||||||
|
try {
|
||||||
|
var result = await db.run(
|
||||||
|
'DELETE FROM personal_notes WHERE user_id = $1 AND deleted_at IS NOT NULL',
|
||||||
|
[req.user.id]
|
||||||
|
);
|
||||||
|
res.json({ success: true, removed: result.changes || 0 });
|
||||||
|
logger.audit(req.user.id, 'empty_notes_trash', 'Emptied notes trash', req, { category: 'notes' });
|
||||||
|
} catch (e) {
|
||||||
|
logger.error('POST /notes/trash/empty', e.message);
|
||||||
|
res.status(500).json({ error: 'Request failed' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// ── POST /api/notes/from-voice ──────────────────────────────
|
// ── POST /api/notes/from-voice ──────────────────────────────
|
||||||
// Takes a raw voice transcript (produced by the shared
|
// Takes a raw voice transcript (produced by the shared
|
||||||
// /api/transcribe endpoint — whichever STT provider the admin
|
// /api/transcribe endpoint — whichever STT provider the admin
|
||||||
|
|
|
||||||
110
test/notes-sanitize.test.js
Normal file
110
test/notes-sanitize.test.js
Normal file
|
|
@ -0,0 +1,110 @@
|
||||||
|
// ============================================================
|
||||||
|
// Contract test for the Notes reader sanitizer. Confirms that
|
||||||
|
// DOMPurify (loaded via jsdom here) strips the payloads we care
|
||||||
|
// about — script tags, inline event handlers, iframes, object
|
||||||
|
// tags — while preserving the formatting tags we DO allow.
|
||||||
|
//
|
||||||
|
// Why this test exists:
|
||||||
|
// The original Notes module used a homegrown walker-based
|
||||||
|
// sanitizer. When the body round-trips through localStorage or
|
||||||
|
// a compromised row, a bypass in the sanitizer means session
|
||||||
|
// theft. We swapped to DOMPurify; this test holds the contract
|
||||||
|
// so a regression is loud.
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
const { test } = require('node:test');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
const { JSDOM } = require('jsdom');
|
||||||
|
const createDOMPurify = require('dompurify');
|
||||||
|
|
||||||
|
// Match the allowlist used in public/js/notes.js `sanitizeHtml`.
|
||||||
|
const ALLOWED_TAGS = ['p','br','strong','em','b','i','u','s','h2','h3','h4',
|
||||||
|
'ul','ol','li','a','blockquote','code','pre','hr'];
|
||||||
|
const ALLOWED_ATTR = ['href','target','rel'];
|
||||||
|
|
||||||
|
function makeSanitizer() {
|
||||||
|
const window = new JSDOM('').window;
|
||||||
|
const DOMPurify = createDOMPurify(window);
|
||||||
|
return function sanitize(html) {
|
||||||
|
return DOMPurify.sanitize(html, {
|
||||||
|
ALLOWED_TAGS, ALLOWED_ATTR,
|
||||||
|
FORBID_ATTR: ['style','onerror','onload','onclick','onmouseover'],
|
||||||
|
ALLOW_DATA_ATTR: false,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
test('strips <script> tags completely', () => {
|
||||||
|
const sanitize = makeSanitizer();
|
||||||
|
const out = sanitize('<p>ok</p><script>alert(1)</script>');
|
||||||
|
assert.ok(!out.includes('<script'));
|
||||||
|
assert.ok(!out.includes('alert'));
|
||||||
|
assert.match(out, /<p>ok<\/p>/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('strips inline event handlers on otherwise-allowed tags', () => {
|
||||||
|
const sanitize = makeSanitizer();
|
||||||
|
const out = sanitize('<a href="https://x" onclick="steal()">link</a>');
|
||||||
|
assert.ok(!out.includes('onclick'));
|
||||||
|
assert.ok(!out.includes('steal'));
|
||||||
|
assert.match(out, /href="https:\/\/x"/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('drops <img onerror="..."> — img is not in the allowlist', () => {
|
||||||
|
const sanitize = makeSanitizer();
|
||||||
|
const out = sanitize('<img src=x onerror="alert(1)">');
|
||||||
|
assert.ok(!out.includes('<img'));
|
||||||
|
assert.ok(!out.includes('onerror'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('drops <iframe> and <object> (framing/plugin injection)', () => {
|
||||||
|
const sanitize = makeSanitizer();
|
||||||
|
assert.ok(!sanitize('<iframe src="evil"></iframe>').includes('iframe'));
|
||||||
|
assert.ok(!sanitize('<object data="evil"></object>').includes('object'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('drops style attributes — CSS injection / expression() on older browsers', () => {
|
||||||
|
const sanitize = makeSanitizer();
|
||||||
|
const out = sanitize('<p style="background:url(evil)">text</p>');
|
||||||
|
assert.ok(!out.includes('style'));
|
||||||
|
assert.match(out, /<p>text<\/p>/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('preserves every tag in the allowlist', () => {
|
||||||
|
const sanitize = makeSanitizer();
|
||||||
|
for (const tag of ALLOWED_TAGS) {
|
||||||
|
const html = `<${tag}>x</${tag}>`;
|
||||||
|
const out = sanitize(html);
|
||||||
|
// <br> and <hr> are self-closing; DOMPurify may emit them either way.
|
||||||
|
if (tag === 'br' || tag === 'hr') {
|
||||||
|
assert.match(out, new RegExp(`<${tag}`), `expected <${tag}> preserved`);
|
||||||
|
} else {
|
||||||
|
assert.match(out, new RegExp(`<${tag}>x</${tag}>`), `expected <${tag}>x</${tag}> preserved`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('preserves mailto + https links; drops javascript: URIs', () => {
|
||||||
|
const sanitize = makeSanitizer();
|
||||||
|
assert.match(sanitize('<a href="https://ok">x</a>'), /href="https:\/\/ok"/);
|
||||||
|
assert.match(sanitize('<a href="mailto:a@b.c">x</a>'), /href="mailto:a@b\.c"/);
|
||||||
|
const jsAttempt = sanitize('<a href="javascript:alert(1)">x</a>');
|
||||||
|
assert.ok(!jsAttempt.includes('javascript:'), 'javascript: URI should be stripped');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('empty + null + non-string input does not throw', () => {
|
||||||
|
const sanitize = makeSanitizer();
|
||||||
|
assert.equal(sanitize(''), '');
|
||||||
|
assert.equal(sanitize(null), '');
|
||||||
|
assert.equal(sanitize(undefined), '');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('nested tags: <script> inside <p> still stripped', () => {
|
||||||
|
const sanitize = makeSanitizer();
|
||||||
|
const out = sanitize('<p>safe<script>evil()</script>still safe</p>');
|
||||||
|
assert.ok(!out.includes('<script'));
|
||||||
|
assert.ok(!out.includes('evil()'));
|
||||||
|
// Text content before + after the stripped tag should survive
|
||||||
|
assert.match(out, /safe/);
|
||||||
|
assert.match(out, /still safe/);
|
||||||
|
});
|
||||||
Loading…
Reference in a new issue