Merge pull request #6 from runbear-io/feat/native-url-paths

feat(web): native URL path routing (no hash, no %2F)
This commit is contained in:
Snow W. Lee (Sungwon)
2026-07-09 14:19:42 -07:00
committed by GitHub
9 changed files with 155 additions and 69 deletions
+1 -1
View File
@@ -39,7 +39,7 @@ Package roles (`internal/`):
- **`syncer`** — the heart: `Session.Cycle()` runs one pass: scan → commit local ops → pull peer journals → preserve conflict copies → materialize merged state → push blobs + own journal. Read the package doc comment in `syncer.go` first. `ignore.go` holds the path filter (`.bdriveignore` rules + the `.bdrive` include list), applied symmetrically in scan and materialize; a newly filtered path is dropped from the cache *without* a delete op so opting out locally never deletes remotely.
- **`daemon`** — per-mount background loop (detached process, `daemon.pid`/`daemon.log` in the mount's volume dir). Scans every `--scan-interval` (3s), talks to the remote every `--remote-interval` (10s) or immediately after local edits. Re-reads `.bdrive/config.json` each tick; if it vanishes (folder moved/renamed/deleted) the daemon **exits cleanly without propagating deletes** — the next bdrive command at the new location resumes it (self-heal on next touch).
- **`config`** — global state under `$BDRIVE_HOME` (default `~/.bdrive`): device identity (`device.json`), settings (`settings.json`: default server + device token + signed-in account), and the mount registry (`mounts.json`, keyed by **stable mount id**, holding only each mount's last-known path). The per-folder `.bdrive/` directory (`project.go`) holds `config.json` with the mount id + volume/remote/include; **nothing is keyed by the folder path**, so renames/moves are free — `ResolveMount` self-heals the registry path, and the volume store lives at `~/.bdrive/volumes/<mount-id>/`. `.bdrive/` is never synced and holds no credentials.
- **`webapp`** — the `bdrive web` server, in two modes. Single-volume: `Source` is a `DirSource` (plain folder from disk) or `RemoteSource` (folds journals into a file tree with per-file provenance). Hub: `Root` + `Projects` host many projects on one storage root, each under `<root>/<project-id>/` via `remote.Prefixed`; `ProjectDB` (`projects.go`) is a file-backed registry (JSON, loaded at open, rewritten atomically per change) with create-or-join-by-name semantics, name-scoped per organization. Orgs (`orgs.go`, file-backed `orgs.json`) wall projects by membership (email → owner|member): every per-project route — viewer APIs, uploads, history, shares management, the `/store/*` sync proxy — 403s for non-members, `/api/projects` lists only your orgs' projects, owners mint expiring multi-use invite links (`/#join/<token>`), and a pre-org hub migrates all projects into a "default" org (all existing accounts join, oldest owns) at startup. `QuotaProvider` (`quota.go`) is the plan-enforcement seam mirroring `AuthProvider` — CheckWrite/RecordUsage on every write path, CheckSeat on invite redemption; OSS ships only `UnlimitedQuota`, managed deployments swap the provider. Renders markdown (goldmark + Obsidian `[[wikilinks]]`). With `--upload` it accepts writes: browser uploads (`upload.go` — direct-to-storage via presigned URLs when the backend implements `remote.PutSigner`, relayed otherwise; ops journaled under the server's own device) and the per-project `/api/p/<id>/store/*` proxy (`store.go`) that whole devices sync through — the `https://` remote backend (`remote/http.go`) is its client; journals are never presigned, only immutable blobs. Frontend is dependency-free vanilla JS embedded via `go:embed static`; it learns everything from `/api/config` (+ `/api/projects` in hub mode) and never sees storage info or credentials.
- **`webapp`** — the `bdrive web` server, in two modes. Single-volume: `Source` is a `DirSource` (plain folder from disk) or `RemoteSource` (folds journals into a file tree with per-file provenance). Hub: `Root` + `Projects` host many projects on one storage root, each under `<root>/<project-id>/` via `remote.Prefixed`; `ProjectDB` (`projects.go`) is a file-backed registry (JSON, loaded at open, rewritten atomically per change) with create-or-join-by-name semantics, name-scoped per organization. Orgs (`orgs.go`, file-backed `orgs.json`) wall projects by membership (email → owner|member): every per-project route — viewer APIs, uploads, history, shares management, the `/store/*` sync proxy — 403s for non-members, `/api/projects` lists only your orgs' projects, owners mint expiring multi-use invite links (`/join/<token>`), and a pre-org hub migrates all projects into a "default" org (all existing accounts join, oldest owns) at startup. `QuotaProvider` (`quota.go`) is the plan-enforcement seam mirroring `AuthProvider` — CheckWrite/RecordUsage on every write path, CheckSeat on invite redemption; OSS ships only `UnlimitedQuota`, managed deployments swap the provider. Renders markdown (goldmark + Obsidian `[[wikilinks]]`). With `--upload` it accepts writes: browser uploads (`upload.go` — direct-to-storage via presigned URLs when the backend implements `remote.PutSigner`, relayed otherwise; ops journaled under the server's own device) and the per-project `/api/p/<id>/store/*` proxy (`store.go`) that whole devices sync through — the `https://` remote backend (`remote/http.go`) is its client; journals are never presigned, only immutable blobs. Frontend is dependency-free vanilla JS embedded via `go:embed static`; it learns everything from `/api/config` (+ `/api/projects` in hub mode) and never sees storage info or credentials. It uses native History-API path routing (`/<project-id>/<path>` in hub mode, `/<path>` in volume mode, `/join/<token>` for invites — no `#`, slashes stay literal); `Server.frontend` serves `index.html` as the SPA fallback for any non-asset, non-API/auth/share route so deep links and refreshes resolve, and all client API/asset URLs are root-absolute so a deep path doesn't break relative resolution.
`cmd/bdrive/` is a thin cobra CLI over these packages (`login`, `init`, `stop`, `sync`, `status`, `log`, `remote`, `web`, `whoami`, `daemon`, `version``mnt`/`umnt` are gone; `init` is the front door and `stop` pauses). `bdrive login` signs the device in (bare form uses the remembered server or `config.DefaultServer` = beardrive.ai; loopback-callback browser flow in `login.go`, `--device` for headless) and stores server+token+account in `settings.json`. `bdrive init` is interactive on a TTY (survey menus: create-new vs connect-existing with a project list; whole-folder vs `--shared <dir>`, which becomes the include list) with full flag bypass (`--name/--project/--shared/--yes`) and never prompts without a TTY; it runs the login flow first when there is no session, writes `.bdrive/config.json`, seeds `.bdriveignore`, and starts sync via `startSync`; re-running it resumes — including after a folder move. `bdrive web -c config.json` configures the server from a file, explicit flags winning.
+1 -1
View File
@@ -210,7 +210,7 @@ Projects are walled by **organization**: every project belongs to one org
`owner` or `member` role — can see, browse, or sync it. Your first
`bdrive init` creates an org for you automatically; an owner invites
teammates from the web UI (the org name in the sidebar footer — Invite
mints an expiring join link, `/#join/<token>`, that any signed-in account
mints an expiring join link, `/join/<token>`, that any signed-in account
can open to become a member). A hub upgraded from an earlier version
sweeps its existing projects into a `default` org that all existing
accounts join, so nothing breaks. Public share links stay outside the
+36
View File
@@ -65,3 +65,39 @@ func TestDirSourceServesFolder(t *testing.T) {
t.Fatalf("path traversal must 404, got %d", rec.Code)
}
}
// The frontend serves real assets directly but returns the app shell for any
// client-side route (a deep file path, /join/<token>), so a deep link or
// refresh doesn't 404. Reserved API/auth/share prefixes stay real 404s.
func TestFrontendSPAFallback(t *testing.T) {
h := dirServer(t, map[string]string{"notes/plan.md": "content"})
shell := func(url string) {
t.Helper()
rec := get(t, h, url)
if rec.Code != 200 || !strings.Contains(rec.Header().Get("Content-Type"), "text/html") {
t.Fatalf("%s: want 200 html, got %d %s", url, rec.Code, rec.Header().Get("Content-Type"))
}
if !strings.Contains(rec.Body.String(), `id="sidebar"`) {
t.Fatalf("%s: expected the app shell, got %.60q", url, rec.Body.String())
}
}
// Client routes all resolve to the shell, not a 404 or file content.
shell("/")
shell("/notes/plan.md") // a deep file route (not the raw file)
shell("/p-deadbeef/notes/plan.md") // hub-style route
shell("/join/abc123") // invite route
// Real assets are served as themselves.
if rec := get(t, h, "/app.js"); rec.Code != 200 || !strings.Contains(rec.Header().Get("Content-Type"), "javascript") {
t.Fatalf("/app.js: %d %s", rec.Code, rec.Header().Get("Content-Type"))
}
if rec := get(t, h, "/style.css"); rec.Code != 200 || !strings.Contains(rec.Header().Get("Content-Type"), "css") {
t.Fatalf("/style.css: %d %s", rec.Code, rec.Header().Get("Content-Type"))
}
// A mistyped API path is a genuine 404, not the shell.
if rec := get(t, h, "/api/bogus"); rec.Code != 404 {
t.Fatalf("/api/bogus: want 404, got %d", rec.Code)
}
}
+2 -2
View File
@@ -511,7 +511,7 @@ func (s *Server) handleInviteList(w http.ResponseWriter, r *http.Request) {
out := make([]map[string]any, 0, len(invs))
for _, inv := range invs {
out = append(out, map[string]any{
"token": inv.Token, "url": requestBaseURL(r) + "/#join/" + inv.Token,
"token": inv.Token, "url": requestBaseURL(r) + "/join/" + inv.Token,
"creator": inv.Creator, "created": inv.Created, "expires": inv.Expires, "uses": inv.Uses,
})
}
@@ -565,7 +565,7 @@ func (s *Server) handleInviteCreate(w http.ResponseWriter, r *http.Request) {
}
writeJSON(w, map[string]any{
"token": inv.Token,
"url": requestBaseURL(r) + "/#join/" + inv.Token,
"url": requestBaseURL(r) + "/join/" + inv.Token,
"expires": inv.Expires,
})
}
+1 -1
View File
@@ -239,7 +239,7 @@ func TestOrgInviteFlow(t *testing.T) {
if err := json.Unmarshal(rec.Body.Bytes(), &inv); err != nil {
t.Fatal(err)
}
if !strings.Contains(inv.URL, "/#join/"+inv.Token) {
if !strings.Contains(inv.URL, "/join/"+inv.Token) {
t.Fatalf("invite URL = %q", inv.URL)
}
+31 -1
View File
@@ -361,13 +361,43 @@ func (s *Server) Handler() http.Handler {
mux.HandleFunc("POST /api/p/{project}/store/sign", proj(s.handleStoreSign))
mux.HandleFunc("PUT /api/p/{project}/store/object", proj(s.handleStorePut))
mux.Handle("GET /", http.FileServerFS(static))
mux.Handle("GET /", s.frontend(static))
if s.Auth != nil {
s.Auth.Register(mux)
}
return s.rateLimitAuth(s.authGate(mux))
}
// frontend serves the embedded single-page app. Real asset files (app.js,
// style.css) are served directly; every other GET that isn't an API, auth,
// or share route returns index.html, so client-side routes like
// /<project-id>/<path> and /join/<token> survive a deep link or refresh.
func (s *Server) frontend(static fs.FS) http.HandlerFunc {
files := http.FileServerFS(static)
index, _ := fs.ReadFile(static, "index.html")
return func(w http.ResponseWriter, r *http.Request) {
upath := strings.TrimPrefix(path.Clean("/"+r.URL.Path), "/")
// Reserved prefixes that fell through to the catch-all are genuine
// 404s — don't mask a mistyped API/auth/share URL with the app shell.
if strings.HasPrefix(upath, "api/") || strings.HasPrefix(upath, "auth/") || strings.HasPrefix(upath, "s/") {
http.NotFound(w, r)
return
}
if upath != "" && upath != "index.html" {
if f, err := static.Open(upath); err == nil {
fi, statErr := f.Stat()
f.Close()
if statErr == nil && !fi.IsDir() {
files.ServeHTTP(w, r) // a real asset
return
}
}
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Write(index)
}
}
// handleConfig tells the client how this server is configured. Deliberately
// nothing about the storage backend.
func (s *Server) handleConfig(w http.ResponseWriter, r *http.Request) {
+80 -60
View File
@@ -17,7 +17,7 @@ const TEXT_EXT = /\.(txt|log|json|ya?ml|toml|csv|go|py|js|ts|jsx|tsx|sh|bash|zsh
let serverConfig = { mode: "volume", upload: { enabled: false } };
let projects = [];
let currentProject = null; // hub mode: the selected project
let apiBase = "api/"; // volume-scoped endpoint prefix
let apiBase = "/api/"; // volume-scoped endpoint prefix
let orgs = []; // hub mode: the orgs this account belongs to
let joinedOrgId = null; // org just joined via an invite this page-load
@@ -38,7 +38,7 @@ function projColor(s) {
async function getJSON(url) {
const r = await fetch(url);
if (r.status === 401) { // auth required: sign in, then come back here
location.href = "/auth/login?next=" + encodeURIComponent(location.pathname + location.hash);
location.href = "/auth/login?next=" + encodeURIComponent(location.pathname + location.search);
throw new Error("signing in…");
}
if (!r.ok) throw new Error(await r.text());
@@ -52,7 +52,7 @@ async function postJSON(url, body) {
body: JSON.stringify(body || {}),
});
if (r.status === 401) {
location.href = "/auth/login?next=" + encodeURIComponent(location.pathname + location.hash);
location.href = "/auth/login?next=" + encodeURIComponent(location.pathname + location.search);
throw new Error("signing in…");
}
if (!r.ok) throw new Error(await r.text());
@@ -62,23 +62,23 @@ async function postJSON(url, body) {
/* ---- boot ---- */
async function boot() {
try {
serverConfig = await getJSON("api/config");
serverConfig = await getJSON("/api/config");
} catch { /* non-fatal */ }
document.title = serverConfig.brand || serverConfig.volume || "BearDrive";
// If auth is on and we're not signed in, go straight to the login page
// rather than firing authed API calls that 401 (noisy console, and the
// redirect happens anyway). /api/config reports `me` only when signed in.
if (serverConfig.auth && serverConfig.auth.enabled && !serverConfig.me) {
location.href = "/auth/login?next=" + encodeURIComponent(location.pathname + location.hash);
location.href = "/auth/login?next=" + encodeURIComponent(location.pathname + location.search);
return;
}
if (serverConfig.auth && serverConfig.auth.enabled) $("signout").hidden = false;
if (serverConfig.mode === "hub") {
await acceptInviteFromHash();
await acceptInviteFromURL();
await loadOrgs();
await loadProjects();
updateAdminBar();
const { project, path } = parseHash();
const { project, path } = parseRoute();
// After accepting an invite, open a project in the org you just joined
// rather than whatever happened to be first.
const proj = projects.find((x) => x.id === project)
@@ -91,7 +91,7 @@ async function boot() {
$("vault-name").textContent = serverConfig.volume || "BearDrive";
initUpload();
await refreshTree();
const { path } = parseHash();
const { path } = parseRoute();
if (path) openFile(path);
}
setInterval(refreshTree, 15000); // pick up synced changes
@@ -101,7 +101,7 @@ async function boot() {
async function loadProjects() {
let out;
try {
out = await getJSON("api/projects");
out = await getJSON("/api/projects");
} catch { return; }
projects = out.projects || [];
const nav = $("projects");
@@ -147,7 +147,7 @@ function selectProject(p, path) {
currentProject = p;
expanded = new Set(); // fresh collapse state for the new project's tree
treeFirstLoad = true;
apiBase = "api/p/" + p.id + "/";
apiBase = "/api/p/" + p.id + "/";
$("vault-name").textContent = p.name;
document.title = p.name + " — BearDrive";
currentPath = null;
@@ -162,26 +162,26 @@ function selectProject(p, path) {
initHistory();
updateShareButton();
refreshTree().then(() => { if (path) openFile(path); });
if (!path) location.hash = p.id;
if (!path) pushURL("/" + p.id);
}
/* ---- hub: organizations ---- */
/* Opening "#join/<token>" joins the invite's org. If the visitor isn't
/* Opening "/join/<token>" joins the invite's org. If the visitor isn't
signed in yet, postJSON's 401 handler sends them to /auth/login with the
#join hash intact in `next`, so after signing in they land right back
here and the join completes the token is never lost. */
async function acceptInviteFromHash() {
const m = location.hash.match(/^#join\/([0-9a-f]+)$/);
/join path intact in `next`, so after signing in the server re-serves the
app there and the join completes the token is never lost. */
async function acceptInviteFromURL() {
const m = location.pathname.match(/^\/join\/([0-9a-f]+)\/?$/);
if (!m) return;
try {
const out = await postJSON("api/invites/" + m[1]); // may redirect to login (401)
location.hash = "";
const out = await postJSON("/api/invites/" + m[1]); // may redirect to login (401)
history.replaceState(null, "", "/");
joinedOrgId = out.org && out.org.id;
toast("Welcome — you joined the “" + out.org.name + "” team. Opening its projects…");
} catch (e) {
if (String(e.message).includes("signing in")) throw e; // redirecting; stop boot
location.hash = "";
history.replaceState(null, "", "/");
toast("Could not accept the invite: " + e.message, true);
}
}
@@ -202,7 +202,7 @@ function showEmptyState() {
<h3>Have an invite link?</h3>
<p>A teammate can send you a join link. Paste it here:</p>
<div class="ob-row">
<input id="ob-invite" type="text" placeholder="https://…/#join/…" autocomplete="off">
<input id="ob-invite" type="text" placeholder="https://…/join/…" autocomplete="off">
<button id="ob-join" class="pbtn">Join</button>
</div>
</div>` : ``}
@@ -218,10 +218,9 @@ function showEmptyState() {
const join = $("ob-join");
if (join) join.onclick = () => {
const v = $("ob-invite").value.trim();
const m = v.match(/#join\/([0-9a-f]+)/) || v.match(/^([0-9a-f]{8,})$/);
const m = v.match(/join\/([0-9a-f]+)/) || v.match(/^([0-9a-f]{8,})$/);
if (!m) { toast("That doesn't look like an invite link.", true); return; }
location.hash = "join/" + m[1];
location.reload();
location.href = "/join/" + m[1];
};
$("ob-create").onclick = () => createProject($("ob-name").value.trim());
}
@@ -229,7 +228,7 @@ function showEmptyState() {
async function createProject(name) {
if (!name) { toast("Give the project a name.", true); return; }
try {
const out = await postJSON("api/projects", { name });
const out = await postJSON("/api/projects", { name });
await loadOrgs();
await loadProjects();
selectProject(out.project, null);
@@ -241,7 +240,7 @@ async function createProject(name) {
async function loadOrgs() {
try {
orgs = (await getJSON("api/orgs")).orgs || [];
orgs = (await getJSON("/api/orgs")).orgs || [];
} catch { orgs = []; }
}
@@ -299,7 +298,7 @@ async function showOrgAdmin(org) {
rn.querySelector("#org-rename").value = org.name;
rn.querySelector("#org-rename-btn").onclick = async () => {
const name = rn.querySelector("#org-rename").value.trim();
try { await api("PATCH", "api/orgs/" + org.id, { name }); toast("Renamed."); await loadOrgs(); refreshAll(); }
try { await api("PATCH", "/api/orgs/" + org.id, { name }); toast("Renamed."); await loadOrgs(); refreshAll(); }
catch (e) { toast(e.message, true); }
};
}
@@ -319,14 +318,14 @@ async function showOrgAdmin(org) {
if (m.role === r) o.selected = true; sel.appendChild(o);
}
sel.onchange = async () => {
try { await api("PATCH", "api/orgs/" + org.id + "/members/" + encodeURIComponent(m.email), { role: sel.value }); toast("Role updated."); await loadOrgs(); showOrgAdmin(currentOrg()); }
try { await api("PATCH", "/api/orgs/" + org.id + "/members/" + encodeURIComponent(m.email), { role: sel.value }); toast("Role updated."); await loadOrgs(); showOrgAdmin(currentOrg()); }
catch (e) { toast(e.message, true); showOrgAdmin(currentOrg()); }
};
row.appendChild(sel);
const rm = el(row, "button", "ai-del", "Remove");
rm.onclick = async () => {
if (!(await modalConfirm("Remove member", "Remove " + m.email + " from " + org.name + "?", "Remove", true))) return;
try { await api("DELETE", "api/orgs/" + org.id + "/members/" + encodeURIComponent(m.email)); toast("Removed."); await loadOrgs(); showOrgAdmin(currentOrg()); }
try { await api("DELETE", "/api/orgs/" + org.id + "/members/" + encodeURIComponent(m.email)); toast("Removed."); await loadOrgs(); showOrgAdmin(currentOrg()); }
catch (e) { toast(e.message, true); }
};
} else {
@@ -348,14 +347,14 @@ async function showOrgAdmin(org) {
rn.onclick = async () => {
const name = await modalPrompt("Rename project", "New name", p.name, "Rename");
if (!name || name === p.name) return;
try { await api("PATCH", "api/projects/" + p.id, { name }); toast("Renamed."); await loadProjects(); showOrgAdmin(currentOrg()); }
try { await api("PATCH", "/api/projects/" + p.id, { name }); toast("Renamed."); await loadProjects(); showOrgAdmin(currentOrg()); }
catch (e) { toast(e.message, true); }
};
const del = el(row, "button", "ai-del", "Delete");
del.onclick = async () => {
if (!(await modalConfirm("Delete project", "Delete “" + p.name + "”? Its files stay in storage, but it's removed from the hub.", "Delete", true))) return;
try {
await api("DELETE", "api/projects/" + p.id);
await api("DELETE", "/api/projects/" + p.id);
toast("Deleted “" + p.name + "”.");
if (currentProject && currentProject.id === p.id) currentProject = null;
await loadProjects();
@@ -371,7 +370,7 @@ async function showOrgAdmin(org) {
const mk = el(ih, "button", "pbtn", "New invite");
mk.onclick = async () => {
try {
const out = await postJSON("api/orgs/" + org.id + "/invites");
const out = await postJSON("/api/orgs/" + org.id + "/invites");
const ok = await copyText(out.url);
toast(ok ? "Invite link copied to clipboard." : "Invite created — copy it from the list below.");
showOrgAdmin(currentOrg());
@@ -379,7 +378,7 @@ async function showOrgAdmin(org) {
};
const ilist = el(panel, "div", "admin-list");
try {
const invs = (await getJSON("api/orgs/" + org.id + "/invites")).invites || [];
const invs = (await getJSON("/api/orgs/" + org.id + "/invites")).invites || [];
if (!invs.length) el(ilist, "div", "admin-empty", "No active invite links.");
for (const inv of invs) {
const row = el(ilist, "div", "admin-item");
@@ -394,7 +393,7 @@ async function showOrgAdmin(org) {
const rv = el(row, "button", "ai-del", "Revoke");
rv.onclick = async () => {
if (!(await modalConfirm("Revoke invite", "Revoke this invite link? Anyone still holding it won't be able to join.", "Revoke", true))) return;
try { await api("DELETE", "api/orgs/" + org.id + "/invites/" + inv.token); toast("Revoked."); showOrgAdmin(currentOrg()); }
try { await api("DELETE", "/api/orgs/" + org.id + "/invites/" + inv.token); toast("Revoked."); showOrgAdmin(currentOrg()); }
catch (e) { toast(e.message, true); }
};
}
@@ -404,7 +403,7 @@ async function showOrgAdmin(org) {
el(panel, "h3", null, "Public share links");
const slist = el(panel, "div", "admin-list");
try {
const shs = (await getJSON("api/orgs/" + org.id + "/shares")).shares || [];
const shs = (await getJSON("/api/orgs/" + org.id + "/shares")).shares || [];
if (!shs.length) el(slist, "div", "admin-empty", "No public shares.");
for (const sh of shs) {
const row = el(slist, "div", "admin-item");
@@ -418,7 +417,7 @@ async function showOrgAdmin(org) {
const rv = el(row, "button", "ai-del", "Revoke");
rv.onclick = async () => {
if (!(await modalConfirm("Revoke share link", "Revoke the public link to “" + sh.path + "”? Anyone with the URL will lose access.", "Revoke", true))) return;
try { await api("DELETE", "api/shares/" + sh.token); toast("Share revoked."); showOrgAdmin(currentOrg()); }
try { await api("DELETE", "/api/shares/" + sh.token); toast("Share revoked."); showOrgAdmin(currentOrg()); }
catch (e) { toast(e.message, true); }
};
}
@@ -527,7 +526,7 @@ async function updateAdminBar() {
if (!bar) return;
if (!(serverConfig.auth && serverConfig.auth.admin)) { bar.hidden = true; return; }
let pending = [];
try { pending = (await getJSON("api/admin/pending")).pending || []; } catch { }
try { pending = (await getJSON("/api/admin/pending")).pending || []; } catch { }
bar.hidden = false;
bar.innerHTML = svgIcon("shield") + `<span>Admin${pending.length ? " · " + pending.length : ""}</span>`;
bar.title = "Hub administration — signup policy" + (pending.length ? " and pending approvals" : "");
@@ -539,7 +538,7 @@ async function updateAdminBar() {
(they're server-config owned, deliberately not browser-editable). */
async function showHubSettings() {
let pol = {};
try { pol = await getJSON("api/admin/policy"); } catch (e) { toast(e.message, true); return; }
try { pol = await getJSON("/api/admin/policy"); } catch (e) { toast(e.message, true); return; }
currentPath = null; markActive(); closeSidebarOnMobile();
$("crumb").textContent = "Signup & access";
$("share-btn").hidden = $("history-btn").hidden = $("download").hidden = $("more-btn").hidden = true;
@@ -572,7 +571,7 @@ async function showHubSettings() {
save.style.marginTop = "14px";
save.onclick = async () => {
try {
await postJSON("api/admin/policy", { require_verification: ver.checked, require_approval: app.checked });
await postJSON("/api/admin/policy", { require_verification: ver.checked, require_approval: app.checked });
toast("Signup policy saved.");
} catch (e) { toast(e.message, true); }
};
@@ -594,21 +593,21 @@ async function showHubSettings() {
el(panel, "h3", null, "Pending signups");
const plist = el(panel, "div", "admin-list");
let pending = [];
try { pending = (await getJSON("api/admin/pending")).pending || []; } catch { }
try { pending = (await getJSON("/api/admin/pending")).pending || []; } catch { }
if (!pending.length) el(plist, "div", "admin-empty", "No one is waiting for approval.");
for (const u of pending) {
const row = el(plist, "div", "admin-item");
el(row, "span", "ai-main", (u.name ? u.name + " · " : "") + u.email);
const ok = el(row, "button", "pbtn", "Approve");
ok.onclick = async () => { try { await postJSON("api/admin/pending/" + u.id + "/approve"); toast("Approved " + u.email); updateAdminBar(); showHubSettings(); } catch (e) { toast(e.message, true); } };
ok.onclick = async () => { try { await postJSON("/api/admin/pending/" + u.id + "/approve"); toast("Approved " + u.email); updateAdminBar(); showHubSettings(); } catch (e) { toast(e.message, true); } };
const no = el(row, "button", "ai-del", "Deny");
no.onclick = async () => { try { await postJSON("api/admin/pending/" + u.id + "/deny"); toast("Denied " + u.email); updateAdminBar(); showHubSettings(); } catch (e) { toast(e.message, true); } };
no.onclick = async () => { try { await postJSON("/api/admin/pending/" + u.id + "/deny"); toast("Denied " + u.email); updateAdminBar(); showHubSettings(); } catch (e) { toast(e.message, true); } };
}
}
async function showPending() {
let pending = [];
try { pending = (await getJSON("api/admin/pending")).pending || []; } catch { }
try { pending = (await getJSON("/api/admin/pending")).pending || []; } catch { }
currentPath = null; markActive();
$("crumb").textContent = "Pending signups";
$("share-btn").hidden = $("history-btn").hidden = $("download").hidden = $("more-btn").hidden = true;
@@ -622,9 +621,9 @@ async function showPending() {
const row = el(list, "div", "admin-item");
el(row, "span", "ai-main", (u.name ? u.name + " · " : "") + u.email);
const ok = el(row, "button", "pbtn", "Approve");
ok.onclick = async () => { try { await postJSON("api/admin/pending/" + u.id + "/approve"); toast("Approved " + u.email); updateAdminBar(); showPending(); } catch (e) { toast(e.message, true); } };
ok.onclick = async () => { try { await postJSON("/api/admin/pending/" + u.id + "/approve"); toast("Approved " + u.email); updateAdminBar(); showPending(); } catch (e) { toast(e.message, true); } };
const no = el(row, "button", "ai-del", "Deny");
no.onclick = async () => { try { await postJSON("api/admin/pending/" + u.id + "/deny"); toast("Denied " + u.email); updateAdminBar(); showPending(); } catch (e) { toast(e.message, true); } };
no.onclick = async () => { try { await postJSON("/api/admin/pending/" + u.id + "/deny"); toast("Denied " + u.email); updateAdminBar(); showPending(); } catch (e) { toast(e.message, true); } };
}
}
@@ -634,21 +633,40 @@ function refreshAll() {
if (currentProject) refreshTree();
}
/* Hash routing: "#<path>" in volume mode, "#<project-id>/<path>" in hub mode. */
function parseHash() {
const h = decodeURIComponent(location.hash.slice(1));
if (serverConfig.mode !== "hub") return { path: h };
const slash = h.indexOf("/");
if (slash === -1) return { project: h, path: "" };
return { project: h.slice(0, slash), path: h.slice(slash + 1) };
/* Native path routing (no hash, no %2F):
volume mode: /<path>
hub mode: /<project-id>/<path>
invite: /join/<token>
Each path segment is percent-encoded for odd characters, but the "/"
separators stay literal so the URL reads like a real file path. */
function encodePath(p) { return p.split("/").map(encodeURIComponent).join("/"); }
function decodePath(p) { return p.split("/").map(decodeURIComponent).join("/"); }
function parseRoute() {
const raw = location.pathname.replace(/^\/+/, "");
if (serverConfig.mode !== "hub") return { path: raw ? decodePath(raw) : "" };
const slash = raw.indexOf("/");
if (slash === -1) return { project: raw, path: "" };
return { project: raw.slice(0, slash), path: decodePath(raw.slice(slash + 1)) };
}
function setHash(path) {
location.hash = serverConfig.mode === "hub" && currentProject
? currentProject.id + "/" + encodeURIComponent(path)
: encodeURIComponent(path);
/* The URL for a file within the current context. */
function urlForPath(path) {
const enc = encodePath(path);
if (serverConfig.mode === "hub" && currentProject) {
return "/" + currentProject.id + (enc ? "/" + enc : "");
}
return "/" + enc;
}
/* Push a route without reloading, skipping a no-op that would just stack a
duplicate history entry (e.g. when boot opens the file already in the URL). */
function pushURL(url) {
if (location.pathname === url) return;
history.pushState(null, "", url);
}
function syncURL(path) { pushURL(urlForPath(path)); }
/* ---- tree ---- */
async function refreshTree() {
if (serverConfig.mode === "hub" && !currentProject) return;
@@ -764,7 +782,7 @@ function revealInTree(p) {
/* ---- file pane ---- */
async function openFile(p) {
currentPath = p;
setHash(p);
syncURL(p);
markActive();
revealInTree(p);
closeSidebarOnMobile();
@@ -875,7 +893,7 @@ function showShareDialog(url, copied) {
back.querySelector('[data-a="open"]').onclick = () => window.open(url, "_blank");
back.querySelector('[data-a="close"]').onclick = close;
back.querySelector('[data-a="revoke"]').onclick = async () => {
try { await api("DELETE", "api/shares/" + token); toast("Link revoked — it no longer works."); close(); }
try { await api("DELETE", "/api/shares/" + token); toast("Link revoked — it no longer works."); close(); }
catch (e) { toast(e.message, true); }
};
document.body.appendChild(back);
@@ -1316,11 +1334,13 @@ function closeSidebarOnMobile() { document.body.classList.remove("sb-open"); }
$("menu-btn").addEventListener("click", toggleSidebar);
$("sb-backdrop").addEventListener("click", closeSidebarOnMobile);
window.addEventListener("hashchange", () => {
const { project, path } = parseHash();
/* Back/forward: re-resolve the route from the URL. selectProject/openFile
dedup against the current URL, so replaying it here never stacks history. */
window.addEventListener("popstate", () => {
const { project, path } = parseRoute();
if (serverConfig.mode === "hub" && project && (!currentProject || currentProject.id !== project)) {
const proj = projects.find((x) => x.id === project);
if (proj) { selectProject(proj, path); return; }
if (proj) { selectProject(proj, path || null); return; }
}
if (path && path !== currentPath) openFile(path);
});
+2 -2
View File
@@ -4,7 +4,7 @@
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>BearDrive</title>
<link rel="stylesheet" href="style.css">
<link rel="stylesheet" href="/style.css">
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>&#128059;</text></svg>">
</head>
<body>
@@ -84,6 +84,6 @@
<footer id="palette-hint">↑↓ navigate · ⏎ select · esc close</footer>
</div>
</div>
<script src="app.js"></script>
<script src="/app.js"></script>
</body>
</html>
+1 -1
View File
@@ -93,7 +93,7 @@ cd ~/agent-workspace && bdrive init --name agent-workspace
Devices connecting the same project (by name or id) converge through the hub. Direct-to-bucket setups (no hub) remain possible via `bdrive remote set <folder> s3://…` after an offline init.
Hub projects belong to an **organization**: only members of the project's org can see or sync it (project names are scoped per org too). Your first `bdrive init` creates your org automatically. To give a teammate access, an org **owner** opens the web UI and clicks **Invite** in the sidebar footer — it mints an expiring join link (`…/#join/<token>`); the teammate opens it, signs in (or up), and is in. If a teammate's `bdrive init --project <id>` gets 403/404 or the project list looks empty, the missing invite is the reason. Public share links (`bdrive share`) intentionally bypass the org wall.
Hub projects belong to an **organization**: only members of the project's org can see or sync it (project names are scoped per org too). Your first `bdrive init` creates your org automatically. To give a teammate access, an org **owner** opens the web UI and clicks **Invite** in the sidebar footer — it mints an expiring join link (`…/join/<token>`); the teammate opens it, signs in (or up), and is in. If a teammate's `bdrive init --project <id>` gets 403/404 or the project list looks empty, the missing invite is the reason. Public share links (`bdrive share`) intentionally bypass the org wall.
### Renames and moves