Spaces:
Sleeping
Sleeping
| // MarkdownFS minimal web UI. Pure REST client. | |
| const $ = (id) => document.getElementById(id); | |
| const TOKEN_KEY = "mdfs.token"; | |
| const USER_KEY = "mdfs.username"; | |
| const OBO_KEY = "mdfs.onBehalfOf"; | |
| const state = { | |
| expanded: new Set([""]), | |
| selected: null, | |
| editing: false, | |
| originalContent: "", | |
| token: localStorage.getItem(TOKEN_KEY) || null, | |
| onBehalfOf: localStorage.getItem(OBO_KEY) || null, | |
| user: null, | |
| isAdmin: false, | |
| }; | |
| function setToken(token, username) { | |
| if (token) { | |
| localStorage.setItem(TOKEN_KEY, token); | |
| if (username) localStorage.setItem(USER_KEY, username); | |
| } else { | |
| localStorage.removeItem(TOKEN_KEY); | |
| localStorage.removeItem(USER_KEY); | |
| } | |
| state.token = token; | |
| } | |
| function setOnBehalfOf(value) { | |
| if (value) { | |
| localStorage.setItem(OBO_KEY, value); | |
| state.onBehalfOf = value; | |
| } else { | |
| localStorage.removeItem(OBO_KEY); | |
| state.onBehalfOf = null; | |
| } | |
| } | |
| async function api(method, path, opts = {}) { | |
| const init = { method, headers: {} }; | |
| if (opts.json !== undefined) { | |
| init.body = JSON.stringify(opts.json); | |
| init.headers["content-type"] = "application/json"; | |
| } else if (opts.body !== undefined) { | |
| init.body = opts.body; | |
| init.headers["content-type"] = opts.contentType || "text/markdown"; | |
| } | |
| if (!opts.skipAuth) { | |
| if (state.token) { | |
| init.headers["authorization"] = `Bearer ${state.token}`; | |
| } else if (state.usernameAuth) { | |
| init.headers["authorization"] = `User ${state.usernameAuth}`; | |
| } else if (opts.username) { | |
| init.headers["authorization"] = `User ${opts.username}`; | |
| } | |
| if (state.onBehalfOf) { | |
| init.headers["x-markdownfs-on-behalf-of"] = state.onBehalfOf; | |
| } | |
| } | |
| if (opts.headers) Object.assign(init.headers, opts.headers); | |
| const res = await fetch(path, init); | |
| if (!res.ok) { | |
| const text = await res.text().catch(() => ""); | |
| let msg = res.statusText; | |
| try { | |
| const j = JSON.parse(text); | |
| if (j && j.error) msg = j.error; | |
| } catch {} | |
| const err = new Error(msg); | |
| err.status = res.status; | |
| throw err; | |
| } | |
| return res; | |
| } | |
| async function copyToClipboard(text, btn) { | |
| try { | |
| if (navigator.clipboard && window.isSecureContext) { | |
| await navigator.clipboard.writeText(text); | |
| } else { | |
| const ta = document.createElement("textarea"); | |
| ta.value = text; | |
| ta.style.position = "fixed"; | |
| ta.style.opacity = "0"; | |
| document.body.append(ta); | |
| ta.select(); | |
| document.execCommand("copy"); | |
| ta.remove(); | |
| } | |
| if (btn) { | |
| const original = btn.textContent; | |
| btn.textContent = "copied ✓"; | |
| btn.classList.add("copied"); | |
| setTimeout(() => { | |
| btn.textContent = original; | |
| btn.classList.remove("copied"); | |
| }, 1500); | |
| } | |
| return true; | |
| } catch (e) { | |
| toast(`copy failed: ${e.message}`, true); | |
| return false; | |
| } | |
| } | |
| function toast(msg, isError = false) { | |
| const el = $("toast"); | |
| el.textContent = msg; | |
| el.classList.toggle("error", isError); | |
| el.hidden = false; | |
| clearTimeout(toast._t); | |
| toast._t = setTimeout(() => (el.hidden = true), 2200); | |
| } | |
| function ts(seconds) { | |
| const d = new Date(seconds * 1000); | |
| const diff = (Date.now() - d.getTime()) / 1000; | |
| if (diff < 60) return "just now"; | |
| if (diff < 3600) return `${Math.floor(diff / 60)}m ago`; | |
| if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`; | |
| return d.toISOString().slice(0, 10); | |
| } | |
| async function refreshHeader() { | |
| try { | |
| const res = await api("GET", "/health"); | |
| const h = await res.json(); | |
| $("counters").textContent = `· ${h.inodes} inodes · ${h.commits} commits`; | |
| return h; | |
| } catch (e) { | |
| $("counters").textContent = "(server unreachable)"; | |
| return null; | |
| } | |
| } | |
| async function refreshWhoAmI() { | |
| try { | |
| const res = await api("GET", "/auth/whoami"); | |
| const w = await res.json(); | |
| state.user = w; | |
| state.isAdmin = w.is_root || w.is_wheel === true; | |
| $("user-info").hidden = false; | |
| let label = w.authenticated ? w.username : `${w.username} (anon)`; | |
| if (w.on_behalf_of) { | |
| label += ` → ${w.on_behalf_of}`; | |
| } | |
| $("user-name").textContent = label; | |
| $("users-btn").hidden = !state.isAdmin; | |
| return w; | |
| } catch (e) { | |
| state.user = null; | |
| state.isAdmin = false; | |
| $("user-info").hidden = true; | |
| return null; | |
| } | |
| } | |
| function showBackdrop(which) { | |
| $("modal-backdrop").hidden = false; | |
| $("bootstrap-modal").hidden = which !== "bootstrap"; | |
| $("login-modal").hidden = which !== "login"; | |
| } | |
| function hideBackdrop() { | |
| $("modal-backdrop").hidden = true; | |
| } | |
| async function tryBootstrap(username) { | |
| const res = await api("POST", "/auth/bootstrap", { | |
| json: { username }, | |
| skipAuth: true, | |
| }); | |
| return await res.json(); | |
| } | |
| async function tryLoginUsername(username) { | |
| await api("POST", "/auth/login", { json: { username }, skipAuth: true }); | |
| // No token from username login — store username and use User auth header. | |
| // For simplicity, we set state.token to null and rely on opts.username, | |
| // but localStorage tracks the username so subsequent loads keep us signed in. | |
| setToken(null, username); | |
| state.usernameAuth = username; | |
| } | |
| function logout() { | |
| setToken(null); | |
| setOnBehalfOf(null); | |
| state.usernameAuth = null; | |
| location.reload(); | |
| } | |
| async function loadDir(path) { | |
| const url = path ? `/fs/${encodeSegments(path)}` : "/fs"; | |
| const res = await api("GET", url); | |
| const data = await res.json(); | |
| return data.entries || []; | |
| } | |
| function encodeSegments(p) { | |
| const isAbs = p.startsWith("/"); | |
| const cleaned = p.replace(/^(\.?\/)+|\/+$/g, ""); | |
| const enc = cleaned.split("/").filter(Boolean).map(encodeURIComponent).join("/"); | |
| return isAbs ? `/${enc}` : enc; | |
| } | |
| async function renderTree() { | |
| const root = $("tree"); | |
| root.innerHTML = ""; | |
| await renderDir(root, ""); | |
| } | |
| async function renderDir(parent, path) { | |
| let entries; | |
| try { | |
| entries = await loadDir(path); | |
| } catch (e) { | |
| return; | |
| } | |
| entries.sort((a, b) => { | |
| if (a.is_dir !== b.is_dir) return a.is_dir ? -1 : 1; | |
| return a.name.localeCompare(b.name); | |
| }); | |
| for (const e of entries) { | |
| const childPath = path ? `${path}/${e.name}` : `/${e.name}`; | |
| const li = document.createElement("li"); | |
| const item = document.createElement("div"); | |
| item.className = "tree-item"; | |
| item.style.paddingLeft = `${path.split("/").filter(Boolean).length * 14 + 8}px`; | |
| const twirl = document.createElement("span"); | |
| twirl.className = "twirl"; | |
| twirl.textContent = e.is_dir ? (state.expanded.has(childPath) ? "▼" : "▶") : ""; | |
| const icon = document.createElement("span"); | |
| icon.className = "icon"; | |
| icon.textContent = e.is_dir ? "📁" : "📄"; | |
| const name = document.createElement("span"); | |
| name.className = "name"; | |
| name.textContent = e.name; | |
| item.append(twirl, icon, name); | |
| li.append(item); | |
| if (e.is_dir) { | |
| const childUl = document.createElement("ul"); | |
| if (state.expanded.has(childPath)) { | |
| await renderDir(childUl, childPath); | |
| } else { | |
| childUl.hidden = true; | |
| } | |
| li.append(childUl); | |
| item.addEventListener("click", async () => { | |
| if (state.expanded.has(childPath)) { | |
| state.expanded.delete(childPath); | |
| twirl.textContent = "▶"; | |
| childUl.hidden = true; | |
| } else { | |
| state.expanded.add(childPath); | |
| twirl.textContent = "▼"; | |
| if (!childUl.children.length) { | |
| await renderDir(childUl, childPath); | |
| } | |
| childUl.hidden = false; | |
| } | |
| }); | |
| } else { | |
| item.addEventListener("click", () => openFile(childPath, item)); | |
| } | |
| parent.append(li); | |
| } | |
| } | |
| async function openFile(path, itemEl) { | |
| if (state.editing && !confirm("Discard unsaved changes?")) return; | |
| document.querySelectorAll(".tree-item.selected").forEach((el) => el.classList.remove("selected")); | |
| if (itemEl) itemEl.classList.add("selected"); | |
| try { | |
| const res = await api("GET", `/fs/${encodeSegments(path)}`); | |
| const text = await res.text(); | |
| state.selected = path; | |
| state.originalContent = text; | |
| state.editing = false; | |
| $("placeholder").hidden = true; | |
| $("content").hidden = false; | |
| $("content").textContent = text; | |
| $("editor").hidden = true; | |
| $("current-path").textContent = path; | |
| $("file-actions").hidden = false; | |
| $("edit").hidden = false; | |
| $("save").hidden = true; | |
| $("cancel").hidden = true; | |
| // Fetch metadata and show mode/owner | |
| try { | |
| const stRes = await api("GET", `/fs/${encodeSegments(path)}?stat=true`); | |
| const st = await stRes.json(); | |
| state.selectedMode = st.mode; | |
| const owner = await uidToName(st.uid); | |
| state.selectedOwner = owner; | |
| $("file-meta").textContent = `${st.mode} ${owner}`; | |
| } catch { | |
| $("file-meta").textContent = ""; | |
| } | |
| } catch (e) { | |
| toast(`open: ${e.message}`, true); | |
| } | |
| } | |
| let userNameCache = null; | |
| async function uidToName(uid) { | |
| if (uid === 0) return "root"; | |
| if (!state.isAdmin) return `uid=${uid}`; | |
| if (!userNameCache) { | |
| try { | |
| const res = await api("GET", "/admin/users"); | |
| const data = await res.json(); | |
| userNameCache = new Map((data.users || []).map((u) => [u.uid, u.name])); | |
| } catch { | |
| return `uid=${uid}`; | |
| } | |
| } | |
| return userNameCache.get(uid) || `uid=${uid}`; | |
| } | |
| function startEdit() { | |
| if (!state.selected) return; | |
| state.editing = true; | |
| const ed = $("editor"); | |
| ed.value = state.originalContent; | |
| ed.hidden = false; | |
| $("content").hidden = true; | |
| $("edit").hidden = true; | |
| $("save").hidden = false; | |
| $("cancel").hidden = false; | |
| ed.focus(); | |
| } | |
| function cancelEdit() { | |
| state.editing = false; | |
| $("editor").hidden = true; | |
| $("content").hidden = false; | |
| $("edit").hidden = false; | |
| $("save").hidden = true; | |
| $("cancel").hidden = true; | |
| } | |
| async function saveEdit() { | |
| if (!state.selected) return; | |
| const text = $("editor").value; | |
| try { | |
| await api("PUT", `/fs/${encodeSegments(state.selected)}`, { body: text }); | |
| state.originalContent = text; | |
| $("content").textContent = text; | |
| cancelEdit(); | |
| toast("saved"); | |
| await refreshHeader(); | |
| } catch (e) { | |
| toast(`save: ${e.message}`, true); | |
| } | |
| } | |
| async function deleteFile() { | |
| if (!state.selected) return; | |
| if (!confirm(`Delete ${state.selected}?`)) return; | |
| try { | |
| await api("DELETE", `/fs/${encodeSegments(state.selected)}`); | |
| state.selected = null; | |
| state.editing = false; | |
| $("content").hidden = true; | |
| $("editor").hidden = true; | |
| $("placeholder").hidden = false; | |
| $("current-path").textContent = "no file selected"; | |
| $("file-actions").hidden = true; | |
| toast("deleted"); | |
| await renderTree(); | |
| await refreshHeader(); | |
| } catch (e) { | |
| toast(`delete: ${e.message}`, true); | |
| } | |
| } | |
| async function newFile() { | |
| const path = prompt( | |
| "New file path. Relative paths land in your home; use a leading / for absolute.\n" + | |
| "Example: notes/idea.md → /home/<you>/notes/idea.md", | |
| ); | |
| if (!path) return; | |
| try { | |
| await api("PUT", `/fs/${encodeSegments(path)}`, { body: "" }); | |
| toast(`created ${path}`); | |
| const segs = path.split("/").slice(0, -1); | |
| let acc = ""; | |
| for (const s of segs) { | |
| acc = acc ? `${acc}/${s}` : s; | |
| state.expanded.add(acc); | |
| } | |
| await renderTree(); | |
| await refreshHeader(); | |
| await openFile(path, null); | |
| } catch (e) { | |
| toast(`new: ${e.message}`, true); | |
| } | |
| } | |
| async function commitWorking() { | |
| const message = prompt("Commit message:"); | |
| if (!message) return; | |
| try { | |
| const res = await api("POST", "/vcs/commit", { json: { message } }); | |
| const data = await res.json(); | |
| toast(`committed ${data.hash}`); | |
| await refreshHeader(); | |
| await refreshCommits(); | |
| } catch (e) { | |
| toast(`commit: ${e.message}`, true); | |
| } | |
| } | |
| async function refreshCommits() { | |
| try { | |
| const res = await api("GET", "/vcs/log"); | |
| const data = await res.json(); | |
| const ul = $("commits"); | |
| ul.innerHTML = ""; | |
| for (const c of data.commits || []) { | |
| const li = document.createElement("li"); | |
| li.innerHTML = `<span class="hash">${c.hash}</span><span class="msg"></span><span class="author"></span><span class="when"></span>`; | |
| li.querySelector(".msg").textContent = c.message; | |
| li.querySelector(".author").textContent = c.author; | |
| li.querySelector(".when").textContent = ts(c.timestamp); | |
| ul.append(li); | |
| } | |
| $("commit-count").textContent = `(${(data.commits || []).length})`; | |
| } catch (e) { | |
| // ignore | |
| } | |
| } | |
| let searchTimer; | |
| function onSearchInput(e) { | |
| const pattern = e.target.value.trim(); | |
| clearTimeout(searchTimer); | |
| if (!pattern) { | |
| $("search-results").hidden = true; | |
| return; | |
| } | |
| searchTimer = setTimeout(async () => { | |
| try { | |
| const url = `/search/grep?pattern=${encodeURIComponent(pattern)}&recursive=true`; | |
| const res = await api("GET", url); | |
| const data = await res.json(); | |
| const ul = $("hits"); | |
| ul.innerHTML = ""; | |
| for (const r of data.results || []) { | |
| const li = document.createElement("li"); | |
| li.innerHTML = `<span class="file"></span><span class="line"></span>`; | |
| li.querySelector(".file").textContent = `${r.file}:${r.line_num}`; | |
| li.querySelector(".line").textContent = r.line.length > 100 ? r.line.slice(0, 100) + "…" : r.line; | |
| // grep results are returned as "./path"; treat them as absolute file locations | |
| li.addEventListener("click", () => openFile("/" + r.file.replace(/^(\.?\/)+/, ""), null)); | |
| ul.append(li); | |
| } | |
| $("search-results").hidden = false; | |
| } catch (e) { | |
| toast(`search: ${e.message}`, true); | |
| } | |
| }, 250); | |
| } | |
| function bind() { | |
| $("edit").addEventListener("click", startEdit); | |
| $("save").addEventListener("click", saveEdit); | |
| $("cancel").addEventListener("click", cancelEdit); | |
| $("delete").addEventListener("click", deleteFile); | |
| $("new-file").addEventListener("click", newFile); | |
| $("commit").addEventListener("click", commitWorking); | |
| $("search").addEventListener("input", onSearchInput); | |
| $("commits-toggle").addEventListener("click", () => { | |
| $("commits-panel").classList.toggle("collapsed"); | |
| }); | |
| $("logout").addEventListener("click", logout); | |
| $("users-btn").addEventListener("click", openUsersPanel); | |
| $("users-close").addEventListener("click", closeUsersPanel); | |
| $("chmod-btn").addEventListener("click", openChmodModal); | |
| $("chmod-cancel").addEventListener("click", closeChmodModal); | |
| $("chown-btn").addEventListener("click", openChownModal); | |
| $("chown-cancel").addEventListener("click", closeChownModal); | |
| $("add-user-form").addEventListener("submit", async (e) => { | |
| e.preventDefault(); | |
| const name = $("add-user-name").value.trim(); | |
| const isAgent = $("add-user-agent").checked; | |
| if (!name) return; | |
| await createUser(name, isAgent); | |
| $("add-user-name").value = ""; | |
| $("add-user-agent").checked = false; | |
| userNameCache = null; | |
| }); | |
| $("chmod-form").addEventListener("submit", (e) => { | |
| e.preventDefault(); | |
| applyChmod($("chmod-mode").value.trim()); | |
| }); | |
| $("chown-form").addEventListener("submit", (e) => { | |
| e.preventDefault(); | |
| applyChown($("chown-owner").value.trim(), $("chown-group").value.trim()); | |
| }); | |
| $("bootstrap-form").addEventListener("submit", async (e) => { | |
| e.preventDefault(); | |
| const username = $("bootstrap-username").value.trim(); | |
| if (!username) return; | |
| try { | |
| const result = await tryBootstrap(username); | |
| $("bootstrap-token").textContent = result.token; | |
| $("bootstrap-form").hidden = true; | |
| $("bootstrap-result").hidden = false; | |
| setToken(result.token, result.username); | |
| } catch (err) { | |
| toast(`bootstrap: ${err.message}`, true); | |
| } | |
| }); | |
| $("bootstrap-continue").addEventListener("click", async () => { | |
| hideBackdrop(); | |
| await loadWorkspace(); | |
| }); | |
| $("bootstrap-copy").addEventListener("click", (e) => { | |
| copyToClipboard($("bootstrap-token").textContent, e.currentTarget); | |
| }); | |
| $("new-token-copy").addEventListener("click", (e) => { | |
| copyToClipboard($("new-token-value").textContent, e.currentTarget); | |
| }); | |
| $("login-form").addEventListener("submit", async (e) => { | |
| e.preventDefault(); | |
| const token = $("login-token").value.trim(); | |
| const username = $("login-username").value.trim(); | |
| const onBehalfOf = $("login-on-behalf-of").value.trim(); | |
| try { | |
| setOnBehalfOf(onBehalfOf || null); | |
| if (token) { | |
| setToken(token); | |
| await refreshWhoAmI(); | |
| if (!state.user || !state.user.authenticated) { | |
| throw new Error("invalid token"); | |
| } | |
| } else if (username) { | |
| await tryLoginUsername(username); | |
| } else { | |
| throw new Error("provide a token or username"); | |
| } | |
| hideBackdrop(); | |
| await loadWorkspace(); | |
| } catch (err) { | |
| setToken(null); | |
| setOnBehalfOf(null); | |
| toast(`login: ${err.message}`, true); | |
| } | |
| }); | |
| } | |
| async function loadWorkspace() { | |
| await refreshWhoAmI(); | |
| await refreshHeader(); | |
| await renderTree(); | |
| await refreshCommits(); | |
| } | |
| // ---------- admin panel ---------- | |
| function openUsersPanel() { | |
| $("modal-backdrop").hidden = false; | |
| $("users-modal").hidden = false; | |
| $("bootstrap-modal").hidden = true; | |
| $("login-modal").hidden = true; | |
| $("new-token-display").hidden = true; | |
| refreshUsersTable(); | |
| } | |
| function closeUsersPanel() { | |
| $("users-modal").hidden = true; | |
| hideBackdrop(); | |
| } | |
| async function refreshUsersTable() { | |
| try { | |
| const res = await api("GET", "/admin/users"); | |
| const data = await res.json(); | |
| const tbody = $("users-tbody"); | |
| tbody.innerHTML = ""; | |
| for (const u of data.users || []) { | |
| const tr = document.createElement("tr"); | |
| tr.innerHTML = ` | |
| <td class="muted">${u.uid}</td> | |
| <td><strong></strong> <span class="muted small"></span></td> | |
| <td class="user-groups"></td> | |
| <td class="${u.has_token ? "has-token" : "no-token"}"></td> | |
| <td class="actions-cell"></td> | |
| `; | |
| tr.querySelector("strong").textContent = u.name; | |
| if (u.is_agent) tr.querySelector(".small").textContent = "(agent)"; | |
| tr.querySelector(".user-groups").textContent = u.groups.join(", "); | |
| tr.querySelector("td.has-token, td.no-token").textContent = u.has_token ? "✓" : "—"; | |
| const actions = tr.querySelector(".actions-cell"); | |
| const tokBtn = document.createElement("button"); | |
| tokBtn.textContent = u.has_token ? "regen token" : "issue token"; | |
| tokBtn.addEventListener("click", () => issueToken(u.name)); | |
| actions.append(tokBtn); | |
| const grpBtn = document.createElement("button"); | |
| grpBtn.textContent = "+group"; | |
| grpBtn.addEventListener("click", () => addUserToGroup(u.name)); | |
| actions.append(grpBtn); | |
| if (u.uid !== 0) { | |
| const delBtn = document.createElement("button"); | |
| delBtn.textContent = "delete"; | |
| delBtn.className = "danger"; | |
| delBtn.addEventListener("click", () => deleteUser(u.name)); | |
| actions.append(delBtn); | |
| } | |
| tbody.append(tr); | |
| } | |
| } catch (e) { | |
| toast(`users: ${e.message}`, true); | |
| } | |
| } | |
| async function createUser(name, isAgent) { | |
| try { | |
| const res = await api("POST", "/admin/users", { | |
| json: { name, is_agent: isAgent }, | |
| }); | |
| const data = await res.json(); | |
| if (data.token) showNewToken(data.name, data.token); | |
| await refreshUsersTable(); | |
| return data; | |
| } catch (e) { | |
| toast(`add user: ${e.message}`, true); | |
| } | |
| } | |
| async function issueToken(name) { | |
| try { | |
| const res = await api("POST", `/admin/users/${encodeURIComponent(name)}/tokens`); | |
| const data = await res.json(); | |
| showNewToken(data.name, data.token); | |
| await refreshUsersTable(); | |
| } catch (e) { | |
| toast(`token: ${e.message}`, true); | |
| } | |
| } | |
| async function deleteUser(name) { | |
| if (!confirm(`Delete user '${name}' and all their data?`)) return; | |
| try { | |
| await api("DELETE", `/admin/users/${encodeURIComponent(name)}`); | |
| await refreshUsersTable(); | |
| toast(`deleted ${name}`); | |
| } catch (e) { | |
| toast(`delete: ${e.message}`, true); | |
| } | |
| } | |
| async function addUserToGroup(name) { | |
| const group = prompt(`Add ${name} to which group?`); | |
| if (!group) return; | |
| try { | |
| await api( | |
| "POST", | |
| `/admin/users/${encodeURIComponent(name)}/groups/${encodeURIComponent(group)}`, | |
| ); | |
| await refreshUsersTable(); | |
| toast(`added ${name} to ${group}`); | |
| } catch (e) { | |
| toast(`group: ${e.message}`, true); | |
| } | |
| } | |
| function showNewToken(name, token) { | |
| $("new-token-user").textContent = name; | |
| $("new-token-value").textContent = token; | |
| $("new-token-display").hidden = false; | |
| } | |
| // ---------- chmod / chown ---------- | |
| function modeBitsFromOctal(s) { | |
| const cleaned = s.trim(); | |
| if (!/^0?[0-7]{3,4}$/.test(cleaned)) return null; | |
| return parseInt(cleaned, 8); | |
| } | |
| function openChmodModal() { | |
| if (!state.selected) return; | |
| $("modal-backdrop").hidden = false; | |
| $("chmod-modal").hidden = false; | |
| $("chmod-path").textContent = state.selected; | |
| $("chmod-mode").value = state.selectedMode || "0644"; | |
| $("chmod-mode").focus(); | |
| } | |
| function closeChmodModal() { | |
| $("chmod-modal").hidden = true; | |
| hideBackdrop(); | |
| } | |
| function openChownModal() { | |
| if (!state.selected) return; | |
| $("modal-backdrop").hidden = false; | |
| $("chown-modal").hidden = false; | |
| $("chown-path").textContent = state.selected; | |
| $("chown-owner").value = state.selectedOwner || ""; | |
| $("chown-group").value = ""; | |
| $("chown-owner").focus(); | |
| } | |
| function closeChownModal() { | |
| $("chown-modal").hidden = true; | |
| hideBackdrop(); | |
| } | |
| async function applyChmod(modeStr) { | |
| if (!state.selected) return; | |
| try { | |
| await api("POST", `/admin/chmod/${encodeSegments(state.selected)}`, { | |
| json: { mode: modeStr }, | |
| }); | |
| toast(`chmod ${modeStr} ${state.selected}`); | |
| closeChmodModal(); | |
| await openFile(state.selected, document.querySelector(".tree-item.selected")); | |
| } catch (e) { | |
| toast(`chmod: ${e.message}`, true); | |
| } | |
| } | |
| async function applyChown(owner, group) { | |
| if (!state.selected) return; | |
| try { | |
| await api("POST", `/admin/chown/${encodeSegments(state.selected)}`, { | |
| json: { owner, group: group || null }, | |
| }); | |
| toast(`chown ${owner} ${state.selected}`); | |
| closeChownModal(); | |
| await openFile(state.selected, document.querySelector(".tree-item.selected")); | |
| } catch (e) { | |
| toast(`chown: ${e.message}`, true); | |
| } | |
| } | |
| async function init() { | |
| bind(); | |
| // Restore username-auth if no token | |
| const savedUser = localStorage.getItem(USER_KEY); | |
| if (!state.token && savedUser) { | |
| state.usernameAuth = savedUser; | |
| } | |
| let health = null; | |
| try { | |
| const res = await api("GET", "/health", { skipAuth: true }); | |
| health = await res.json(); | |
| } catch (e) { | |
| $("counters").textContent = "(server unreachable)"; | |
| return; | |
| } | |
| if (health.needs_bootstrap) { | |
| showBackdrop("bootstrap"); | |
| return; | |
| } | |
| // Validate any stored credential | |
| if (state.token || state.usernameAuth) { | |
| try { | |
| const w = await api("GET", "/auth/whoami"); | |
| const wj = await w.json(); | |
| if (!wj.authenticated && state.token) { | |
| // token rejected | |
| setToken(null); | |
| state.usernameAuth = null; | |
| showBackdrop("login"); | |
| return; | |
| } | |
| } catch (e) { | |
| setToken(null); | |
| state.usernameAuth = null; | |
| showBackdrop("login"); | |
| return; | |
| } | |
| } else { | |
| // No credential at all — show login (anon-root would still work but we want explicit identity) | |
| showBackdrop("login"); | |
| return; | |
| } | |
| await loadWorkspace(); | |
| } | |
| init(); | |