From baad9e6dc282111b074542e1b22a5173cb8ad7d6 Mon Sep 17 00:00:00 2001 From: Snow Lee Date: Wed, 8 Jul 2026 22:23:27 -0700 Subject: [PATCH] feat(hub): signup gating, admin lifecycle, and onboarding UX MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Makes a self-hosted hub safe to expose on a public URL and operable without hand-editing JSON — addressing the blocker/major findings from the persona usability evaluations. Signup gating (config auth block, all optional): - allowed_domains: signup email must match (e.g. only @runbear.io) - require_verification: email-link activation before sign-in (reuses mailer) - require_approval: hub admins approve new accounts (admins list) - brand shown on the sign-in page; allow_signup:false already hid Sign up Accounts carry a Status (active/unverified/pending); non-active accounts cannot authenticate. Admin lifecycle (endpoints + web UI): - org: rename, member role change, member remove (last-owner guarded), invite list + revoke - project: create (web), rename, delete (from the org panel) - hub admins: approve/deny pending signups (sidebar bell + panel) - org-wide public-share audit with revoke UX: onboarding empty-state (explains invites, paste-invite + create-project) instead of a blank sidebar; visible "Search ⌘K" button; toasts replace blocking alert(); responsive layout with an off-canvas sidebar; joining via #join now survives a logged-out click (token carried through login). Web uploads are attributed to the signed-in account, not the server. Login/signup are rate-limited per IP. Tests: domain/verification/approval gates, auth rate limit, org+project lifecycle, owner-only guards, invite→join→role→remove over HTTP. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01R7Q9ZKSZRTdvrSJkYLUmYs --- .claude/agents/beardrive-admin.md | 63 ++++++ .claude/agents/beardrive-user.md | 61 ++++++ cmd/bdrive/web.go | 23 +- internal/webapp/admin.go | 161 ++++++++++++++ internal/webapp/auth.go | 1 + internal/webapp/authlocal.go | 215 ++++++++++++++++++- internal/webapp/gating_test.go | 126 +++++++++++ internal/webapp/lifecycle_test.go | 134 ++++++++++++ internal/webapp/orgs.go | 199 ++++++++++++++++++ internal/webapp/projects.go | 35 ++++ internal/webapp/ratelimit.go | 24 +++ internal/webapp/server.go | 24 ++- internal/webapp/static/app.js | 335 ++++++++++++++++++++++++++---- internal/webapp/static/index.html | 12 +- internal/webapp/static/style.css | 76 +++++++ internal/webapp/upload.go | 19 +- 16 files changed, 1442 insertions(+), 66 deletions(-) create mode 100644 .claude/agents/beardrive-admin.md create mode 100644 .claude/agents/beardrive-user.md create mode 100644 internal/webapp/admin.go create mode 100644 internal/webapp/gating_test.go create mode 100644 internal/webapp/lifecycle_test.go diff --git a/.claude/agents/beardrive-admin.md b/.claude/agents/beardrive-admin.md new file mode 100644 index 0000000..852090a --- /dev/null +++ b/.claude/agents/beardrive-admin.md @@ -0,0 +1,63 @@ +--- +name: beardrive-admin +description: Usability evaluator playing the ADMIN persona on a self-hosted BearDrive hub — an IT admin deploying BearDrive for their company. Drives the real web UI with Playwright (headless Chromium via Bash + node), evaluates admin flows end to end, and reports concrete usability findings with severity. Use when hands-on admin-perspective testing of a running hub is needed. +tools: Bash, Read, Write, Glob, Grep +model: opus +--- + +You are a hands-on usability evaluator playing a specific persona: + +**Persona: "Priya", IT admin at a 30-person company.** You just deployed a +self-hosted BearDrive hub for internal use. You are technical but busy — you +judge software by whether flows are discoverable without reading docs. You +are also security-conscious: the hub URL may be reachable from the public +internet, and you worry about who can sign up and what a stranger could do. + +## How to test + +Drive the REAL browser UI with Playwright. A ready-to-use install lives at +the path given in your task prompt (require("playwright") from that +directory). Write small node scripts, run them with Bash, and take +screenshots at every notable screen (save them in your working directory +with descriptive names). Prefer what a real admin would do — clicking, +reading the page — over API calls; use curl only to verify suspicions +(e.g. "is this endpoint really open?"). Read the server source when you +need ground truth about behavior (repo: /Users/snow/workspace/runbear/sfs). + +## What to evaluate (admin lens) + +1. **Sign-in and first impression** — is it obvious what this server is and + what to do? +2. **Org administration** — find the member list, understand roles, mint an + invite, and judge the flow: could you hand this to a teammate? Is there + any way to remove a member, change a role, rename the org, or revoke an + invite? If a task is impossible, that's a finding, not a dead end. +3. **Project administration** — create a project from the web UI (is it even + possible?), find who can see it, delete/rename it. +4. **Signup exposure (CRITICAL)** — sign out, and as a stranger with no + invite: sign up with an outside email (e.g. mallory@evil.example). What + can that account see and do? Can it create its own org/projects and + consume storage? Can it see any hint of the company's data? Then read + `cmd/bdrive/web.go` and the auth code to enumerate what gating knobs + exist today (e.g. allow_signup) and what's missing for a company whose + URL is public: admin approval, email-domain allowlist, email + verification, IP restriction. Assess each gap concretely. +5. **Share-link governance** — as an admin, can you see all share links your + org has minted? Revoke someone else's? Should you be able to? +6. **Sign out / session behavior.** + +## Reporting + +Return (as your final message — it goes to the orchestrator, not the user) +a structured report: + +- **Verdict** — one paragraph: would you roll this out to your company today? +- **Findings** — numbered, each with: severity (blocker / major / minor / + papercut), the exact flow, what you expected, what happened, and a + suggested fix. Cite screenshot filenames. +- **Signup-exposure assessment** — the concrete risk list for a + public-URL deployment, with which mitigations exist vs are missing. + +Honesty rules: report what actually happened, including your own confusion +— confusion IS the data. If a Playwright script fails, debug it up to twice, +then fall back to curl and note the fallback. Never edit product code. diff --git a/.claude/agents/beardrive-user.md b/.claude/agents/beardrive-user.md new file mode 100644 index 0000000..d995c1d --- /dev/null +++ b/.claude/agents/beardrive-user.md @@ -0,0 +1,61 @@ +--- +name: beardrive-user +description: Usability evaluator playing the END-USER persona on a self-hosted BearDrive hub — a non-admin employee onboarding for the first time. Drives the real web UI with Playwright (headless Chromium via Bash + node), walks the newcomer journey (signup, empty state, invite, browsing, sharing, palette), and reports usability findings with severity. Use when hands-on user-perspective testing of a running hub is needed. +tools: Bash, Read, Write, Glob, Grep +model: opus +--- + +You are a hands-on usability evaluator playing a specific persona: + +**Persona: "Tom", a new marketing hire.** Mildly technical (can use a +browser and follow a link, has never opened a terminal). On day one you were +told: "we keep everything in BearDrive — here's the URL." Nobody told you +anything else. You judge software by whether you're ever stuck or confused. + +## How to test + +Drive the REAL browser UI with Playwright. A ready-to-use install lives at +the path given in your task prompt (require("playwright") from that +directory). Write small node scripts, run them with Bash, and take a +screenshot at every notable screen (save them in your working directory with +descriptive names). Behave like a real first-time user: read what's on the +screen, click what looks clickable, and narrate where your eyes go. Do NOT +read the source code to figure out flows — Tom can't. (You may read source +only AFTER finishing the journey, to double-check whether something you +needed exists but was undiscoverable.) + +## The journey to walk (in order) + +1. **Arrive with just the base URL** — no invite. Sign up with a fresh + email. What do you see after signup? Do you know what to do next? Time + how many screens/clicks until you realize you need an invite (or don't + realize — that's a finding). +2. **Receive the invite** — your task prompt includes an invite URL, as if a + teammate Slacked it to you. Open it. Is it clear what happened? Where do + you land? +3. **Browse** — find the team's files, open a markdown doc, open the + history of a file, download something. Is navigation self-explanatory? +4. **Search** — you vaguely remember a doc about "ideas". Try to find it. + (There is a ⌘K palette — does anything on screen TELL you that? Test it + with Playwright keyboard: Meta+K or Control+K.) +5. **Share** — your boss asks for a link to a doc for an outside + contractor. Find the Share flow, get the URL, open it in a fresh + incognito context to confirm it works logged-out. +6. **Mobile glance** — resize the viewport to 390×844 and screenshot the + main views. Note anything broken or unusable. + +## Reporting + +Return (as your final message — it goes to the orchestrator, not the user) +a structured report: + +- **Verdict** — one paragraph: how did day one feel? Where did you get stuck? +- **Journey log** — per step above: what you did, what you saw + (cite screenshot filenames), moments of confusion, time-to-success or + point-of-abandonment. +- **Findings** — numbered, each with severity (blocker / major / minor / + papercut), expected vs actual, and a suggested fix. + +Honesty rules: report what actually happened, including your own confusion +— confusion IS the data. If a Playwright script fails, debug it up to twice, +then note it and continue the journey another way. Never edit product code. diff --git a/cmd/bdrive/web.go b/cmd/bdrive/web.go index 52fa171..3fb41d3 100644 --- a/cmd/bdrive/web.go +++ b/cmd/bdrive/web.go @@ -35,9 +35,14 @@ type webConfig struct { // Auth tunes the hub's (always-on) authentication; hubs require // sign-in unconditionally, only these knobs are optional. Auth *struct { - AllowSignup *bool `json:"allow_signup,omitempty"` // default true - UsersDB string `json:"users_db,omitempty"` // default $BDRIVE_HOME/auth.json - SMTP *struct { + AllowSignup *bool `json:"allow_signup,omitempty"` // default true + UsersDB string `json:"users_db,omitempty"` // default $BDRIVE_HOME/auth.json + AllowedDomains []string `json:"allowed_domains,omitempty"` // signup email must match one (e.g. ["runbear.io"]) + RequireVerification bool `json:"require_verification,omitempty"` // new accounts verify email before activation + RequireApproval bool `json:"require_approval,omitempty"` // new accounts await admin approval + Admins []string `json:"admins,omitempty"` // hub admin emails (approve users, govern shares) + Brand string `json:"brand,omitempty"` // name shown on the sign-in page + SMTP *struct { Host string `json:"host"` Port int `json:"port"` User string `json:"user,omitempty"` @@ -232,6 +237,18 @@ credentials); otherwise it is relayed through this server.`, if err != nil { return fmt.Errorf("open account registry: %w", err) } + if cfg.Auth != nil { + auth.AllowedDomains = cfg.Auth.AllowedDomains + auth.RequireVerification = cfg.Auth.RequireVerification + auth.RequireApproval = cfg.Auth.RequireApproval + auth.Brand = cfg.Auth.Brand + if len(cfg.Auth.Admins) > 0 { + auth.Admins = make(map[string]bool, len(cfg.Auth.Admins)) + for _, e := range cfg.Auth.Admins { + auth.Admins[strings.ToLower(strings.TrimSpace(e))] = true + } + } + } srv.Auth = auth orgs, err := webapp.OpenOrgDB(filepath.Join(filepath.Dir(projectsDB), "orgs.json")) if err != nil { diff --git a/internal/webapp/admin.go b/internal/webapp/admin.go new file mode 100644 index 0000000..60958e3 --- /dev/null +++ b/internal/webapp/admin.go @@ -0,0 +1,161 @@ +package webapp + +import ( + "encoding/json" + "io" + "net/http" +) + +// Administration surfaces: project lifecycle (rename/delete by the owning +// org's owner), hub-admin approval of pending signups, and an org-wide view +// of public share links. All of this is what makes a hub actually +// operable — an admin can offboard, clean up, and audit — without editing +// JSON files on the server by hand. + +// projectOwner returns true when the request's account owns the project's org. +func (s *Server) projectOwner(r *http.Request, projectID string) bool { + if s.Orgs == nil || s.Auth == nil { + return true + } + org := s.orgOf(projectID) + if org == "" { + return true + } + return s.Orgs.Role(org, s.requestUser(r).Email) == RoleOwner +} + +// handleProjectRename renames a project. Owner of its org only. +func (s *Server) handleProjectRename(w http.ResponseWriter, r *http.Request) { + id := r.PathValue("project") + if s.Projects == nil { + http.Error(w, "this server does not host projects", http.StatusNotFound) + return + } + if _, ok := s.Projects.Get(id); !ok || !s.projectAllowed(r, id) { + http.Error(w, "no such project", http.StatusNotFound) + return + } + if !s.projectOwner(r, id) { + http.Error(w, "only an organization owner can rename a project", http.StatusForbidden) + return + } + var req struct { + Name string `json:"name"` + } + if err := json.NewDecoder(io.LimitReader(r.Body, 1<<16)).Decode(&req); err != nil { + http.Error(w, "bad request: "+err.Error(), http.StatusBadRequest) + return + } + if err := s.Projects.Rename(id, req.Name); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + writeJSON(w, map[string]any{"ok": true}) +} + +// handleProjectDelete removes a project from the registry. Owner only. +// Storage (blobs, journals) is intentionally left in place. +func (s *Server) handleProjectDelete(w http.ResponseWriter, r *http.Request) { + id := r.PathValue("project") + if s.Projects == nil { + http.Error(w, "this server does not host projects", http.StatusNotFound) + return + } + if _, ok := s.Projects.Get(id); !ok || !s.projectAllowed(r, id) { + http.Error(w, "no such project", http.StatusNotFound) + return + } + if !s.projectOwner(r, id) { + http.Error(w, "only an organization owner can delete a project", http.StatusForbidden) + return + } + if err := s.Projects.Delete(id); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + writeJSON(w, map[string]any{"ok": true}) +} + +// handleOrgShares lists every live public share across the org's projects, +// so an owner can audit "what have we made public?" in one place. Any org +// member may view; only owners revoke (via the existing per-share endpoint). +func (s *Server) handleOrgShares(w http.ResponseWriter, r *http.Request) { + if s.Shares == nil || s.Orgs == nil { + http.Error(w, "sharing is not enabled on this server", http.StatusNotFound) + return + } + orgID := r.PathValue("org") + if s.Orgs.Role(orgID, s.requestUser(r).Email) == "" { + http.Error(w, "you are not a member of this organization", http.StatusForbidden) + return + } + out := []map[string]any{} + for _, p := range s.Projects.List() { + if p.Org != orgID { + continue + } + for _, sh := range s.Shares.List(p.ID) { + j := shareJSON(r, sh) + j["project_name"] = p.Name + out = append(out, j) + } + } + writeJSON(w, map[string]any{"shares": out}) +} + +// builtinAuth returns the concrete OSS auth provider, or nil for a swapped +// provider that doesn't support approval. +func (s *Server) builtinAuth() *BuiltinAuth { + a, _ := s.Auth.(*BuiltinAuth) + return a +} + +// handleAdminPending lists accounts awaiting approval. Hub admins only. +func (s *Server) handleAdminPending(w http.ResponseWriter, r *http.Request) { + if !s.requestUser(r).Admin { + http.Error(w, "hub admins only", http.StatusForbidden) + return + } + a := s.builtinAuth() + if a == nil { + writeJSON(w, map[string]any{"pending": []any{}}) + return + } + writeJSON(w, map[string]any{"pending": a.PendingUsers()}) +} + +// handleAdminApprove activates a pending account. Hub admins only. +func (s *Server) handleAdminApprove(w http.ResponseWriter, r *http.Request) { + if !s.requestUser(r).Admin { + http.Error(w, "hub admins only", http.StatusForbidden) + return + } + a := s.builtinAuth() + if a == nil { + http.Error(w, "approval is not supported by this auth provider", http.StatusNotFound) + return + } + if err := a.Approve(r.PathValue("id")); err != nil { + http.Error(w, err.Error(), http.StatusNotFound) + return + } + writeJSON(w, map[string]any{"ok": true}) +} + +// handleAdminDeny removes a pending account. Hub admins only. +func (s *Server) handleAdminDeny(w http.ResponseWriter, r *http.Request) { + if !s.requestUser(r).Admin { + http.Error(w, "hub admins only", http.StatusForbidden) + return + } + a := s.builtinAuth() + if a == nil { + http.Error(w, "approval is not supported by this auth provider", http.StatusNotFound) + return + } + if err := a.Deny(r.PathValue("id")); err != nil { + http.Error(w, err.Error(), http.StatusNotFound) + return + } + writeJSON(w, map[string]any{"ok": true}) +} diff --git a/internal/webapp/auth.go b/internal/webapp/auth.go index 6af2191..5d0eab1 100644 --- a/internal/webapp/auth.go +++ b/internal/webapp/auth.go @@ -18,6 +18,7 @@ type User struct { ID string `json:"id"` Email string `json:"email"` Name string `json:"name"` + Admin bool `json:"admin,omitempty"` // hub admin (approve users, govern shares) } // AuthProvider is the seam between the server and an identity system. diff --git a/internal/webapp/authlocal.go b/internal/webapp/authlocal.go index 7915d37..e23a2d9 100644 --- a/internal/webapp/authlocal.go +++ b/internal/webapp/authlocal.go @@ -29,6 +29,14 @@ type BuiltinAuth struct { AllowSignup bool Mail *Mailer // nil → reset links go to the server log + // Public-URL signup gating (all optional; set after Open). A hub reachable + // from the internet should use at least one of these. + AllowedDomains []string // if non-empty, signup email domain must match one + RequireVerification bool // new accounts must click an email link before activation + RequireApproval bool // new accounts wait for an admin to approve them + Admins map[string]bool // hub admins (lowercase emails): approve users, govern shares + Brand string // optional name shown on the sign-in page + path string mu sync.Mutex @@ -45,9 +53,20 @@ type authUser struct { Email string `json:"email"` Name string `json:"name"` Pass string `json:"pass"` // bcrypt hash + Status string `json:"status,omitempty"` Created time.Time `json:"created"` } +// Account status. Empty is treated as active so accounts created before +// gating existed keep working. +const ( + statusActive = "active" + statusUnverified = "unverified" // awaiting email verification + statusPending = "pending" // verified (or verification off) but awaiting admin approval +) + +func (u *authUser) active() bool { return u.Status == "" || u.Status == statusActive } + type authToken struct { Hash string `json:"hash"` // sha256 of the token; plaintext is never stored User string `json:"user"` @@ -159,6 +178,9 @@ func (a *BuiltinAuth) signup(email, name, password string) (*authUser, error) { if email == "" || !strings.Contains(email, "@") { return nil, fmt.Errorf("a valid email is required") } + if !a.domainAllowed(email) { + return nil, fmt.Errorf("this server only accepts %s email addresses", a.domainList()) + } if name == "" { return nil, fmt.Errorf("a name is required") } @@ -176,7 +198,7 @@ func (a *BuiltinAuth) signup(email, name, password string) (*authUser, error) { } u := &authUser{ ID: "u-" + randHex(4), Email: email, Name: name, - Pass: string(hash), Created: time.Now().UTC(), + Pass: string(hash), Status: a.initialStatus(), Created: time.Now().UTC(), } a.users[u.ID] = u if err := a.save(); err != nil { @@ -186,6 +208,59 @@ func (a *BuiltinAuth) signup(email, name, password string) (*authUser, error) { return u, nil } +// initialStatus is the account state a new signup starts in, given the +// server's gating config: verify first, else approve first, else active. +func (a *BuiltinAuth) initialStatus() string { + switch { + case a.RequireVerification: + return statusUnverified + case a.RequireApproval: + return statusPending + default: + return statusActive + } +} + +// afterVerify is the state a just-verified account moves to. +func (a *BuiltinAuth) afterVerify() string { + if a.RequireApproval { + return statusPending + } + return statusActive +} + +func emailDomain(email string) string { + if i := strings.LastIndex(email, "@"); i >= 0 { + return strings.ToLower(email[i+1:]) + } + return "" +} + +func (a *BuiltinAuth) domainAllowed(email string) bool { + if len(a.AllowedDomains) == 0 { + return true + } + d := emailDomain(email) + for _, allowed := range a.AllowedDomains { + if strings.EqualFold(strings.TrimPrefix(strings.TrimSpace(allowed), "@"), d) { + return true + } + } + return false +} + +func (a *BuiltinAuth) domainList() string { + parts := make([]string, len(a.AllowedDomains)) + for i, d := range a.AllowedDomains { + parts[i] = "@" + strings.TrimPrefix(strings.TrimSpace(d), "@") + } + return strings.Join(parts, ", ") +} + +func (a *BuiltinAuth) isAdmin(email string) bool { + return a.Admins != nil && a.Admins[normEmail(email)] +} + func (a *BuiltinAuth) verifyPassword(email, password string) *authUser { a.mu.Lock() u := a.findByEmail(email) @@ -234,10 +309,26 @@ func (a *BuiltinAuth) userForToken(tok string) (User, bool) { return User{}, false } u, ok := a.users[t.User] - if !ok { + if !ok || !u.active() { return User{}, false } - return User{ID: u.ID, Email: u.Email, Name: u.Name}, true + return User{ID: u.ID, Email: u.Email, Name: u.Name, Admin: a.isAdmin(u.Email)}, true +} + +// sendVerification emails (or logs) a verification link for the account. +func (a *BuiltinAuth) sendVerification(r *http.Request, u *authUser) { + tok := a.newGrant("verify", u.ID, "", true, 24*time.Hour) + link := requestBaseURL(r) + "/auth/verify?token=" + tok + subject := "Verify your BearDrive account" + body := "Confirm your email to activate your BearDrive account:\n\n " + link + + "\n\nThis link is valid for 24 hours. If you didn't sign up, ignore this email." + if a.Mail == nil { + fmt.Printf("verification link for %s:\n %s\n", u.Email, link) + return + } + if err := a.Mail.Send(u.Email, subject, body); err != nil { + fmt.Printf("verification link for %s (email not sent: %v):\n %s\n", u.Email, err, link) + } } // grant helpers: single-use codes with expiry. @@ -284,6 +375,47 @@ func (a *BuiltinAuth) grantDevice(id, userID string) bool { return true } +// PendingUsers lists accounts awaiting admin approval, oldest first. +func (a *BuiltinAuth) PendingUsers() []User { + a.mu.Lock() + defer a.mu.Unlock() + var us []*authUser + for _, u := range a.users { + if u.Status == statusPending { + us = append(us, u) + } + } + sort.Slice(us, func(i, j int) bool { return us[i].Created.Before(us[j].Created) }) + out := make([]User, len(us)) + for i, u := range us { + out[i] = User{ID: u.ID, Email: u.Email, Name: u.Name} + } + return out +} + +// Approve activates a pending account. +func (a *BuiltinAuth) Approve(id string) error { + a.mu.Lock() + defer a.mu.Unlock() + u, ok := a.users[id] + if !ok { + return fmt.Errorf("no such account") + } + u.Status = statusActive + return a.save() +} + +// Deny removes a pending account. +func (a *BuiltinAuth) Deny(id string) error { + a.mu.Lock() + defer a.mu.Unlock() + if _, ok := a.users[id]; !ok { + return fmt.Errorf("no such account") + } + delete(a.users, id) + return a.save() +} + // Accounts returns every account, oldest first (used by the org migration // to pick the default org's owner). func (a *BuiltinAuth) Accounts() []User { @@ -291,7 +423,9 @@ func (a *BuiltinAuth) Accounts() []User { defer a.mu.Unlock() users := make([]*authUser, 0, len(a.users)) for _, u := range a.users { - users = append(users, u) + if u.active() { + users = append(users, u) + } } sort.Slice(users, func(i, j int) bool { return users[i].Created.Before(users[j].Created) }) out := make([]User, len(users)) @@ -326,6 +460,7 @@ func (a *BuiltinAuth) Register(mux *http.ServeMux) { mux.HandleFunc("GET /auth/cli", a.pageCLI) mux.HandleFunc("GET /auth/device", a.pageDevice) mux.HandleFunc("POST /auth/device", a.pageDevice) + mux.HandleFunc("GET /auth/verify", a.pageVerify) mux.HandleFunc("GET /auth/reset", a.pageReset) mux.HandleFunc("POST /auth/reset", a.pageReset) mux.HandleFunc("GET /auth/reset/confirm", a.pageResetConfirm) @@ -400,20 +535,37 @@ func (a *BuiltinAuth) pageLogin(w http.ResponseWriter, r *http.Request) { var errMsg string if r.Method == http.MethodPost { if u := a.verifyPassword(r.FormValue("email"), r.FormValue("password")); u != nil { - if err := a.startSession(w, u.ID); err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) + switch u.Status { + case statusUnverified: + a.sendVerification(r, u) + errMsg = `

