Add audit logging, rate limiting, secret redactor, and backlinks
Implement several security and feature improvements across the backend and frontend: - New IP-based rate limiter for authentication endpoints - New audit logging system for sensitive operations - New secret redactor to mask sensitive patterns in rendered content - Configurable token TTL and IGNORED_DIRS via environment variables - Add backlink index and API endpoint - Add preview tab support with single/double-click behavior in tree - Add file backup before write/delete operations
This commit is contained in:
+144
-3
@@ -2351,10 +2351,15 @@
|
||||
attachTreeItemLongPress(fileItem, () => ({ vault: vaultName, path: item.path, type: "file", isReadonly: false }));
|
||||
fileItem.addEventListener("click", () => {
|
||||
scrollTreeItemIntoView(fileItem, false);
|
||||
openFile(vaultName, item.path);
|
||||
TabManager.openPreview(vaultName, item.path);
|
||||
closeMobileSidebar();
|
||||
});
|
||||
|
||||
fileItem.addEventListener("dblclick", (e) => {
|
||||
e.preventDefault();
|
||||
TabManager.openPersistent(vaultName, item.path);
|
||||
});
|
||||
|
||||
fileItem.addEventListener("contextmenu", (e) => {
|
||||
e.preventDefault();
|
||||
ContextMenuManager.show(e.clientX, e.clientY, vaultName, item.path, "file", false);
|
||||
@@ -3104,9 +3109,66 @@
|
||||
}
|
||||
}
|
||||
|
||||
async function renderBacklinksPanel(vault, path, container) {
|
||||
try {
|
||||
const data = await api(`/api/file/${encodeURIComponent(vault)}/backlinks?path=${encodeURIComponent(path)}`);
|
||||
if (!data.backlinks || data.backlinks.length === 0) return;
|
||||
|
||||
const panel = el("div", { class: "backlinks-panel" });
|
||||
const header = el("div", { class: "backlinks-header" }, [
|
||||
icon("link", 14),
|
||||
document.createTextNode(` ${data.total} lien(s) entrant(s)`),
|
||||
]);
|
||||
panel.appendChild(header);
|
||||
|
||||
const list = el("div", { class: "backlinks-list" });
|
||||
data.backlinks.forEach((bl) => {
|
||||
const item = el("div", { class: "backlink-item" });
|
||||
const vaultBadge = el("span", { class: "backlink-vault" }, [document.createTextNode(bl.vault)]);
|
||||
const titleEl = el("span", { class: "backlink-title" }, [document.createTextNode(bl.title || bl.path.split("/").pop().replace(/\.md$/i, ""))]);
|
||||
item.appendChild(icon(getFileIcon(bl.path), 12));
|
||||
item.appendChild(vaultBadge);
|
||||
item.appendChild(titleEl);
|
||||
item.addEventListener("click", () => TabManager.openPreview(bl.vault, bl.path));
|
||||
item.addEventListener("dblclick", (e) => { e.preventDefault(); TabManager.openPersistent(bl.vault, bl.path); });
|
||||
list.appendChild(item);
|
||||
});
|
||||
panel.appendChild(list);
|
||||
container.appendChild(panel);
|
||||
} catch (err) {
|
||||
// Silently ignore — backlinks are optional
|
||||
console.debug("Backlinks fetch failed:", err);
|
||||
}
|
||||
}
|
||||
|
||||
function renderFile(data) {
|
||||
const area = document.getElementById("content-area");
|
||||
|
||||
// Handle unsupported (binary) files
|
||||
if (data.unsupported) {
|
||||
const sizeStr = data.size_bytes
|
||||
? data.size_bytes < 1024 ? `${data.size_bytes} o`
|
||||
: data.size_bytes < 1048576 ? `${(data.size_bytes / 1024).toFixed(1)} Ko`
|
||||
: `${(data.size_bytes / 1048576).toFixed(1)} Mo`
|
||||
: "";
|
||||
area.innerHTML = `
|
||||
<div class="unsupported-file">
|
||||
<i data-lucide="file" style="width:48px;height:48px"></i>
|
||||
<div class="filename">${escapeHtml(data.path.split("/").pop())}</div>
|
||||
<div>Ce fichier est binaire et ne peut pas être affiché.</div>
|
||||
${sizeStr ? `<div style="font-size:0.85rem;margin-top:4px">Taille : ${sizeStr}</div>` : ""}
|
||||
<button class="btn-action" id="unsupported-download-btn">
|
||||
<i data-lucide="download" style="width:14px;height:14px"></i> Télécharger
|
||||
</button>
|
||||
</div>`;
|
||||
lucide.createIcons();
|
||||
document.getElementById("unsupported-download-btn").addEventListener("click", () => {
|
||||
const dlUrl = `/api/file/${encodeURIComponent(data.vault)}/download?path=${encodeURIComponent(data.path)}`;
|
||||
window.open(dlUrl, "_blank");
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Breadcrumb
|
||||
const parts = data.path.split("/");
|
||||
const breadcrumbEls = [];
|
||||
@@ -3240,6 +3302,11 @@
|
||||
area.appendChild(mdDiv);
|
||||
area.appendChild(rawDiv);
|
||||
|
||||
// Backlinks panel
|
||||
if (data.is_markdown) {
|
||||
renderBacklinksPanel(data.vault, data.path, area);
|
||||
}
|
||||
|
||||
// Highlight code blocks
|
||||
area.querySelectorAll("pre code").forEach((block) => {
|
||||
safeHighlight(block);
|
||||
@@ -4668,7 +4735,8 @@
|
||||
});
|
||||
if (tagsDiv.children.length > 0) item.appendChild(tagsDiv);
|
||||
}
|
||||
item.addEventListener("click", () => openFile(r.vault, r.path));
|
||||
item.addEventListener("click", () => TabManager.openPreview(r.vault, r.path));
|
||||
item.addEventListener("dblclick", (e) => { e.preventDefault(); TabManager.openPersistent(r.vault, r.path); });
|
||||
container.appendChild(item);
|
||||
});
|
||||
area.appendChild(container);
|
||||
@@ -4848,7 +4916,8 @@
|
||||
if (tagsDiv.children.length > 0) item.appendChild(tagsDiv);
|
||||
}
|
||||
|
||||
item.addEventListener("click", () => openFile(r.vault, r.path));
|
||||
item.addEventListener("click", () => TabManager.openPreview(r.vault, r.path));
|
||||
item.addEventListener("dblclick", (e) => { e.preventDefault(); TabManager.openPersistent(r.vault, r.path); });
|
||||
container.appendChild(item);
|
||||
});
|
||||
area.appendChild(container);
|
||||
@@ -7234,6 +7303,7 @@
|
||||
const TabManager = {
|
||||
_tabs: [],
|
||||
_activeTabId: null,
|
||||
_previewTabId: null, // single-click preview tab (temporary, replaced on next preview)
|
||||
_tabCache: {}, // { tabId: { vault, path, title, data, rawSource, sourceView, scrollTop, icon } }
|
||||
_tabBar: null,
|
||||
_tabList: null,
|
||||
@@ -7244,6 +7314,71 @@
|
||||
this._tabList = document.getElementById("tab-list");
|
||||
},
|
||||
|
||||
/** Open a file as a preview tab (single-click).
|
||||
* Replaces any existing preview tab. If the file is already
|
||||
* open as a persistent tab, just activates it. */
|
||||
async openPreview(vault, path) {
|
||||
const tabId = `${vault}::${path}`;
|
||||
|
||||
// If already open as persistent tab, just activate it
|
||||
const existing = this._tabs.find(t => t.id === tabId && !t.preview);
|
||||
if (existing) {
|
||||
this.activate(tabId);
|
||||
return;
|
||||
}
|
||||
|
||||
// Close existing preview tab
|
||||
if (this._previewTabId && this._previewTabId !== tabId) {
|
||||
this.close(this._previewTabId);
|
||||
}
|
||||
|
||||
// If already open as preview, just focus it
|
||||
const previewExisting = this._tabs.find(t => t.id === tabId && t.preview);
|
||||
if (previewExisting) {
|
||||
this.activate(tabId);
|
||||
return;
|
||||
}
|
||||
|
||||
// Create preview tab
|
||||
const name = path.split("/").pop().replace(/\.md$/i, "");
|
||||
const icon = getFileIcon(name + ".md");
|
||||
|
||||
this._tabs.push({ id: tabId, vault, path, name, icon, preview: true });
|
||||
this._tabCache[tabId] = { vault, path, title: name, data: null, rawSource: null, sourceView: false, scrollTop: 0, icon };
|
||||
this._previewTabId = tabId;
|
||||
|
||||
this._renderTabs();
|
||||
this.activate(tabId);
|
||||
},
|
||||
|
||||
/** Convert a preview tab to a persistent tab (double-click).
|
||||
* If already persistent, opens a new duplicate (same file, different tab). */
|
||||
async openPersistent(vault, path) {
|
||||
const tabId = `${vault}::${path}`;
|
||||
|
||||
// If it's already a preview tab, convert it to persistent
|
||||
const previewTab = this._tabs.find(t => t.id === tabId && t.preview);
|
||||
if (previewTab) {
|
||||
previewTab.preview = false;
|
||||
if (this._previewTabId === tabId) {
|
||||
this._previewTabId = null;
|
||||
}
|
||||
this._renderTabs();
|
||||
this.activate(tabId);
|
||||
return;
|
||||
}
|
||||
|
||||
// If already persistent, just focus it
|
||||
const existing = this._tabs.find(t => t.id === tabId && !t.preview);
|
||||
if (existing) {
|
||||
this.activate(tabId);
|
||||
return;
|
||||
}
|
||||
|
||||
// Create a new persistent tab
|
||||
this.open(vault, path);
|
||||
},
|
||||
|
||||
/** Open a file in a tab (or focus existing) */
|
||||
async open(vault, path, options = {}) {
|
||||
const tabId = `${vault}::${path}`;
|
||||
@@ -7251,6 +7386,12 @@
|
||||
// If already open, just focus it
|
||||
const existing = this._tabs.find(t => t.id === tabId);
|
||||
if (existing) {
|
||||
// Convert preview to persistent if needed
|
||||
if (existing.preview) {
|
||||
existing.preview = false;
|
||||
if (this._previewTabId === tabId) this._previewTabId = null;
|
||||
this._renderTabs();
|
||||
}
|
||||
this.activate(tabId);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -5446,6 +5446,15 @@ body.popup-mode .content-area {
|
||||
max-width: 150px;
|
||||
}
|
||||
|
||||
/* Preview tab (single-click) — italic title, slightly muted */
|
||||
.tab-item.preview .tab-name {
|
||||
font-style: italic;
|
||||
opacity: 0.75;
|
||||
}
|
||||
.tab-item.preview {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
.tab-item .tab-close {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
@@ -5478,3 +5487,76 @@ body.popup-mode .content-area {
|
||||
background: var(--accent);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* ── Backlinks panel ── */
|
||||
.backlinks-panel {
|
||||
margin-top: 24px;
|
||||
padding: 12px 16px;
|
||||
background: var(--bg-card, var(--bg-secondary));
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
.backlinks-header {
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-muted);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-bottom: 8px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
.backlinks-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
.backlink-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 6px 8px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
.backlink-item:hover {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
.backlink-vault {
|
||||
font-size: 0.7rem;
|
||||
color: var(--accent);
|
||||
background: var(--accent-bg, rgba(99, 102, 241, 0.1));
|
||||
padding: 1px 6px;
|
||||
border-radius: 3px;
|
||||
}
|
||||
.backlink-title {
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-primary);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* ── Unsupported file notice ── */
|
||||
.unsupported-file {
|
||||
padding: 40px;
|
||||
text-align: center;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.unsupported-file i {
|
||||
display: block;
|
||||
margin: 0 auto 12px;
|
||||
opacity: 0.4;
|
||||
}
|
||||
.unsupported-file .filename {
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
margin-bottom: 8px;
|
||||
word-break: break-all;
|
||||
}
|
||||
.unsupported-file .btn-action {
|
||||
margin-top: 12px;
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user