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-underline": "^3.20.4",
|
||||
"@tiptap/starter-kit": "^3.20.4",
|
||||
"axios": "^1.7.7",
|
||||
"argon2": "^0.41.1",
|
||||
"axios": "^1.7.7",
|
||||
"bcryptjs": "^2.4.3",
|
||||
"cookie-parser": "^1.4.7",
|
||||
"cors": "^2.8.5",
|
||||
|
|
@ -54,5 +54,9 @@
|
|||
"@aws-sdk/client-transcribe-streaming": "^3.1017.0",
|
||||
"@aws-sdk/s3-request-presigner": "^3.700.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. -->
|
||||
<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">
|
||||
<div class="notes-sidebar-head">
|
||||
<button id="btn-notes-new" class="btn-primary notes-new-btn" type="button">
|
||||
|
|
@ -18,10 +18,24 @@
|
|||
<i class="fas fa-search"></i>
|
||||
<input type="text" id="notes-search" placeholder="Search" autocomplete="off">
|
||||
</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 id="notes-list" class="notes-list">
|
||||
<div class="notes-empty">Loading…</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>
|
||||
|
||||
<!-- Reader pane — shown by default after opening any saved note -->
|
||||
|
|
|
|||
|
|
@ -1022,3 +1022,26 @@ textarea.full-input{resize:vertical;}
|
|||
@media (min-width:901px){
|
||||
.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() {
|
||||
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 _editor = null; // Tiptap instance
|
||||
var _search = '';
|
||||
|
|
@ -67,12 +69,39 @@
|
|||
renderList();
|
||||
});
|
||||
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');
|
||||
if (!item) return;
|
||||
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
|
||||
var newFromEmpty = $('btn-notes-new-empty');
|
||||
if (newFromEmpty) newFromEmpty.addEventListener('click', function() { startCreate(); });
|
||||
|
|
@ -179,50 +208,140 @@
|
|||
}
|
||||
|
||||
// ── 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() {
|
||||
var listEl = $('notes-list');
|
||||
if (!listEl) return;
|
||||
listEl.innerHTML = '<div class="notes-empty">Loading…</div>';
|
||||
fetch('/api/notes', { headers: getAuthHeaders(), credentials: 'include' })
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(data) {
|
||||
if (!data.success) { listEl.innerHTML = '<div class="notes-empty">' + esc(data.error || 'Failed to load') + '</div>'; return; }
|
||||
_notes = data.notes || [];
|
||||
Promise.all([
|
||||
fetch('/api/notes', { headers: getAuthHeaders(), credentials: 'include' }).then(function(r) { return r.json(); }),
|
||||
fetch('/api/notes/trash', { headers: getAuthHeaders(), credentials: 'include' }).then(function(r) { return r.json(); }),
|
||||
])
|
||||
.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();
|
||||
updateTrashCount();
|
||||
})
|
||||
.catch(function(err) {
|
||||
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() {
|
||||
var listEl = $('notes-list');
|
||||
if (!listEl) return;
|
||||
var filtered = _notes;
|
||||
var source = _pane === 'trash' ? _trash : _notes;
|
||||
var filtered = source;
|
||||
if (_search) {
|
||||
filtered = _notes.filter(function(n) {
|
||||
filtered = source.filter(function(n) {
|
||||
var hay = (n.title + ' ' + stripTags(n.body || '')).toLowerCase();
|
||||
return hay.indexOf(_search) !== -1;
|
||||
});
|
||||
}
|
||||
if (filtered.length === 0) {
|
||||
listEl.innerHTML = '<div class="notes-empty">'
|
||||
+ (_search ? 'No matches' : '')
|
||||
+ (_search ? 'No matches' : (_pane === 'trash' ? 'Trash is empty' : ''))
|
||||
+ '</div>';
|
||||
return;
|
||||
}
|
||||
listEl.innerHTML = filtered.map(function(n) {
|
||||
var snippet = stripTags(n.body || '').substring(0, 110);
|
||||
var when = formatWhen(n.updated_at);
|
||||
var active = (n.id === _activeId) ? ' active' : '';
|
||||
return '<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">' + esc(when) + '</div>'
|
||||
+ '</button>';
|
||||
var when = formatWhen(_pane === 'trash' ? n.deleted_at : n.updated_at);
|
||||
var active = (n.id === _activeId && _pane === 'active') ? ' active' : '';
|
||||
var trashActions = _pane === 'trash'
|
||||
? '<div class="notes-trash-actions">'
|
||||
+ '<button type="button" class="notes-restore-btn" data-id="' + n.id + '" title="Restore">'
|
||||
+ '<i class="fas fa-rotate-left"></i> Restore'
|
||||
+ '</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('');
|
||||
}
|
||||
|
||||
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) ─────────
|
||||
function openReader(id) {
|
||||
flushAutosave();
|
||||
|
|
@ -386,29 +505,27 @@
|
|||
var note = _notes.find(function(n) { return n.id === _activeId; });
|
||||
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();
|
||||
fetch('/api/notes/' + _activeId, { method: 'DELETE', headers: getAuthHeaders(), credentials: 'include' })
|
||||
.then(function(r) { return r.json(); })
|
||||
.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; });
|
||||
_activeId = null;
|
||||
_dirty = false;
|
||||
if (_editor) { _editor.destroy(); _editor = null; }
|
||||
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') {
|
||||
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;
|
||||
return (d.textContent || d.innerText || '').replace(/\s+/g, ' ').trim();
|
||||
}
|
||||
// Allowlist-based sanitizer for reader view. The body is also
|
||||
// encrypted in the DB and only rendered back to the same user
|
||||
// who wrote it, so the threat model here is "clean up your own
|
||||
// pasted HTML" rather than hostile content — but we still drop
|
||||
// script/event-handler/iframe/object to be safe.
|
||||
var ALLOWED_TAGS = /^(p|br|strong|em|b|i|u|s|a|ul|ol|li|blockquote|h2|h3|h4|code|pre|hr)$/i;
|
||||
// Sanitize via DOMPurify (loaded from cdnjs in index.html, also used by
|
||||
// learningHub.js). DOMPurify's allowlist + attribute filter is the right
|
||||
// primitive — homegrown regex/walker sanitizers historically have bypasses.
|
||||
// If DOMPurify somehow fails to load, refuse to render HTML rather than
|
||||
// falling back to a hand-rolled walker.
|
||||
function sanitizeHtml(html) {
|
||||
var doc = document.createElement('div'); doc.innerHTML = html;
|
||||
(function walk(node) {
|
||||
var children = Array.from(node.children);
|
||||
children.forEach(function(el) {
|
||||
if (!ALLOWED_TAGS.test(el.tagName)) {
|
||||
// Replace with its text content
|
||||
var text = document.createTextNode(el.textContent || '');
|
||||
el.parentNode.replaceChild(text, el);
|
||||
return;
|
||||
}
|
||||
// Strip every attribute except href on <a>
|
||||
Array.from(el.attributes || []).forEach(function(attr) {
|
||||
if (el.tagName.toLowerCase() === 'a' && attr.name === 'href') {
|
||||
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;
|
||||
if (!window.DOMPurify || typeof window.DOMPurify.sanitize !== 'function') {
|
||||
console.warn('[notes] DOMPurify unavailable — rendering as plain text.');
|
||||
var d = document.createElement('div');
|
||||
d.textContent = String(html == null ? '' : html);
|
||||
return d.innerHTML;
|
||||
}
|
||||
return window.DOMPurify.sanitize(html, {
|
||||
ALLOWED_TAGS: ['p','br','strong','em','b','i','u','s','h2','h3','h4',
|
||||
'ul','ol','li','a','blockquote','code','pre','hr'],
|
||||
ALLOWED_ATTR: ['href','target','rel'],
|
||||
ADD_ATTR: ['target'],
|
||||
FORBID_ATTR: ['style','onerror','onload','onclick','onmouseover'],
|
||||
ALLOW_DATA_ATTR: false
|
||||
});
|
||||
}
|
||||
function formatWhen(iso) {
|
||||
if (!iso) return '';
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
// API calls always fresh (critical for medical data accuracy)
|
||||
// ============================================================
|
||||
|
||||
var CACHE_NAME = 'pedscribe-v12-notes4';
|
||||
var CACHE_NAME = 'pedscribe-v12-notes5';
|
||||
var SHELL_ASSETS = [
|
||||
'/',
|
||||
'/index.html',
|
||||
|
|
|
|||
|
|
@ -27,14 +27,11 @@ function decryptRow(row) {
|
|||
return row;
|
||||
}
|
||||
|
||||
// ── GET list ────────────────────────────────────────────────
|
||||
// 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.
|
||||
// ── GET list (active only — items in trash are filtered out) ────
|
||||
router.get('/notes', async function (req, res) {
|
||||
try {
|
||||
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]
|
||||
);
|
||||
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 ─────────────────────────────────────────────────
|
||||
router.get('/notes/:id', async function (req, res) {
|
||||
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) {
|
||||
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 });
|
||||
logger.audit(req.user.id, 'delete_note', 'Deleted personal note', req, { category: 'notes' });
|
||||
} catch (e) {
|
||||
logger.error('DELETE /notes/:id', e.message);
|
||||
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 ──────────────────────────────
|
||||
// Takes a raw voice transcript (produced by the shared
|
||||
// /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