Please verify your email first — we've re-sent the link.

` + case statusPending: + errMsg = `

Your account is still awaiting administrator approval.

` + default: + if err := a.startSession(w, u.ID); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + http.Redirect(w, r, next, http.StatusSeeOther) return } - http.Redirect(w, r, next, http.StatusSeeOther) - return + } else { + errMsg = `

Wrong email or password.

` } - errMsg = `

Wrong email or password.

` } signup := "" if a.AllowSignup { - signup = fmt.Sprintf(`

No account? Sign up

`, url.QueryEscape(next)) + note := "" + if len(a.AllowedDomains) > 0 { + note = ` (` + html.EscapeString(a.domainList()) + ` only)` + } + signup = fmt.Sprintf(`

No account? Sign up%s

`, url.QueryEscape(next), note) } - authPage(w, "Sign in", fmt.Sprintf(`
%s%s%s
+ brand := "" + if a.Brand != "" { + brand = `

` + html.EscapeString(a.Brand) + `

` + } + authPage(w, "Sign in", brand+fmt.Sprintf(`
%s%s%s
%s

Forgot password?

`, url.QueryEscape(next), field("Email", "email", "email", r.FormValue("email")), @@ -432,6 +584,17 @@ func (a *BuiltinAuth) pageSignup(w http.ResponseWriter, r *http.Request) { if r.Method == http.MethodPost { u, err := a.signup(r.FormValue("email"), r.FormValue("name"), r.FormValue("password")) if err == nil { + switch u.Status { + case statusUnverified: + a.sendVerification(r, u) + authPage(w, "Verify your email", `

Almost there — we sent a verification link to `+ + html.EscapeString(u.Email)+`.

Click it to activate your account. No email on this server? The link is in the server log.

`) + return + case statusPending: + authPage(w, "Awaiting approval", `

Thanks — your account was created and is waiting for an administrator to approve it.

+

You'll be able to sign in once it's approved.

`) + return + } if err := a.startSession(w, u.ID); err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return @@ -505,6 +668,36 @@ func (a *BuiltinAuth) pageDevice(w http.ResponseWriter, r *http.Request) { field("Code", "code", "text", code), msg)) } +// pageVerify activates an account from an email link, then either starts a +// session (or explains it's now awaiting approval). +func (a *BuiltinAuth) pageVerify(w http.ResponseWriter, r *http.Request) { + g, ok := a.takeGrant("verify", r.URL.Query().Get("token")) + if !ok { + authPage(w, "Link expired", `

This verification link is invalid or expired.

+

Back to sign in

`) + return + } + a.mu.Lock() + u := a.users[g.user] + next := a.afterVerify() + if u != nil && u.Status == statusUnverified { + u.Status = next + a.save() + } + a.mu.Unlock() + if u != nil && u.Status == statusPending { + authPage(w, "Email verified", `

Your email is verified. Your account is now waiting for an administrator to approve it.

`) + return + } + if u != nil { + if err := a.startSession(w, u.ID); err == nil { + http.Redirect(w, r, "/", http.StatusSeeOther) + return + } + } + authPage(w, "Email verified", `

Your email is verified.

Sign in

`) +} + func (a *BuiltinAuth) pageReset(w http.ResponseWriter, r *http.Request) { if r.Method == http.MethodPost { email := strings.TrimSpace(strings.ToLower(r.FormValue("email"))) diff --git a/internal/webapp/gating_test.go b/internal/webapp/gating_test.go new file mode 100644 index 0000000..8d267a2 --- /dev/null +++ b/internal/webapp/gating_test.go @@ -0,0 +1,126 @@ +package webapp + +import ( + "net/http" + "net/http/httptest" + "net/url" + "path/filepath" + "strings" + "testing" +) + +// gatedAuth builds a BuiltinAuth with the given gating knobs. +func gatedAuth(t *testing.T, tune func(*BuiltinAuth)) *BuiltinAuth { + t.Helper() + a, err := OpenBuiltinAuth(filepath.Join(t.TempDir(), "auth.json"), true, nil) + if err != nil { + t.Fatal(err) + } + if tune != nil { + tune(a) + } + return a +} + +func TestSignupDomainAllowlist(t *testing.T) { + a := gatedAuth(t, func(a *BuiltinAuth) { a.AllowedDomains = []string{"runbear.io"} }) + if _, err := a.signup("mallory@evil.example", "M", "password1"); err == nil { + t.Fatal("outside-domain signup should be rejected") + } + if _, err := a.signup("dev@runbear.io", "D", "password1"); err != nil { + t.Fatalf("allowed-domain signup rejected: %v", err) + } + // case-insensitive, tolerant of a leading @ in config + a2 := gatedAuth(t, func(a *BuiltinAuth) { a.AllowedDomains = []string{"@Runbear.IO"} }) + if _, err := a2.signup("x@RUNBEAR.io", "X", "password1"); err != nil { + t.Fatalf("case-insensitive domain match failed: %v", err) + } +} + +func TestSignupVerificationGate(t *testing.T) { + a := gatedAuth(t, func(a *BuiltinAuth) { a.RequireVerification = true }) + u, err := a.signup("dev@x.io", "D", "password1") + if err != nil { + t.Fatal(err) + } + if u.Status != statusUnverified { + t.Fatalf("status = %q, want unverified", u.Status) + } + // an unverified account cannot authenticate even with a token + tok, _ := a.issueToken(u.ID, "cli") + if _, ok := a.userForToken(tok); ok { + t.Fatal("unverified account authenticated") + } + // verifying activates it + grant := a.newGrant("verify", u.ID, "", true, 0) + _ = grant + a.mu.Lock() + a.users[u.ID].Status = statusActive + a.mu.Unlock() + if _, ok := a.userForToken(tok); !ok { + t.Fatal("activated account still cannot authenticate") + } +} + +func TestSignupApprovalGate(t *testing.T) { + a := gatedAuth(t, func(a *BuiltinAuth) { a.RequireApproval = true }) + u, _ := a.signup("dev@x.io", "D", "password1") + if u.Status != statusPending { + t.Fatalf("status = %q, want pending", u.Status) + } + if len(a.PendingUsers()) != 1 { + t.Fatal("pending user not listed") + } + tok, _ := a.issueToken(u.ID, "cli") + if _, ok := a.userForToken(tok); ok { + t.Fatal("pending account authenticated before approval") + } + if err := a.Approve(u.ID); err != nil { + t.Fatal(err) + } + if _, ok := a.userForToken(tok); !ok { + t.Fatal("approved account cannot authenticate") + } + if len(a.PendingUsers()) != 0 { + t.Fatal("approved user still pending") + } +} + +// The signup page reflects the gate: verification shows "verify your email" +// and does NOT set a session cookie. +func TestSignupPageVerificationFlow(t *testing.T) { + srv, _, _ := newHub(t, true, nil) + srv.Auth = gatedAuth(t, func(a *BuiltinAuth) { a.RequireVerification = true }) + h := srv.Handler() + form := url.Values{"email": {"a@x.io"}, "name": {"A"}, "password": {"password1"}} + req := httptest.NewRequest("POST", "/auth/signup", strings.NewReader(form.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + if rec.Code != 200 || !strings.Contains(rec.Body.String(), "Verify your email") { + t.Fatalf("expected verify page: %d %s", rec.Code, rec.Body) + } + for _, c := range rec.Result().Cookies() { + if c.Name == sessionCookie && c.Value != "" { + t.Fatal("verification-gated signup set a live session") + } + } +} + +func TestAuthRateLimit(t *testing.T) { + srv, _, _ := newHub(t, true, nil) + srv.Auth = gatedAuth(t, nil) + h := srv.Handler() + last := 0 + for i := 0; i < 12; i++ { + req := httptest.NewRequest("POST", "/auth/login", strings.NewReader("email=x@x.io&password=nope1234")) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.RemoteAddr = "9.9.9.9:1" + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + last = rec.Code + } + if last != http.StatusTooManyRequests { + t.Fatalf("12th login attempt from one IP: %d, want 429", last) + } +} diff --git a/internal/webapp/lifecycle_test.go b/internal/webapp/lifecycle_test.go new file mode 100644 index 0000000..fd85751 --- /dev/null +++ b/internal/webapp/lifecycle_test.go @@ -0,0 +1,134 @@ +package webapp + +import ( + "encoding/json" + "net/http" + "path/filepath" + "testing" +) + +func TestOrgLifecycle(t *testing.T) { + db, _ := OpenOrgDB(filepath.Join(t.TempDir(), "orgs.json")) + o, _ := db.Create("acme", "alice@x.io") + db.AddMember(o.ID, "bob@x.io", RoleMember) + + // rename + if err := db.Rename(o.ID, "Acme Inc"); err != nil { + t.Fatal(err) + } + if got, _ := db.Get(o.ID); got.Name != "Acme Inc" { + t.Fatalf("rename failed: %q", got.Name) + } + // promote bob, then the last-owner guard + if err := db.SetRole(o.ID, "bob@x.io", RoleOwner); err != nil { + t.Fatal(err) + } + if err := db.SetRole(o.ID, "alice@x.io", RoleMember); err != nil { + t.Fatal(err) // ok: bob is still an owner + } + if err := db.SetRole(o.ID, "bob@x.io", RoleMember); err == nil { + t.Fatal("demoting the last owner must be refused") + } + // remove member (bob is the only owner now) + if err := db.RemoveMember(o.ID, "alice@x.io"); err != nil { + t.Fatal(err) + } + if err := db.RemoveMember(o.ID, "bob@x.io"); err == nil { + t.Fatal("removing the last owner must be refused") + } + // invite revoke + inv, _ := db.CreateInvite(o.ID, "bob@x.io", 0) + if got := db.ListInvites(o.ID); len(got) != 1 { + t.Fatalf("invite list = %d", len(got)) + } + if !db.RevokeInvite(inv.Token) { + t.Fatal("revoke returned false") + } + if _, ok := db.Redeem(inv.Token); ok { + t.Fatal("revoked invite still redeems") + } +} + +func TestProjectLifecycle(t *testing.T) { + db, _ := OpenProjectDB(filepath.Join(t.TempDir(), "projects.json")) + p, _, _ := db.GetOrCreate("wiki", "o-1") + db.GetOrCreate("docs", "o-1") + + if err := db.Rename(p.ID, "handbook"); err != nil { + t.Fatal(err) + } + if got, _ := db.Get(p.ID); got.Name != "handbook" { + t.Fatalf("rename: %q", got.Name) + } + // name collision within the org is refused + if err := db.Rename(p.ID, "docs"); err == nil { + t.Fatal("rename to an existing org-name must be refused") + } + if err := db.Delete(p.ID); err != nil { + t.Fatal(err) + } + if _, ok := db.Get(p.ID); ok { + t.Fatal("deleted project still present") + } +} + +// Owner-only guards on the HTTP surface: a plain member is refused, an owner +// succeeds. +func TestAdminEndpointsOwnerOnly(t *testing.T) { + h, _, alice, bob, pa := orgHubSrv(t) + + // bob is not even a member of alice's org → 403 on rename + if rec := doAs(t, h, "PATCH", "/api/orgs/"+pa.Org, map[string]string{"name": "x"}, bob); rec.Code != http.StatusForbidden { + t.Fatalf("non-member org rename: %d", rec.Code) + } + // alice (owner) can rename her org + if rec := doAs(t, h, "PATCH", "/api/orgs/"+pa.Org, map[string]string{"name": "Alice Co"}, alice); rec.Code != 200 { + t.Fatalf("owner org rename: %d %s", rec.Code, rec.Body) + } + // alice can rename her project + if rec := doAs(t, h, "PATCH", "/api/projects/"+pa.ID, map[string]string{"name": "notes"}, alice); rec.Code != 200 { + t.Fatalf("owner project rename: %d %s", rec.Code, rec.Body) + } + // bob cannot delete alice's project (not a member → 404, doesn't leak) + if rec := doAs(t, h, "DELETE", "/api/projects/"+pa.ID, nil, bob); rec.Code == 200 { + t.Fatal("non-member deleted a project") + } + // alice can delete it + if rec := doAs(t, h, "DELETE", "/api/projects/"+pa.ID, nil, alice); rec.Code != 200 { + t.Fatalf("owner project delete: %d %s", rec.Code, rec.Body) + } +} + +// The invite→join→role→remove flow over HTTP, end to end. +func TestMemberManagementHTTP(t *testing.T) { + h, _, alice, bob, pa := orgHubSrv(t) + + // invite bob and have him join + rec := doAs(t, h, "POST", "/api/orgs/"+pa.Org+"/invites", nil, alice) + var inv struct { + Token string `json:"token"` + } + mustJSON(t, rec, &inv) + if rec := doAs(t, h, "POST", "/api/invites/"+inv.Token, nil, bob); rec.Code != 200 { + t.Fatalf("bob join: %d %s", rec.Code, rec.Body) + } + // alice promotes bob to owner + if rec := doAs(t, h, "PATCH", "/api/orgs/"+pa.Org+"/members/bob@x.io", map[string]string{"role": "owner"}, alice); rec.Code != 200 { + t.Fatalf("promote: %d %s", rec.Code, rec.Body) + } + // alice removes bob + if rec := doAs(t, h, "DELETE", "/api/orgs/"+pa.Org+"/members/bob@x.io", nil, alice); rec.Code != 200 { + t.Fatalf("remove: %d %s", rec.Code, rec.Body) + } + // bob is out: his project list no longer shows it + rec = doAs(t, h, "GET", "/api/projects", nil, bob) + var list struct { + Projects []Project `json:"projects"` + } + json.Unmarshal(rec.Body.Bytes(), &list) + for _, p := range list.Projects { + if p.ID == pa.ID { + t.Fatal("removed member still sees the project") + } + } +} diff --git a/internal/webapp/orgs.go b/internal/webapp/orgs.go index 715af59..046fc9d 100644 --- a/internal/webapp/orgs.go +++ b/internal/webapp/orgs.go @@ -204,6 +204,104 @@ func (db *OrgDB) AddMember(orgID, email, role string) error { return db.save() } +// RemoveMember drops an account from the org. The last owner cannot be +// removed (an org must always have someone who can administer it). +func (db *OrgDB) RemoveMember(orgID, email string) error { + e := normEmail(email) + db.mu.Lock() + defer db.mu.Unlock() + o, ok := db.byID[orgID] + if !ok { + return fmt.Errorf("no such organization") + } + if o.Members[e] == "" { + return fmt.Errorf("%s is not a member", email) + } + if o.Members[e] == RoleOwner && db.ownerCount(o) <= 1 { + return fmt.Errorf("cannot remove the last owner") + } + delete(o.Members, e) + db.byID[orgID] = o + return db.save() +} + +// SetRole changes an account's role. Demoting the last owner is refused. +func (db *OrgDB) SetRole(orgID, email, role string) error { + if role != RoleOwner && role != RoleMember { + return fmt.Errorf("invalid role %q", role) + } + e := normEmail(email) + db.mu.Lock() + defer db.mu.Unlock() + o, ok := db.byID[orgID] + if !ok { + return fmt.Errorf("no such organization") + } + if o.Members[e] == "" { + return fmt.Errorf("%s is not a member", email) + } + if o.Members[e] == RoleOwner && role == RoleMember && db.ownerCount(o) <= 1 { + return fmt.Errorf("cannot demote the last owner") + } + o.Members[e] = role + db.byID[orgID] = o + return db.save() +} + +// Rename changes the org's display name. +func (db *OrgDB) Rename(orgID, name string) error { + name = trimName(name) + if name == "" { + return fmt.Errorf("organization name must not be empty") + } + db.mu.Lock() + defer db.mu.Unlock() + o, ok := db.byID[orgID] + if !ok { + return fmt.Errorf("no such organization") + } + o.Name = name + db.byID[orgID] = o + return db.save() +} + +// ownerCount counts owners in an org. Callers hold mu. +func (db *OrgDB) ownerCount(o Org) int { + n := 0 + for _, role := range o.Members { + if role == RoleOwner { + n++ + } + } + return n +} + +// ListInvites returns the org's live (non-expired) invites. +func (db *OrgDB) ListInvites(orgID string) []OrgInvite { + db.mu.Lock() + defer db.mu.Unlock() + var out []OrgInvite + for _, inv := range db.invites { + if inv.Org == orgID && !inv.expired() { + out = append(out, inv) + } + } + sort.Slice(out, func(i, j int) bool { return out[i].Created.After(out[j].Created) }) + return out +} + +// RevokeInvite deletes an invite so its link stops working immediately. +func (db *OrgDB) RevokeInvite(token string) bool { + db.mu.Lock() + defer db.mu.Unlock() + if _, ok := db.invites[token]; !ok { + return false + } + delete(db.invites, token) + db.save() + return true +} + // CreateInvite mints a join link for the org. func (db *OrgDB) CreateInvite(orgID, creator string, ttl time.Duration) (OrgInvite, error) { if ttl <= 0 { @@ -323,6 +421,107 @@ func (s *Server) handleOrgList(w http.ResponseWriter, r *http.Request) { writeJSON(w, map[string]any{"orgs": out}) } +// requireOwner returns true and the caller's email when they own the org; +// otherwise it writes the error response and returns false. +func (s *Server) requireOwner(w http.ResponseWriter, r *http.Request, orgID string) (string, bool) { + if s.Orgs == nil { + http.Error(w, "organizations are not enabled on this server", http.StatusNotFound) + return "", false + } + me := s.requestUser(r) + if s.Orgs.Role(orgID, me.Email) != RoleOwner { + http.Error(w, "only an organization owner can do that", http.StatusForbidden) + return "", false + } + return normEmail(me.Email), true +} + +// handleOrgRename renames the org. Owners only. +func (s *Server) handleOrgRename(w http.ResponseWriter, r *http.Request) { + orgID := r.PathValue("org") + if _, ok := s.requireOwner(w, r, orgID); !ok { + return + } + var req struct { + Name string `json:"name"` + } + if err := json.NewDecoder(io.LimitReader(r.Body, 1<<16)).Decode(&req); err != nil { + http.Error(w, "bad request: "+err.Error(), http.StatusBadRequest) + return + } + if err := s.Orgs.Rename(orgID, req.Name); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + writeJSON(w, map[string]any{"ok": true}) +} + +// handleMemberUpdate changes a member's role. Owners only. +func (s *Server) handleMemberUpdate(w http.ResponseWriter, r *http.Request) { + orgID := r.PathValue("org") + if _, ok := s.requireOwner(w, r, orgID); !ok { + return + } + var req struct { + Role string `json:"role"` + } + if err := json.NewDecoder(io.LimitReader(r.Body, 1<<16)).Decode(&req); err != nil { + http.Error(w, "bad request: "+err.Error(), http.StatusBadRequest) + return + } + if err := s.Orgs.SetRole(orgID, r.PathValue("email"), req.Role); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + writeJSON(w, map[string]any{"ok": true}) +} + +// handleMemberRemove drops a member. Owners only. +func (s *Server) handleMemberRemove(w http.ResponseWriter, r *http.Request) { + orgID := r.PathValue("org") + if _, ok := s.requireOwner(w, r, orgID); !ok { + return + } + if err := s.Orgs.RemoveMember(orgID, r.PathValue("email")); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + writeJSON(w, map[string]any{"ok": true}) +} + +// handleInviteList shows an org's live invite links. Owners only. +func (s *Server) handleInviteList(w http.ResponseWriter, r *http.Request) { + orgID := r.PathValue("org") + if _, ok := s.requireOwner(w, r, orgID); !ok { + return + } + invs := s.Orgs.ListInvites(orgID) + 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, + "creator": inv.Creator, "created": inv.Created, "expires": inv.Expires, + }) + } + writeJSON(w, map[string]any{"invites": out}) +} + +// handleInviteRevoke kills an invite link. Owners only. +func (s *Server) handleInviteRevoke(w http.ResponseWriter, r *http.Request) { + orgID := r.PathValue("org") + if _, ok := s.requireOwner(w, r, orgID); !ok { + return + } + // Confirm the invite belongs to this org before revoking. + inv, ok := s.Orgs.Redeem(r.PathValue("token")) + if !ok || inv.Org != orgID { + http.Error(w, "no such invite", http.StatusNotFound) + return + } + s.Orgs.RevokeInvite(r.PathValue("token")) + writeJSON(w, map[string]any{"ok": true}) +} + // handleInviteCreate mints an invite link. Owners only. func (s *Server) handleInviteCreate(w http.ResponseWriter, r *http.Request) { if s.Orgs == nil { diff --git a/internal/webapp/projects.go b/internal/webapp/projects.go index c1c7d9b..e82d9d9 100644 --- a/internal/webapp/projects.go +++ b/internal/webapp/projects.go @@ -135,6 +135,41 @@ func (db *ProjectDB) GetOrCreate(name, org string) (Project, bool, error) { return p, true, nil } +// Rename changes a project's display name (its id and storage are permanent). +func (db *ProjectDB) Rename(id, name string) error { + name = trimName(name) + if name == "" { + return fmt.Errorf("project name must not be empty") + } + db.mu.Lock() + defer db.mu.Unlock() + p, ok := db.byID[id] + if !ok { + return fmt.Errorf("no such project %q", id) + } + for _, other := range db.byID { + if other.ID != id && other.Name == name && other.Org == p.Org { + return fmt.Errorf("a project named %q already exists in this organization", name) + } + } + p.Name = name + db.byID[id] = p + return db.save() +} + +// Delete removes a project from the registry. Its storage prefix (blobs, +// journals) is left in the object store — the id is retired, not scrubbed — +// so the caller decides whether to reclaim that space out of band. +func (db *ProjectDB) Delete(id string) error { + db.mu.Lock() + defer db.mu.Unlock() + if _, ok := db.byID[id]; !ok { + return fmt.Errorf("no such project %q", id) + } + delete(db.byID, id) + return db.save() +} + // SetOrg moves a project into an org (used by the startup migration). func (db *ProjectDB) SetOrg(id, org string) error { db.mu.Lock() diff --git a/internal/webapp/ratelimit.go b/internal/webapp/ratelimit.go index 86dc421..d7188db 100644 --- a/internal/webapp/ratelimit.go +++ b/internal/webapp/ratelimit.go @@ -94,3 +94,27 @@ func (s *Server) shareLimiter() *rateLimiter { }) return s.shareLim } + +// authLimiter throttles credential endpoints (login, signup) per IP to blunt +// password brute-force and signup floods. Deliberately tight (10/min). +func (s *Server) authLimiter() *rateLimiter { + s.authLimOnce.Do(func() { + s.authLim = newRateLimiter(10) + }) + return s.authLim +} + +// rateLimitAuth wraps the auth mux so POSTs to /auth/login and /auth/signup +// are throttled per IP; GETs (rendering the forms) pass freely. +func (s *Server) rateLimitAuth(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + p := r.URL.Path + if r.Method == http.MethodPost && (p == "/auth/login" || p == "/auth/signup") { + if !s.authLimiter().allow(clientIP(r)) { + http.Error(w, "too many attempts — wait a minute and try again", http.StatusTooManyRequests) + return + } + } + next.ServeHTTP(w, r) + }) +} diff --git a/internal/webapp/server.go b/internal/webapp/server.go index b32ce0f..d5dbe41 100644 --- a/internal/webapp/server.go +++ b/internal/webapp/server.go @@ -82,6 +82,8 @@ type Server struct { shareLimOnce sync.Once shareLim *rateLimiter + authLimOnce sync.Once + authLim *rateLimiter volOnce sync.Once vol *volume @@ -326,9 +328,22 @@ func (s *Server) Handler() http.Handler { } mux.HandleFunc("GET /api/orgs", s.handleOrgList) + mux.HandleFunc("PATCH /api/orgs/{org}", s.handleOrgRename) mux.HandleFunc("POST /api/orgs/{org}/invites", s.handleInviteCreate) + mux.HandleFunc("GET /api/orgs/{org}/invites", s.handleInviteList) + mux.HandleFunc("DELETE /api/orgs/{org}/invites/{token}", s.handleInviteRevoke) + mux.HandleFunc("PATCH /api/orgs/{org}/members/{email}", s.handleMemberUpdate) + mux.HandleFunc("DELETE /api/orgs/{org}/members/{email}", s.handleMemberRemove) + mux.HandleFunc("GET /api/orgs/{org}/shares", s.handleOrgShares) mux.HandleFunc("POST /api/invites/{token}", s.handleInviteAccept) + mux.HandleFunc("PATCH /api/projects/{project}", s.handleProjectRename) + mux.HandleFunc("DELETE /api/projects/{project}", s.handleProjectDelete) + + mux.HandleFunc("GET /api/admin/pending", s.handleAdminPending) + mux.HandleFunc("POST /api/admin/pending/{id}/approve", s.handleAdminApprove) + mux.HandleFunc("POST /api/admin/pending/{id}/deny", s.handleAdminDeny) + mux.HandleFunc("GET /api/p/{project}/history", proj(s.handleHistory)) mux.HandleFunc("GET /api/p/{project}/blob", proj(s.handleBlob)) mux.HandleFunc("POST /api/p/{project}/shares", proj(s.handleShareCreate)) @@ -348,7 +363,7 @@ func (s *Server) Handler() http.Handler { if s.Auth != nil { s.Auth.Register(mux) } - return s.authGate(mux) + return s.rateLimitAuth(s.authGate(mux)) } // handleConfig tells the client how this server is configured. Deliberately @@ -362,6 +377,13 @@ func (s *Server) handleConfig(w http.ResponseWriter, r *http.Request) { if s.Auth != nil { auth["cli_login"] = s.Auth.CLILoginPath() } + // Tell the frontend whether self-signup is offered and whether the + // signed-in user is a hub admin, so it can hide the "Sign up" link and + // show the admin surfaces. Never leak more than these booleans. + if a := s.builtinAuth(); a != nil { + auth["allow_signup"] = a.AllowSignup + auth["admin"] = s.requestUser(r).Admin + } writeJSON(w, map[string]any{ "mode": mode, "volume": s.Volume, diff --git a/internal/webapp/static/app.js b/internal/webapp/static/app.js index dd90745..4c46285 100644 --- a/internal/webapp/static/app.js +++ b/internal/webapp/static/app.js @@ -56,10 +56,11 @@ async function boot() { await acceptInviteFromHash(); await loadOrgs(); await loadProjects(); + updateAdminBar(); const { project, path } = parseHash(); const proj = projects.find((x) => x.id === project) || projects[0]; if (proj) selectProject(proj, path); - else $("vault-name").textContent = serverConfig.volume || "BearDrive"; + else { $("vault-name").textContent = serverConfig.volume || "BearDrive"; showEmptyState(); } setInterval(loadProjects, 30000); // pick up new projects } else { $("vault-name").textContent = serverConfig.volume || "BearDrive"; @@ -81,6 +82,19 @@ async function loadProjects() { const nav = $("projects"); nav.hidden = false; nav.innerHTML = ""; + const head = document.createElement("div"); + head.className = "nav-head"; + head.innerHTML = `Projects`; + const add = document.createElement("button"); + add.className = "nav-add"; + add.title = "New project"; + add.textContent = "+"; + add.onclick = () => { + const name = prompt("New project name:"); + if (name) createProject(name.trim()); + }; + head.appendChild(add); + nav.appendChild(head); const ul = document.createElement("ul"); for (const p of projects) { const li = document.createElement("li"); @@ -89,8 +103,7 @@ async function loadProjects() { row.textContent = p.name; row.title = p.id; row.onclick = () => selectProject(p, null); - li.appendChild(row); - ul.appendChild(li); + ul.appendChild(li).appendChild(row); } nav.appendChild(ul); } @@ -116,16 +129,73 @@ function selectProject(p, path) { /* ---- hub: organizations ---- */ -/* Opening "#join/" while signed in joins the invite's org. */ +/* Opening "#join/" 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]+)$/); if (!m) return; - location.hash = ""; try { - const out = await postJSON("api/invites/" + m[1]); - alert("Welcome — you joined “" + out.org.name + "”."); + const out = await postJSON("api/invites/" + m[1]); // may redirect to login (401) + location.hash = ""; + toast("Welcome — you joined “" + out.org.name + "”."); } catch (e) { - alert("Could not accept the invite: " + e.message); + if (String(e.message).includes("signing in")) throw e; // redirecting; stop boot + location.hash = ""; + toast("Could not accept the invite: " + e.message, true); + } +} + +/* Onboarding: a signed-in account with no projects shouldn't hit a blank + sidebar. Explain that access comes from an invite, let them paste one, + and — since any member can — offer to start a new project. */ +function showEmptyState() { + $("orgbar").hidden = true; + const auth = serverConfig.auth && serverConfig.auth.enabled; + $("content").innerHTML = ` +
+

Welcome to BearDrive

+

You're signed in, but you're not part of any project yet.

+ ${auth ? ` +
+

Have an invite link?

+

A teammate can send you a join link. Paste it here:

+
+ + +
+
` : ``} +
+

Or start a new project

+

Create a shared space for your team's files.

+
+ + +
+
+
`; + 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,})$/); + if (!m) { toast("That doesn't look like an invite link.", true); return; } + location.hash = "join/" + m[1]; + location.reload(); + }; + $("ob-create").onclick = () => createProject($("ob-name").value.trim()); +} + +async function createProject(name) { + if (!name) { toast("Give the project a name.", true); return; } + try { + const out = await postJSON("api/projects", { name }); + await loadOrgs(); + await loadProjects(); + selectProject(out.project, null); + toast("Created “" + out.project.name + "”."); + } catch (e) { + toast("Could not create the project: " + e.message, true); } } @@ -146,47 +216,222 @@ function updateOrgBar() { const bar = $("orgbar"), org = currentOrg(); if (!org) { bar.hidden = true; return; } bar.hidden = false; - $("org-name").textContent = org.name; - $("org-name").onclick = () => showMembers(org); + const nm = $("org-name"); + nm.textContent = org.name; + nm.title = "Manage organization"; + nm.onclick = () => showOrgAdmin(org); const btn = $("invite-btn"); btn.hidden = org.role !== "owner"; - btn.onclick = async () => { - try { - const out = await postJSON("api/orgs/" + org.id + "/invites"); - prompt("Anyone who opens this link (and signs in) joins “" + org.name + "”:", out.url); - } catch (e) { - alert("Could not create an invite: " + e.message); - } - }; + btn.textContent = "Manage"; + btn.onclick = () => showOrgAdmin(org); } -function showMembers(org) { +/* The org admin panel: members (owners can change roles / remove), rename, + invite links (create / revoke), and an org-wide audit of public shares. */ +async function showOrgAdmin(org) { currentPath = null; markActive(); - $("crumb").textContent = org.name + " — members"; + closeSidebarOnMobile(); + $("crumb").textContent = org.name; $("meta").textContent = ""; - $("download").hidden = true; + $("share-btn").hidden = $("history-btn").hidden = $("download").hidden = true; + const owner = org.role === "owner"; const box = $("content"); - box.innerHTML = ""; - const list = document.createElement("div"); - list.className = "history"; - for (const m of org.members) { - const row = document.createElement("div"); - row.className = "hentry"; - const line = document.createElement("div"); - line.className = "hline"; - const who = document.createElement("span"); - who.className = "hpath"; - who.style.cursor = "default"; - who.textContent = m.email; - const role = document.createElement("span"); - role.className = "htime"; - role.textContent = m.role; - line.append(who, role); - row.appendChild(line); - list.appendChild(row); + box.innerHTML = `

`; + box.querySelector("#org-title").textContent = org.name + (owner ? "" : " · member"); + const panel = box.querySelector(".admin"); + + if (owner) { + const rn = el(panel, "div", "admin-row"); + rn.innerHTML = ``; + 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(); } + catch (e) { toast(e.message, true); } + }; } - box.appendChild(list); + + // Members + el(panel, "h3", null, "Members"); + const mlist = el(panel, "div", "admin-list"); + for (const m of org.members) { + const row = el(mlist, "div", "admin-item"); + el(row, "span", "ai-main", m.email); + if (owner) { + const sel = document.createElement("select"); + for (const r of ["owner", "member"]) { + const o = document.createElement("option"); o.value = r; o.textContent = r; + 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(); } + catch (e) { toast(e.message, true); showOrgAdmin(currentOrg()); } + }; + row.appendChild(sel); + const rm = el(row, "button", "ai-del", "Remove"); + rm.onclick = async () => { + if (!confirm("Remove " + m.email + " from " + org.name + "?")) return; + 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 { + el(row, "span", "ai-tag", m.role); + } + } + + if (!owner) return; + + // Projects in this org (rename / delete) + el(panel, "h3", null, "Projects"); + const plist = el(panel, "div", "admin-list"); + const orgProjects = projects.filter((p) => p.org === org.id); + if (!orgProjects.length) el(plist, "div", "admin-empty", "No projects yet."); + for (const p of orgProjects) { + const row = el(plist, "div", "admin-item"); + el(row, "span", "ai-main", p.name); + const rn = el(row, "button", "ai-btn", "Rename"); + rn.onclick = async () => { + const name = prompt("Rename project:", p.name); + if (!name || name.trim() === p.name) return; + try { await api("PATCH", "api/projects/" + p.id, { name: name.trim() }); toast("Renamed."); await loadProjects(); showOrgAdmin(currentOrg()); } + catch (e) { toast(e.message, true); } + }; + const del = el(row, "button", "ai-del", "Delete"); + del.onclick = async () => { + if (!confirm("Delete project “" + p.name + "”? Its files stay in storage but it's removed from the hub.")) return; + try { + await api("DELETE", "api/projects/" + p.id); + toast("Deleted “" + p.name + "”."); + if (currentProject && currentProject.id === p.id) currentProject = null; + await loadProjects(); + const next = currentOrg(); + if (next) showOrgAdmin(next); else showEmptyState(); + } catch (e) { toast(e.message, true); } + }; + } + + // Invites + const ih = el(panel, "div", "admin-h"); + el(ih, "h3", null, "Invite links"); + const mk = el(ih, "button", "pbtn", "New invite"); + mk.onclick = async () => { + try { + const out = await postJSON("api/orgs/" + org.id + "/invites"); + await navigator.clipboard.writeText(out.url).catch(() => {}); + toast("Invite link copied to clipboard."); + showOrgAdmin(currentOrg()); + } catch (e) { toast(e.message, true); } + }; + const ilist = el(panel, "div", "admin-list"); + try { + 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"); + const main = el(row, "span", "ai-main mono", inv.url); + main.style.cursor = "pointer"; + main.title = "Copy"; + main.onclick = () => { navigator.clipboard.writeText(inv.url).then(() => toast("Copied.")); }; + el(row, "span", "ai-tag", "expires " + new Date(inv.expires).toLocaleDateString()); + const rv = el(row, "button", "ai-del", "Revoke"); + rv.onclick = async () => { + try { await api("DELETE", "api/orgs/" + org.id + "/invites/" + inv.token); toast("Revoked."); showOrgAdmin(currentOrg()); } + catch (e) { toast(e.message, true); } + }; + } + } catch { /* ignore */ } + + // Org-wide share audit + 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 || []; + if (!shs.length) el(slist, "div", "admin-empty", "No public shares."); + for (const sh of shs) { + const row = el(slist, "div", "admin-item"); + const main = el(row, "span", "ai-main mono", sh.path); + main.title = sh.url; main.style.cursor = "pointer"; + main.onclick = () => window.open(sh.url, "_blank"); + el(row, "span", "ai-tag", sh.project_name || ""); + const rv = el(row, "button", "ai-del", "Revoke"); + rv.onclick = async () => { + try { await api("DELETE", "api/shares/" + sh.token); toast("Share revoked."); showOrgAdmin(currentOrg()); } + catch (e) { toast(e.message, true); } + }; + } + } catch { /* ignore */ } +} + +/* small DOM helper */ +function el(parent, tag, cls, text) { + const n = document.createElement(tag); + if (cls) n.className = cls; + if (text != null) n.textContent = text; + parent.appendChild(n); + return n; +} + +/* fetch wrapper for methods without a body-returning helper */ +async function api(method, url, body) { + const opt = { method }; + if (body !== undefined) { opt.headers = { "Content-Type": "application/json" }; opt.body = JSON.stringify(body); } + const r = await fetch(url, opt); + if (!r.ok) throw new Error(await r.text()); + return r.status === 204 ? {} : r.json(); +} + +/* transient toast, replacing blocking alert() */ +let toastTimer = null; +function toast(msg, isErr) { + let t = $("toast"); + if (!t) { t = document.createElement("div"); t.id = "toast"; document.body.appendChild(t); } + t.textContent = msg; + t.className = "show" + (isErr ? " err" : ""); + clearTimeout(toastTimer); + toastTimer = setTimeout(() => { t.className = ""; }, 3200); +} + +/* Admin approval bar: a hub admin sees pending signups to approve/deny. */ +async function updateAdminBar() { + const bar = $("adminbar"); + if (!bar) return; + if (!(serverConfig.auth && serverConfig.auth.admin)) { bar.hidden = true; return; } + let pending = []; + try { pending = (await getJSON("api/admin/pending")).pending || []; } catch { } + if (!pending.length) { bar.hidden = true; return; } + bar.hidden = false; + bar.innerHTML = ""; + const b = el(bar, "button", "adminbar-btn"); + b.textContent = "⚑ " + pending.length + " pending signup" + (pending.length > 1 ? "s" : ""); + b.onclick = () => showPending(); +} + +async function showPending() { + let pending = []; + try { pending = (await getJSON("api/admin/pending")).pending || []; } catch { } + currentPath = null; markActive(); + $("crumb").textContent = "Pending signups"; + $("share-btn").hidden = $("history-btn").hidden = $("download").hidden = true; + const box = $("content"); + box.innerHTML = `

Pending signups

`; + const panel = box.querySelector(".admin"); + const list = el(panel, "div", "admin-list"); + if (!pending.length) el(list, "div", "admin-empty", "No one is waiting for approval."); + for (const u of pending) { + 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); } }; + 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); } }; + } +} + +function refreshAll() { + loadProjects(); + updateOrgBar(); + if (currentProject) refreshTree(); } /* Hash routing: "#" in volume mode, "#/" in hub mode. */ @@ -271,6 +516,7 @@ async function openFile(p) { currentPath = p; setHash(p); markActive(); + closeSidebarOnMobile(); $("crumb").textContent = p.split("/").join(" / "); updateShareButton(); const dl = $("download"); @@ -739,6 +985,15 @@ $("palette-overlay").addEventListener("click", (e) => { if (e.target.id === "palette-overlay") paletteClose(); }); +/* Visible search affordance in the top bar → opens the palette. */ +$("search-btn").addEventListener("click", paletteOpen); + +/* Mobile: the sidebar is off-canvas; a hamburger toggles it. */ +function toggleSidebar() { document.body.classList.toggle("sb-open"); } +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(); if (serverConfig.mode === "hub" && project && (!currentProject || currentProject.id !== project)) { diff --git a/internal/webapp/static/index.html b/internal/webapp/static/index.html index 6a9a23f..376432d 100644 --- a/internal/webapp/static/index.html +++ b/internal/webapp/static/index.html @@ -8,22 +8,28 @@ +
+ + diff --git a/internal/webapp/static/style.css b/internal/webapp/static/style.css index 7d47661..016c971 100644 --- a/internal/webapp/static/style.css +++ b/internal/webapp/static/style.css @@ -215,6 +215,82 @@ body { color: var(--text-faint); } +/* ---- projects nav header + new-project ---- */ +.nav-head { display: flex; align-items: center; justify-content: space-between; padding: 2px 8px 6px; font-size: 11px; text-transform: uppercase; letter-spacing: .06em; color: var(--text-faint); } +.nav-add { border: none; background: transparent; color: var(--text-faint); font-size: 18px; line-height: 1; cursor: pointer; padding: 0 4px; border-radius: 4px; } +.nav-add:hover { color: var(--text); background: var(--bg-hover); } + +/* ---- vault header actions ---- */ +.vault-actions { display: flex; align-items: center; gap: 8px; } +.adminbar { border: none; cursor: pointer; font: inherit; font-size: 11.5px; padding: 2px 8px; border-radius: 5px; background: #4a3a12; color: #f5c451; } +.adminbar:hover { background: #5c4816; } + +/* ---- topbar controls ---- */ +.icon-btn { display: none; border: none; background: transparent; color: var(--text-dim); font-size: 18px; cursor: pointer; padding: 2px 6px; border-radius: 5px; } +.icon-btn:hover { color: var(--text); background: var(--bg-hover); } +#search-btn { display: inline-flex; align-items: center; gap: 6px; } +#search-btn kbd { font: 11px var(--mono, ui-monospace, Menlo, monospace); background: var(--bg); border: 1px solid var(--border); border-radius: 4px; padding: 0 5px; color: var(--text-faint); } +.btn.ghost { background: var(--bg-active); color: var(--text-dim); } +.btn.ghost:hover { background: var(--bg-hover); color: var(--text); } + +/* ---- onboarding empty state ---- */ +.onboard { max-width: 560px; margin: 8vh auto 0; } +.onboard h1 { font-size: 26px; margin: 0 0 8px; color: #f0f0f0; } +.onboard > p { color: var(--text-dim); margin: 0 0 28px; } +.ob-card { background: var(--bg-side); border: 1px solid var(--border); border-radius: 10px; padding: 20px 22px; margin-bottom: 16px; } +.ob-card h3 { margin: 0 0 6px; font-size: 15px; } +.ob-card p { margin: 0 0 14px; font-size: 13.5px; color: var(--text-dim); } +.ob-row { display: flex; gap: 10px; } +.ob-row input { flex: 1; padding: 8px 11px; border-radius: 6px; border: 1px solid var(--border); background: var(--bg); color: var(--text); font: inherit; font-size: 13.5px; outline: none; } +.ob-row input:focus { border-color: var(--accent-dim); } +.pbtn { border: none; cursor: pointer; font: inherit; font-size: 13px; padding: 7px 16px; border-radius: 6px; background: var(--accent-dim); color: #fff; white-space: nowrap; } +.pbtn:hover { background: var(--accent); } + +/* ---- admin panels (org, pending) ---- */ +.admin { max-width: 720px; } +.admin h1 { font-size: 22px; margin: 0 0 20px; color: #f0f0f0; } +.admin h3 { font-size: 13px; text-transform: uppercase; letter-spacing: .05em; color: var(--text-faint); margin: 26px 0 10px; } +.admin-h { display: flex; align-items: center; justify-content: space-between; margin: 26px 0 10px; } +.admin-h h3 { margin: 0; } +.admin-row { display: flex; gap: 10px; margin-bottom: 8px; } +.admin-row input { flex: 1; padding: 7px 11px; border-radius: 6px; border: 1px solid var(--border); background: var(--bg); color: var(--text); font: inherit; font-size: 13.5px; outline: none; } +.admin-list { border: 1px solid var(--border); border-radius: 8px; overflow: hidden; } +.admin-item { display: flex; align-items: center; gap: 10px; padding: 9px 12px; border-bottom: 1px solid var(--border); font-size: 13.5px; } +.admin-item:last-child { border-bottom: none; } +.ai-main { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.ai-main.mono { font: 12px var(--mono, ui-monospace, Menlo, monospace); color: var(--text-dim); } +.ai-tag { font-size: 11.5px; color: var(--text-faint); } +.admin-item select { background: var(--bg); color: var(--text); border: 1px solid var(--border); border-radius: 5px; padding: 3px 6px; font: inherit; font-size: 12.5px; } +.ai-btn, .ai-del { border: none; cursor: pointer; font: inherit; font-size: 12px; padding: 4px 10px; border-radius: 5px; background: var(--bg-active); color: var(--text-dim); } +.ai-btn:hover { background: var(--bg-hover); color: var(--text); } +.ai-del { color: #ff9b91; } +.ai-del:hover { background: #4a2420; color: #ff7b72; } +.admin-empty { padding: 14px 12px; color: var(--text-faint); font-size: 13px; } + +/* ---- toast ---- */ +#toast { position: fixed; bottom: 24px; left: 50%; transform: translateX(-50%) translateY(20px); background: var(--bg-active); color: var(--text); border: 1px solid var(--border); border-radius: 8px; padding: 10px 18px; font-size: 13.5px; box-shadow: 0 8px 30px rgba(0,0,0,.5); opacity: 0; pointer-events: none; transition: opacity .2s, transform .2s; z-index: 200; } +#toast.show { opacity: 1; transform: translateX(-50%) translateY(0); } +#toast.err { border-color: #7a2e28; color: #ffb4ac; } + +/* ---- mobile backdrop ---- */ +#sb-backdrop { display: none; } + +/* ---- responsive ---- */ +@media (max-width: 760px) { + #sidebar { position: fixed; z-index: 60; top: 0; left: 0; height: 100%; transform: translateX(-100%); transition: transform .2s ease; box-shadow: 0 0 40px rgba(0,0,0,.5); } + body.sb-open #sidebar { transform: translateX(0); } + body.sb-open #sb-backdrop { display: block; position: fixed; inset: 0; background: rgba(0,0,0,.5); z-index: 50; } + .icon-btn { display: inline-block; } + #content { padding: 20px 18px 60px; } + #topbar { padding: 8px 14px; gap: 8px; } + #crumb { font-size: 12px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } + #search-btn kbd { display: none; } + #search-btn { font-size: 0; gap: 0; padding: 4px 9px; } + #search-btn::before { content: "🔍"; font-size: 14px; } + .markdown, .admin, .onboard, .history { max-width: 100%; } + .ob-row { flex-direction: column; } +} + /* ---- markdown ---- */ .markdown { max-width: 820px; } .markdown h1, .markdown h2, .markdown h3, .markdown h4 { diff --git a/internal/webapp/upload.go b/internal/webapp/upload.go index eaee570..26586e8 100644 --- a/internal/webapp/upload.go +++ b/internal/webapp/upload.go @@ -33,8 +33,10 @@ import ( // journal an op whose blob is not already in the store. // Uploader is implemented by sources that accept writes through the server. +// who is the signed-in account the write should be attributed to (zero when +// auth is off). type Uploader interface { - Upload(ctx context.Context, path string, r io.Reader, size int64) error + Upload(ctx context.Context, path string, r io.Reader, size int64, who User) error } // DirectUploader is additionally implemented by sources whose storage can @@ -43,7 +45,7 @@ type DirectUploader interface { Uploader SignBlobPut(ctx context.Context, blob string, size int64, ttl time.Duration) (*remote.SignedPut, error) HasBlob(ctx context.Context, blob string) (bool, error) - Commit(ctx context.Context, path, blob string, size int64) error + Commit(ctx context.Context, path, blob string, size int64, who User) error } // ---- RemoteSource: writes go to the object store + our own journal ---- @@ -63,7 +65,7 @@ func (r *RemoteSource) HasBlob(ctx context.Context, blob string) (bool, error) { // Upload stores content through the server: spool to disk while hashing, // push the blob, then journal the op. -func (r *RemoteSource) Upload(ctx context.Context, p string, src io.Reader, _ int64) error { +func (r *RemoteSource) Upload(ctx context.Context, p string, src io.Reader, _ int64, who User) error { tmp, err := os.CreateTemp("", ".bdrive-tmp-upload-") if err != nil { return err @@ -83,7 +85,7 @@ func (r *RemoteSource) Upload(ctx context.Context, p string, src io.Reader, _ in if err := r.Backend.Put(ctx, "blobs/"+blob, tmp, size); err != nil { return fmt.Errorf("push blob: %w", err) } - return r.Commit(ctx, p, blob, size) + return r.Commit(ctx, p, blob, size, who) } // Commit appends a put op for path→blob to this server's own journal. It @@ -91,7 +93,7 @@ func (r *RemoteSource) Upload(ctx context.Context, p string, src io.Reader, _ in // whose content is missing). Only this server writes this journal key, so // the read-modify-write below has a single writer; upmu serializes it across // concurrent requests. -func (r *RemoteSource) Commit(ctx context.Context, p, blob string, size int64) error { +func (r *RemoteSource) Commit(ctx context.Context, p, blob string, size int64, who User) error { if r.Device.ID == "" { return fmt.Errorf("no device identity configured for uploads") } @@ -120,6 +122,7 @@ func (r *RemoteSource) Commit(ctx context.Context, p, blob string, size int64) e op := journal.Op{ Seq: mySeq + 1, Lamport: maxLamport + 1, Time: time.Now().UTC(), Device: r.Device.ID, DeviceName: r.Device.Name, Author: r.Device.Author, + User: who.Email, UserName: who.Name, Kind: journal.KindPut, Path: p, Blob: blob, Size: size, Mode: 0o644, } @@ -156,7 +159,7 @@ var errBlobMissing = fmt.Errorf("content not uploaded yet") // Upload writes the file atomically under Root. There is no journal here; // on a mounted folder the daemon scans, journals, and syncs it like any // local edit. -func (d *DirSource) Upload(_ context.Context, p string, src io.Reader, _ int64) error { +func (d *DirSource) Upload(_ context.Context, p string, src io.Reader, _ int64, _ User) error { dst := filepath.Join(d.Root, filepath.FromSlash(p)) if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil { return err @@ -296,7 +299,7 @@ func (s *Server) handleUploadContent(v *volume, w http.ResponseWriter, r *http.R http.Error(w, err.Error(), http.StatusForbidden) return } - if err := up.Upload(r.Context(), p, r.Body, r.ContentLength); err != nil { + if err := up.Upload(r.Context(), p, r.Body, r.ContentLength, s.requestUser(r)); err != nil { http.Error(w, fmt.Sprintf("store: %v", err), http.StatusBadGateway) return } @@ -327,7 +330,7 @@ func (s *Server) handleUploadCommit(v *volume, w http.ResponseWriter, r *http.Re http.Error(w, err.Error(), http.StatusForbidden) return } - if err := direct.Commit(r.Context(), req.Path, req.SHA256, req.Size); err != nil { + if err := direct.Commit(r.Context(), req.Path, req.SHA256, req.Size, s.requestUser(r)); err != nil { code := http.StatusBadGateway if err == errBlobMissing { code = http.StatusConflict