mirror of
https://github.com/runbear-io/beardrive.git
synced 2026-08-25 08:08:08 +02:00
feat(web): native URL path routing (no hash, no %2F)
Replace the frontend's hash-based routing with the History API and real `/` paths, so URLs read like native file paths: /#p-4e61c7d4/shared%2Fidea.md -> /p-4e61c7d4/shared/idea.md /#shared%2Fnotes.md -> /shared/notes.md (volume mode) /#join/<token> -> /join/<token> Client (app.js): - parseRoute() reads location.pathname; pushURL/syncURL push native paths (segments percent-encoded, "/" kept literal); a popstate handler restores back/forward. Invites read from /join/<token>. - All api/ fetches and the app.js/style.css refs are now root-absolute so a deep path doesn't break relative URL resolution. Server (server.go): - New Server.frontend handler: real assets serve directly; every other non-API/auth/share GET returns index.html (SPA fallback), so deep links and refreshes resolve instead of 404ing. Reserved prefixes stay 404s. - Invite links minted as /join/<token> (orgs.go). Tests: TestFrontendSPAFallback covers the fallback + reserved-prefix 404s; existing invite test updated. Verified end to end in the running hub (deep-link reload, back/forward, no %2F/# in the bar). Docs updated. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01R7Q9ZKSZRTdvrSJkYLUmYs
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
2e6068a413
commit
0254c8d464
@@ -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.
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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'>🐻</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>
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user