mirror of
https://github.com/runbear-io/beardrive.git
synced 2026-08-25 08:08:08 +02:00
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
205 lines
4.9 KiB
Go
205 lines
4.9 KiB
Go
package webapp
|
|
|
|
import (
|
|
"crypto/rand"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"regexp"
|
|
"sort"
|
|
"sync"
|
|
"time"
|
|
)
|
|
|
|
// Project is one synced project hosted by this server. Its storage lives
|
|
// under <root>/<id>/ in the object store; the id is permanent, the name is a
|
|
// renameable label.
|
|
type Project struct {
|
|
ID string `json:"id"`
|
|
Name string `json:"name"`
|
|
Org string `json:"org,omitempty"` // owning organization
|
|
Created time.Time `json:"created"`
|
|
}
|
|
|
|
var projectIDRe = regexp.MustCompile(`^p-[0-9a-f]{8}$`)
|
|
|
|
// ProjectDB is the server's project registry, persisted as a JSON file that
|
|
// is loaded on open and rewritten atomically on every change.
|
|
type ProjectDB struct {
|
|
path string
|
|
|
|
mu sync.Mutex
|
|
byID map[string]Project
|
|
}
|
|
|
|
// OpenProjectDB loads the registry at path; a missing file is an empty
|
|
// registry.
|
|
func OpenProjectDB(path string) (*ProjectDB, error) {
|
|
db := &ProjectDB{path: path, byID: make(map[string]Project)}
|
|
data, err := os.ReadFile(path)
|
|
if err != nil {
|
|
if os.IsNotExist(err) {
|
|
return db, nil
|
|
}
|
|
return nil, err
|
|
}
|
|
var file struct {
|
|
Projects []Project `json:"projects"`
|
|
}
|
|
if err := json.Unmarshal(data, &file); err != nil {
|
|
return nil, fmt.Errorf("parse %s: %w", path, err)
|
|
}
|
|
for _, p := range file.Projects {
|
|
db.byID[p.ID] = p
|
|
}
|
|
return db, nil
|
|
}
|
|
|
|
// save persists the registry. Callers hold mu.
|
|
func (db *ProjectDB) save() error {
|
|
list := db.list()
|
|
data, err := json.MarshalIndent(struct {
|
|
Projects []Project `json:"projects"`
|
|
}{list}, "", " ")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if err := os.MkdirAll(filepath.Dir(db.path), 0o755); err != nil {
|
|
return err
|
|
}
|
|
tmp, err := os.CreateTemp(filepath.Dir(db.path), ".bdrive-tmp-*")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer os.Remove(tmp.Name())
|
|
if _, err := tmp.Write(append(data, '\n')); err != nil {
|
|
tmp.Close()
|
|
return err
|
|
}
|
|
if err := tmp.Close(); err != nil {
|
|
return err
|
|
}
|
|
return os.Rename(tmp.Name(), db.path)
|
|
}
|
|
|
|
// list returns projects sorted by name. Callers hold mu.
|
|
func (db *ProjectDB) list() []Project {
|
|
out := make([]Project, 0, len(db.byID))
|
|
for _, p := range db.byID {
|
|
out = append(out, p)
|
|
}
|
|
sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name })
|
|
return out
|
|
}
|
|
|
|
func (db *ProjectDB) List() []Project {
|
|
db.mu.Lock()
|
|
defer db.mu.Unlock()
|
|
return db.list()
|
|
}
|
|
|
|
func (db *ProjectDB) Get(id string) (Project, bool) {
|
|
db.mu.Lock()
|
|
defer db.mu.Unlock()
|
|
p, ok := db.byID[id]
|
|
return p, ok
|
|
}
|
|
|
|
// GetOrCreate returns the project with the given name in the org, creating
|
|
// it (with a fresh id) if none exists. Names are matched exactly, scoped to
|
|
// the org: two organizations can each have a "wiki".
|
|
func (db *ProjectDB) GetOrCreate(name, org string) (Project, bool, error) {
|
|
name = trimName(name)
|
|
if name == "" {
|
|
return Project{}, false, fmt.Errorf("project name must not be empty")
|
|
}
|
|
db.mu.Lock()
|
|
defer db.mu.Unlock()
|
|
for _, p := range db.byID {
|
|
if p.Name == name && p.Org == org {
|
|
return p, false, nil
|
|
}
|
|
}
|
|
var buf [4]byte
|
|
if _, err := rand.Read(buf[:]); err != nil {
|
|
return Project{}, false, err
|
|
}
|
|
p := Project{ID: "p-" + hex.EncodeToString(buf[:]), Name: name, Org: org, Created: time.Now().UTC()}
|
|
db.byID[p.ID] = p
|
|
if err := db.save(); err != nil {
|
|
delete(db.byID, p.ID)
|
|
return Project{}, false, err
|
|
}
|
|
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()
|
|
defer db.mu.Unlock()
|
|
p, ok := db.byID[id]
|
|
if !ok {
|
|
return fmt.Errorf("no such project %q", id)
|
|
}
|
|
p.Org = org
|
|
db.byID[id] = p
|
|
return db.save()
|
|
}
|
|
|
|
func trimName(s string) string {
|
|
out := make([]rune, 0, len(s))
|
|
for _, r := range s {
|
|
if r == '\n' || r == '\r' || r == '\t' {
|
|
continue
|
|
}
|
|
out = append(out, r)
|
|
}
|
|
for len(out) > 0 && out[0] == ' ' {
|
|
out = out[1:]
|
|
}
|
|
for len(out) > 0 && out[len(out)-1] == ' ' {
|
|
out = out[:len(out)-1]
|
|
}
|
|
if len(out) > 128 {
|
|
out = out[:128]
|
|
}
|
|
return string(out)
|
|
}
|