mirror of
https://github.com/runbear-io/beardrive.git
synced 2026-08-25 08:08:08 +02:00
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:
co-authored by
Claude Fable 5
parent
953db0260b
commit
baad9e6dc2
@@ -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.
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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})
|
||||
}
|
||||
@@ -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
@@ -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")))
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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
@@ -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)) {
|
||||
|
||||
@@ -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'>📁</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>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user