Updated to version 1.2.6

This commit is contained in:
2026-04-28 09:41:56 +02:00
parent c5a5356a0d
commit c951185cdc
12 changed files with 707 additions and 145 deletions
+270 -10
View File
@@ -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(/&lt;br\s*\/?&gt;/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";
});
+35 -2
View File
@@ -193,6 +193,25 @@ body {
border-bottom-left-radius: 2px;
}
/* Markdown headings */
.message-bubble .md-h { margin: 10px 0 4px; font-weight: 600; line-height: 1.3; }
.message-bubble h1.md-h { font-size: 1.4em; }
.message-bubble h2.md-h { font-size: 1.2em; }
.message-bubble h3.md-h { font-size: 1.05em; }
.message-bubble h4.md-h, .message-bubble h5.md-h, .message-bubble h6.md-h { font-size: 1em; }
.message-bubble .md-h:first-child { margin-top: 0; }
/* Markdown lists */
.message-bubble .md-list { margin: 6px 0; padding-left: 22px; }
.message-bubble .md-list li { margin: 2px 0; }
/* Markdown tables */
.md-table-wrap { overflow-x: auto; margin: 8px 0; }
.md-table { border-collapse: collapse; width: 100%; font-size: 13px; }
.md-table th, .md-table td { border: 1px solid var(--border); padding: 6px 12px; text-align: left; vertical-align: top; }
.md-table thead th { background: var(--bg3); font-weight: 600; }
.md-table tbody tr:nth-child(even) { background: color-mix(in srgb, var(--bg3) 40%, transparent); }
/* Copy response button (below assistant bubble, visible on hover) */
.copy-response-btn {
font-size: 11px;
@@ -905,7 +924,21 @@ tr:hover td { background: var(--bg2); }
/* Tab bar scrollable */
.tabs { overflow-x: auto; flex-wrap: nowrap; }
/* Filter bar */
/* Filter bar: inputs fill available width, pairs stack two-per-row */
.filter-bar { gap: 6px; }
.filter-bar .form-input { min-width: 0; }
.filter-bar .form-group { flex: 1 1 calc(50% - 6px); min-width: 120px; }
.filter-bar .form-input { width: 100% !important; min-width: 0; box-sizing: border-box; }
.filter-bar-actions { flex: 1 1 100%; }
/* Chats page: action buttons wrap below content on narrow screens */
.chat-row { flex-wrap: wrap !important; }
.chat-row-actions { flex: 1 1 100% !important; padding-left: 27px; }
/* Agent detail header: name + buttons wrap */
.agent-detail-header { flex-wrap: wrap !important; gap: 12px; }
.agent-detail-header > div:last-child { flex-shrink: 0; }
/* Fullscreen prompt editor on mobile */
.pe-kbd-hint { display: none; }
#pe-textarea { padding: 16px !important; font-size: 14px !important; }
}