feat(hub): signup gating, admin lifecycle, and onboarding UX

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01R7Q9ZKSZRTdvrSJkYLUmYs
This commit is contained in:
Snow Lee
2026-07-08 22:23:27 -07:00
co-authored by Claude Fable 5
parent 953db0260b
commit baad9e6dc2
16 changed files with 1442 additions and 66 deletions
+63
View File
@@ -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.
+61
View File
@@ -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.
+20 -3
View File
@@ -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 {
+161
View File
@@ -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})
}
+1
View File
@@ -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.
+204 -11
View File
@@ -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 = `<p class="err">Please verify your email first — we've re-sent the link.</p>`
case statusPending:
errMsg = `<p class="err">Your account is still awaiting administrator approval.</p>`
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 = `<p class="err">Wrong email or password.</p>`
}
errMsg = `<p class="err">Wrong email or password.</p>`
}
signup := ""
if a.AllowSignup {
signup = fmt.Sprintf(`<p class="alt">No account? <a href="/auth/signup?next=%s">Sign up</a></p>`, url.QueryEscape(next))
note := ""
if len(a.AllowedDomains) > 0 {
note = ` <span style="color:#888">(` + html.EscapeString(a.domainList()) + ` only)</span>`
}
signup = fmt.Sprintf(`<p class="alt">No account? <a href="/auth/signup?next=%s">Sign up</a>%s</p>`, url.QueryEscape(next), note)
}
authPage(w, "Sign in", fmt.Sprintf(`<form method="post" action="/auth/login?next=%s">%s%s%s<button>Sign in</button></form>
brand := ""
if a.Brand != "" {
brand = `<p class="alt" style="margin:0 0 14px;color:#aaa">` + html.EscapeString(a.Brand) + `</p>`
}
authPage(w, "Sign in", brand+fmt.Sprintf(`<form method="post" action="/auth/login?next=%s">%s%s%s<button>Sign in</button></form>
%s<p class="alt"><a href="/auth/reset">Forgot password?</a></p>`,
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", `<p class="msg">Almost there — we sent a verification link to <b>`+
html.EscapeString(u.Email)+`</b>.</p><p class="alt">Click it to activate your account. No email on this server? The link is in the server log.</p>`)
return
case statusPending:
authPage(w, "Awaiting approval", `<p class="msg">Thanks — your account was created and is waiting for an administrator to approve it.</p>
<p class="alt">You'll be able to sign in once it's approved.</p>`)
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", `<p class="err">This verification link is invalid or expired.</p>
<p class="alt"><a href="/auth/login">Back to sign in</a></p>`)
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", `<p class="msg">Your email is verified. Your account is now waiting for an administrator to approve it.</p>`)
return
}
if u != nil {
if err := a.startSession(w, u.ID); err == nil {
http.Redirect(w, r, "/", http.StatusSeeOther)
return
}
}
authPage(w, "Email verified", `<p class="msg">Your email is verified.</p><p class="alt"><a href="/auth/login">Sign in</a></p>`)
}
func (a *BuiltinAuth) pageReset(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodPost {
email := strings.TrimSpace(strings.ToLower(r.FormValue("email")))
+126
View File
@@ -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)
}
}
+134
View File
@@ -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")
}
}
}
+199
View File
@@ -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 {
+35
View File
@@ -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()
+24
View File
@@ -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)
})
}
+23 -1
View File
@@ -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,
+295 -40
View File
@@ -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 = `<span>Projects</span>`;
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/<token>" while signed in joins the invite's org. */
/* 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]+)$/);
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 = `
<div class="onboard">
<h1>Welcome to BearDrive</h1>
<p>You're signed in, but you're not part of any project yet.</p>
${auth ? `
<div class="ob-card">
<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">
<button id="ob-join" class="pbtn">Join</button>
</div>
</div>` : ``}
<div class="ob-card">
<h3>Or start a new project</h3>
<p>Create a shared space for your team's files.</p>
<div class="ob-row">
<input id="ob-name" type="text" placeholder="Project name, e.g. wiki" autocomplete="off">
<button id="ob-create" class="pbtn">Create</button>
</div>
</div>
</div>`;
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 = `<div class="admin"><h1 id="org-title"></h1></div>`;
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 = `<input id="org-rename" type="text" value=""><button class="pbtn" id="org-rename-btn">Rename org</button>`;
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 = `<div class="admin"><h1>Pending signups</h1></div>`;
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: "#<path>" in volume mode, "#<project-id>/<path>" 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)) {
+9 -3
View File
@@ -8,22 +8,28 @@
<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'>&#128193;</text></svg>">
</head>
<body>
<div id="sb-backdrop"></div>
<aside id="sidebar">
<header id="vault">
<span id="vault-name"></span>
<a id="signout" href="/auth/logout" hidden title="Sign out"></a>
<div class="vault-actions">
<button id="adminbar" class="adminbar" hidden></button>
<a id="signout" href="/auth/logout" hidden title="Sign out"></a>
</div>
</header>
<nav id="projects" aria-label="Projects" hidden></nav>
<nav id="tree" aria-label="Files"></nav>
<footer id="orgbar" hidden>
<span id="org-name" title="Show members"></span>
<button id="invite-btn" hidden title="Invite a teammate to this organization">Invite</button>
<span id="org-name" title="Manage organization"></span>
<button id="invite-btn" hidden title="Manage this organization">Manage</button>
</footer>
</aside>
<main id="main">
<header id="topbar">
<button id="menu-btn" class="icon-btn" title="Menu" aria-label="Menu"></button>
<span id="crumb"></span>
<span id="meta"></span>
<button id="search-btn" class="btn ghost" title="Search (⌘K)">🔍 Search <kbd>⌘K</kbd></button>
<button id="share-btn" class="btn" hidden>Share</button>
<button id="history-btn" class="btn" hidden>History</button>
<button id="upload-btn" class="btn" hidden>Upload</button>
+76
View File
@@ -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 {
+11 -8
View File
@@ -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