Updated to version 1.2.6
This commit is contained in:
+270
-10
@@ -277,6 +277,12 @@ function _initPage(url) {
|
||||
══════════════════════════════════════════════════════════════════════════ */
|
||||
|
||||
let _fbPath = "";
|
||||
let _uploadPolicy = null;
|
||||
const _UPLOAD_ALLOWED_EXACT_NAMES = new Set([
|
||||
"known_hosts", "authorized_keys", "config",
|
||||
"makefile", "procfile", "dockerfile",
|
||||
".env", ".gitignore", ".htaccess",
|
||||
]);
|
||||
|
||||
function _fbFmtSize(bytes) {
|
||||
if (bytes === null || bytes === undefined) return "—";
|
||||
@@ -332,6 +338,7 @@ async function fileBrowserNavigate(path) {
|
||||
const emptyEl = document.getElementById("file-empty");
|
||||
const noFolder = document.getElementById("file-no-folder");
|
||||
const zipBtn = document.getElementById("dl-zip-btn");
|
||||
const uploadBtn = document.getElementById("upload-btn");
|
||||
if (!tbody) return;
|
||||
|
||||
tbody.innerHTML = '<tr><td colspan="5" style="text-align:center;color:var(--text-dim)">Loading\u2026</td></tr>';
|
||||
@@ -347,6 +354,7 @@ async function fileBrowserNavigate(path) {
|
||||
tableWrap.style.display = "none";
|
||||
noFolder.style.display = "";
|
||||
zipBtn.style.display = "none";
|
||||
if (uploadBtn) uploadBtn.style.display = "none";
|
||||
document.getElementById("file-breadcrumb").innerHTML = "";
|
||||
} else {
|
||||
tbody.innerHTML = '<tr><td colspan="5" style="text-align:center;color:var(--red)">' + esc(d.detail || "Error loading files") + "</td></tr>";
|
||||
@@ -357,6 +365,7 @@ async function fileBrowserNavigate(path) {
|
||||
const data = await r.json();
|
||||
_fbBuildBreadcrumb(data.path);
|
||||
zipBtn.style.display = "";
|
||||
if (uploadBtn) uploadBtn.style.display = "";
|
||||
|
||||
if (!data.entries.length) {
|
||||
tableWrap.style.display = "none";
|
||||
@@ -514,6 +523,78 @@ async function fileBrowserDeleteFile(path, name) {
|
||||
}
|
||||
}
|
||||
|
||||
function fileBrowserUploadClick() {
|
||||
document.getElementById("file-upload-input").click();
|
||||
}
|
||||
|
||||
async function fileBrowserUploadFiles(input) {
|
||||
const files = Array.from(input.files);
|
||||
input.value = "";
|
||||
if (!files.length) return;
|
||||
|
||||
if (!_uploadPolicy) {
|
||||
const pr = await fetch("/api/my/files/upload-policy").catch(function() { return null; });
|
||||
if (pr && pr.ok) _uploadPolicy = await pr.json().catch(function() { return null; });
|
||||
}
|
||||
|
||||
const policy = _uploadPolicy || { allowed_extensions: [], max_file_size_mb: 50, max_files: 20 };
|
||||
const allowedExts = new Set((policy.allowed_extensions || []).map(function(e) { return e.toLowerCase(); }));
|
||||
const maxBytes = (policy.max_file_size_mb || 50) * 1024 * 1024;
|
||||
const maxFiles = policy.max_files || 20;
|
||||
|
||||
if (files.length > maxFiles) {
|
||||
alert("Too many files — max " + maxFiles + " per upload.");
|
||||
return;
|
||||
}
|
||||
|
||||
const clientRejected = [];
|
||||
const fd = new FormData();
|
||||
for (const f of files) {
|
||||
const hasExt = f.name.includes(".");
|
||||
const ext = hasExt ? f.name.split(".").pop().toLowerCase() : "";
|
||||
const allowed = hasExt
|
||||
? (allowedExts.size === 0 || allowedExts.has(ext))
|
||||
: _UPLOAD_ALLOWED_EXACT_NAMES.has(f.name.toLowerCase());
|
||||
if (!allowed) {
|
||||
clientRejected.push(f.name + (hasExt ? " (type not allowed)" : " (extensionless file not permitted)"));
|
||||
continue;
|
||||
}
|
||||
if (f.size > maxBytes) {
|
||||
clientRejected.push(f.name + " (exceeds " + policy.max_file_size_mb + " MB limit)");
|
||||
continue;
|
||||
}
|
||||
fd.append("files", f);
|
||||
}
|
||||
|
||||
let uploadedCount = 0;
|
||||
const allRejected = [...clientRejected];
|
||||
if ([...fd.entries()].length > 0) {
|
||||
try {
|
||||
const r = await fetch("/api/my/files/upload?path=" + encodeURIComponent(_fbPath), {
|
||||
method: "POST",
|
||||
credentials: "same-origin",
|
||||
body: fd,
|
||||
});
|
||||
const d = await r.json().catch(function() { return {}; });
|
||||
if (!r.ok) { alert("Upload failed: " + (d.detail || r.statusText)); return; }
|
||||
uploadedCount = (d.uploaded || []).length;
|
||||
for (const rej of (d.rejected || [])) allRejected.push(rej.name + " (" + rej.reason + ")");
|
||||
} catch(e) {
|
||||
alert("Upload failed: " + e.message);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (uploadedCount > 0) {
|
||||
let msg = "Uploaded " + uploadedCount + " file" + (uploadedCount !== 1 ? "s" : "");
|
||||
if (allRejected.length) msg += ". Rejected: " + allRejected.join(", ");
|
||||
showFlash(msg);
|
||||
fileBrowserNavigate(_fbPath);
|
||||
} else if (allRejected.length) {
|
||||
alert("No files uploaded. Rejected:\n" + allRejected.join("\n"));
|
||||
}
|
||||
}
|
||||
|
||||
const _FB_TEXT_EXTS = new Set([
|
||||
"md","txt","json","xml","yaml","yml","csv","html","htm","css","js","ts",
|
||||
"jsx","tsx","py","sh","bash","zsh","log","sql","toml","ini","conf","cfg",
|
||||
@@ -913,15 +994,95 @@ function renderMarkdown(text) {
|
||||
`<button class="copy-code-btn btn btn-ghost btn-small" onclick="copyCode(this)">Copy</button>` +
|
||||
`</div><pre><code>${esc(p.s)}</code></pre></div>`;
|
||||
}
|
||||
return _renderInline(p.s);
|
||||
return _renderBlocks(p.s);
|
||||
}).join("");
|
||||
}
|
||||
|
||||
function _renderBlocks(text) {
|
||||
const lines = text.split('\n');
|
||||
const out = [];
|
||||
let textBuf = [];
|
||||
let i = 0;
|
||||
|
||||
const flushText = () => {
|
||||
while (textBuf.length && !textBuf[0].trim()) textBuf.shift();
|
||||
while (textBuf.length && !textBuf[textBuf.length - 1].trim()) textBuf.pop();
|
||||
if (textBuf.length) out.push(textBuf.map(_renderInline).join('<br>'));
|
||||
textBuf = [];
|
||||
};
|
||||
|
||||
while (i < lines.length) {
|
||||
const line = lines[i];
|
||||
|
||||
// Headings: # through ######
|
||||
const hm = line.match(/^(#{1,6}) (.+)/);
|
||||
if (hm) {
|
||||
flushText();
|
||||
out.push(`<h${hm[1].length} class="md-h">${_renderInline(hm[2])}</h${hm[1].length}>`);
|
||||
i++; continue;
|
||||
}
|
||||
|
||||
// Table: current line has | and next line is a GFM separator (|---|---|)
|
||||
if (line.includes('|') && i + 1 < lines.length && /^\|[\s\-:|]+\|/.test(lines[i + 1])) {
|
||||
flushText();
|
||||
const rows = [];
|
||||
while (i < lines.length && lines[i].includes('|')) { rows.push(lines[i]); i++; }
|
||||
out.push(_renderTable(rows));
|
||||
continue;
|
||||
}
|
||||
|
||||
// Unordered list
|
||||
if (/^[-*+] /.test(line)) {
|
||||
flushText();
|
||||
const items = [];
|
||||
while (i < lines.length && /^[-*+] /.test(lines[i])) {
|
||||
items.push(`<li>${_renderInline(lines[i].replace(/^[-*+] /, ''))}</li>`);
|
||||
i++;
|
||||
}
|
||||
out.push(`<ul class="md-list">${items.join('')}</ul>`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Ordered list
|
||||
if (/^\d+\. /.test(line)) {
|
||||
flushText();
|
||||
const items = [];
|
||||
while (i < lines.length && /^\d+\. /.test(lines[i])) {
|
||||
items.push(`<li>${_renderInline(lines[i].replace(/^\d+\. /, ''))}</li>`);
|
||||
i++;
|
||||
}
|
||||
out.push(`<ol class="md-list">${items.join('')}</ol>`);
|
||||
continue;
|
||||
}
|
||||
|
||||
textBuf.push(line);
|
||||
i++;
|
||||
}
|
||||
flushText();
|
||||
return out.join('');
|
||||
}
|
||||
|
||||
function _renderTable(lines) {
|
||||
const isSep = l => /^\|[\s\-:|]+\|/.test(l);
|
||||
const parseRow = l => {
|
||||
const cells = l.split('|');
|
||||
if (cells[0].trim() === '') cells.shift();
|
||||
if (cells.length && cells[cells.length - 1].trim() === '') cells.pop();
|
||||
return cells.map(c => c.trim());
|
||||
};
|
||||
const header = parseRow(lines[0]);
|
||||
const body = lines.slice(1).filter(l => !isSep(l) && l.trim());
|
||||
const thead = `<thead><tr>${header.map(h => `<th>${_renderInline(h)}</th>`).join('')}</tr></thead>`;
|
||||
const tbody = body.map(l => `<tr>${parseRow(l).map(c => `<td>${_renderInline(c)}</td>`).join('')}</tr>`).join('');
|
||||
return `<div class="md-table-wrap"><table class="md-table"><thead>${thead}</thead><tbody>${tbody}</tbody></table></div>`;
|
||||
}
|
||||
|
||||
function _renderInline(text) {
|
||||
let s = esc(text);
|
||||
s = s.replace(/\*\*(.+?)\*\*/g, "<strong>$1</strong>");
|
||||
s = s.replace(/<br\s*\/?>/gi, '<br>');
|
||||
s = s.replace(/\*\*([^*\n]+)\*\*/g, '<strong>$1</strong>');
|
||||
s = s.replace(/\*([^*\n]+)\*/g, '<em>$1</em>');
|
||||
s = s.replace(/`([^`\n]+)`/g, '<code class="inline-code">$1</code>');
|
||||
s = s.replace(/\n/g, "<br>");
|
||||
return s;
|
||||
}
|
||||
|
||||
@@ -1643,7 +1804,7 @@ function switchSettingsTab(name) {
|
||||
if (name === "inbox") { loadInboxStatus(); }
|
||||
if (name === "emailaccounts") { loadEmailAccounts(); }
|
||||
if (name === "brain") { loadBrainKey("brain-mcp-key", "brain-mcp-cmd"); loadBrainAutoApprove(); }
|
||||
if (name === "mfa") { loadTheme(); loadMyProfile(); loadMfaStatus(); loadDataFolder(); }
|
||||
if (name === "mfa") { loadTheme(); loadMyProfile(); loadMfaStatus(); loadDataFolder(); initSshKey(); }
|
||||
if (name === "webhooks") { loadWebhooks(); loadWebhookTargets(); }
|
||||
if (name === "caldav") { loadAdminCaldav(); }
|
||||
if (name === "pushover") { loadAdminPushover(); }
|
||||
@@ -1790,7 +1951,7 @@ function switchUserTab(name) {
|
||||
if (name === "caldav") { loadMyCaldavConfig(); }
|
||||
if (name === "telegram") { loadMyTelegramConfig(); loadMyTelegramWhitelist(); loadMyTelegramTriggers(); }
|
||||
if (name === "brain") { loadBrainKey("ubrain-mcp-key", "ubrain-mcp-cmd"); loadBrainAutoApprove(); }
|
||||
if (name === "mfa") { loadTheme(); loadMyProfile(); loadMfaStatus(); loadDataFolder(); }
|
||||
if (name === "mfa") { loadTheme(); loadMyProfile(); loadMfaStatus(); loadDataFolder(); initSshKey(); }
|
||||
if (name === "webhooks") { loadMyWebhooks(); loadMyWebhookTargets(); }
|
||||
if (name === "browser") { loadMyBrowserTrusted(); }
|
||||
if (name === "pushover") { loadMyPushover(); }
|
||||
@@ -2509,6 +2670,76 @@ async function loadDataFolder() {
|
||||
: "Not provisioned — ask your administrator to set system:users_base_folder in Credentials.";
|
||||
}
|
||||
|
||||
// ── Per-user SSH key ──────────────────────────────────────────────────────────
|
||||
|
||||
async function initSshKey() {
|
||||
const area = document.getElementById("ssh-key-area");
|
||||
if (!area) return;
|
||||
try {
|
||||
const r = await fetch("/api/my/ssh/pubkey");
|
||||
const d = await r.json();
|
||||
if (d.no_folder) {
|
||||
area.innerHTML = '<p style="font-size:13px;color:var(--text-dim)">No data folder configured — contact your administrator.</p>';
|
||||
return;
|
||||
}
|
||||
_renderSshKeyArea(d);
|
||||
} catch(e) {
|
||||
area.innerHTML = '<p style="font-size:13px;color:var(--red)">Failed to load SSH key status.</p>';
|
||||
}
|
||||
}
|
||||
|
||||
function _renderSshKeyArea(d) {
|
||||
const area = document.getElementById("ssh-key-area");
|
||||
if (!area) return;
|
||||
if (d.exists) {
|
||||
area.innerHTML =
|
||||
`<div class="form-group">
|
||||
<label>Public key <span style="font-size:11px;color:var(--text-dim)">(add this to remote servers' authorized_keys)</span></label>
|
||||
<textarea id="ssh-pubkey-box" class="form-input" readonly rows="3"
|
||||
style="font-family:var(--mono);font-size:11px;resize:none;line-height:1.5">${esc(d.pubkey)}</textarea>
|
||||
</div>
|
||||
<div style="display:flex;gap:8px;flex-wrap:wrap">
|
||||
<button class="btn btn-ghost btn-small" onclick="copySshPubkey()">Copy public key</button>
|
||||
<button class="btn btn-ghost btn-small" style="color:var(--text-dim)" onclick="generateSshKey(true)">Regenerate…</button>
|
||||
</div>`;
|
||||
} else {
|
||||
area.innerHTML =
|
||||
`<p style="font-size:13px;color:var(--text-dim);margin-bottom:12px">No SSH key found in your data folder.</p>
|
||||
<button class="btn btn-primary btn-small" onclick="generateSshKey(false)">Generate SSH key</button>`;
|
||||
}
|
||||
}
|
||||
|
||||
async function generateSshKey(force) {
|
||||
if (force && !confirm(
|
||||
"Regenerate SSH key?\n\nThe existing private key will be deleted. " +
|
||||
"Any remote servers using it will stop working until you update their authorized_keys."
|
||||
)) return;
|
||||
const area = document.getElementById("ssh-key-area");
|
||||
if (area) area.innerHTML = '<p style="font-size:13px;color:var(--text-dim)">Generating…</p>';
|
||||
try {
|
||||
const r = await fetch("/api/my/ssh/generate", {
|
||||
method: "POST",
|
||||
headers: {"Content-Type": "application/json"},
|
||||
body: JSON.stringify({force}),
|
||||
});
|
||||
const d = await r.json();
|
||||
if (!r.ok) throw new Error(d.detail || "Failed");
|
||||
_renderSshKeyArea({exists: true, pubkey: d.pubkey});
|
||||
showFlash("SSH key generated successfully");
|
||||
} catch(e) {
|
||||
if (area) _renderSshKeyArea({exists: false});
|
||||
showFlash("Error: " + e.message);
|
||||
}
|
||||
}
|
||||
|
||||
function copySshPubkey() {
|
||||
const box = document.getElementById("ssh-pubkey-box");
|
||||
if (!box) return;
|
||||
navigator.clipboard.writeText(box.value)
|
||||
.then(() => showFlash("Public key copied!"))
|
||||
.catch(() => {});
|
||||
}
|
||||
|
||||
// ── Per-user CalDAV ───────────────────────────────────────────────────────────
|
||||
|
||||
async function loadMyCaldavConfig() {
|
||||
@@ -3778,12 +4009,19 @@ async function loadSecuritySettings() {
|
||||
if (mdEl && data.llm_screen_model) mdEl.value = data.llm_screen_model;
|
||||
const modeEl = document.getElementById("sec-llm-screen-mode");
|
||||
if (modeEl) modeEl.value = data.llm_screen_block ? "block" : "flag";
|
||||
const extEl = document.getElementById("sec-upload-extensions");
|
||||
if (extEl && data.allowed_extensions) extEl.value = data.allowed_extensions.join(", ");
|
||||
setNum("sec-upload-max-mb", data.max_file_size_mb);
|
||||
setNum("sec-upload-max-files", data.max_files);
|
||||
_uploadPolicy = { allowed_extensions: data.allowed_extensions, max_file_size_mb: data.max_file_size_mb, max_files: data.max_files };
|
||||
} catch { /* ignore */ }
|
||||
|
||||
form.addEventListener("submit", async e => {
|
||||
e.preventDefault();
|
||||
const getChk = id => { const el = document.getElementById(id); return el ? el.checked : null; };
|
||||
const getNum = id => { const el = document.getElementById(id); return el ? parseInt(el.value, 10) : null; };
|
||||
const extRaw = (document.getElementById("sec-upload-extensions") || {}).value || "";
|
||||
const extList = extRaw.split(/[\s,]+/).map(s => s.replace(/^\./, "").toLowerCase()).filter(Boolean);
|
||||
const body = {
|
||||
sanitize_enhanced: getChk("sec-sanitize-enhanced"),
|
||||
truncation_enabled: getChk("sec-truncation-enabled"),
|
||||
@@ -3796,8 +4034,10 @@ async function loadSecuritySettings() {
|
||||
llm_screen_enabled: getChk("sec-llm-screen-enabled"),
|
||||
llm_screen_model: (() => { const el = document.getElementById("sec-llm-screen-model"); return el ? el.value : null; })(),
|
||||
llm_screen_block: (() => { const el = document.getElementById("sec-llm-screen-mode"); return el ? el.value === "block" : null; })(),
|
||||
upload_allowed_extensions: extList.length ? extList : null,
|
||||
upload_max_file_size_mb: getNum("sec-upload-max-mb"),
|
||||
upload_max_files: getNum("sec-upload-max-files"),
|
||||
};
|
||||
// Remove null/NaN values
|
||||
for (const k of Object.keys(body)) {
|
||||
if (body[k] === null || (typeof body[k] === "number" && isNaN(body[k]))) delete body[k];
|
||||
}
|
||||
@@ -3806,8 +4046,12 @@ async function loadSecuritySettings() {
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
if (r.ok) showFlash("Security settings saved ✓");
|
||||
else showFlash("Error saving security settings");
|
||||
if (r.ok) {
|
||||
showFlash("Security settings saved ✓");
|
||||
_uploadPolicy = null; // bust cache so files page picks up new policy
|
||||
} else {
|
||||
showFlash("Error saving security settings");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -5465,8 +5709,24 @@ function helpSearch(query) {
|
||||
// Sync TOC link visibility
|
||||
document.querySelectorAll(".toc-list a[href^='#']").forEach(a => {
|
||||
const id = a.getAttribute("href").slice(1);
|
||||
const section = document.getElementById(id);
|
||||
const visible = !q || !section || section.style.display !== "none";
|
||||
const el = document.getElementById(id);
|
||||
let visible;
|
||||
if (!q || !el) {
|
||||
visible = true;
|
||||
} else if (el.matches("section[data-section]")) {
|
||||
visible = el.style.display !== "none";
|
||||
} else {
|
||||
// Heading inside a section: scan from this element to the next sibling
|
||||
// of the same tag to check if the query appears in that content block.
|
||||
const tag = el.tagName;
|
||||
visible = false;
|
||||
let node = el;
|
||||
while (node) {
|
||||
if (node.textContent.toLowerCase().includes(q)) { visible = true; break; }
|
||||
node = node.nextElementSibling;
|
||||
if (node && node.tagName === tag) break;
|
||||
}
|
||||
}
|
||||
a.closest("li").style.display = visible ? "" : "none";
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user