package server import ( "errors" "net/http" "sort" "strings" "time" "cc.tiredsysadmin.keeporg/internal/secret" "cc.tiredsysadmin.keeporg/internal/store" ) var errForbidden = errors.New("forbidden") // deny maps a domain error onto a response. func deny(w http.ResponseWriter, err error) { switch { case err == nil: return case errors.Is(err, errForbidden): fail(w, http.StatusForbidden, "forbidden", "you do not administer that object") default: fail(w, http.StatusBadRequest, "bad_request", err.Error()) } } // ---- databases ---- func (s *Server) handleCreateDatabase(w http.ResponseWriter, r *http.Request, u *store.User) { var req struct { Name string `json:"name"` OwnerGroupID string `json:"owner_group_id"` MergeStrategy store.Strategy `json:"merge_strategy"` } if !readJSON(w, r, &req) { return } var out dbView err := s.st.Update(func(st *store.State) error { if strings.TrimSpace(req.Name) == "" { return errors.New("name is required") } if st.Groups[req.OwnerGroupID] == nil { return errors.New("unknown owner group") } if !st.IsGroupAdmin(u.ID, req.OwnerGroupID) { return errForbidden } strategy := req.MergeStrategy if strategy == "" { strategy = store.StrategyServerWins } if !strategy.Valid() { return errors.New("unknown merge strategy") } db := &store.Database{ID: secret.ID(), Name: strings.TrimSpace(req.Name), OwnerGroupID: req.OwnerGroupID, MergeStrategy: strategy, UpdatedAt: time.Now()} st.Databases[db.ID] = db out = toView(db, store.PermAdmin) return nil }) if err != nil { deny(w, err) return } s.notifyList(out.ID) writeJSON(w, http.StatusCreated, out) } func (s *Server) handlePatchDatabase(w http.ResponseWriter, r *http.Request, u *store.User) { id := r.PathValue("id") var req struct { Name *string `json:"name"` MergeStrategy *store.Strategy `json:"merge_strategy"` } if !readJSON(w, r, &req) { return } var out dbView err := s.st.Update(func(st *store.State) error { db := st.Databases[id] if db == nil { return errors.New("no such database") } if st.DatabasePerm(u.ID, id) != store.PermAdmin { return errForbidden } if req.Name != nil { if strings.TrimSpace(*req.Name) == "" { return errors.New("name may not be empty") } db.Name = strings.TrimSpace(*req.Name) } if req.MergeStrategy != nil { if !req.MergeStrategy.Valid() { return errors.New("unknown merge strategy") } if req.MergeStrategy.NeedsKey() && db.WrappedKey == "" { return errors.New("merging needs the vault password: store it with " + "`keeporg db -id -store-password` first") } db.MergeStrategy = *req.MergeStrategy } out = toView(db, store.PermAdmin) return nil }) if err != nil { deny(w, err) return } s.notifyList(id) writeJSON(w, http.StatusOK, out) } func (s *Server) handleDeleteDatabase(w http.ResponseWriter, r *http.Request, u *store.User) { id := r.PathValue("id") var watchers []string err := s.st.Update(func(st *store.State) error { if st.Databases[id] == nil { return errors.New("no such database") } if st.DatabasePerm(u.ID, id) != store.PermAdmin { return errForbidden } watchers = st.Watchers(id) delete(st.Databases, id) st.Grants = filterGrants(st.Grants, func(g store.Grant) bool { return g.DatabaseID != id }) return nil }) if err != nil { deny(w, err) return } if err := s.blobs.Forget(id); err != nil { s.log.Printf("delete blobs for %s: %v", id, err) } s.hub.Publish(watchers, Event{Type: EventChanged, DatabaseID: id}) writeJSON(w, http.StatusOK, map[string]string{"status": "deleted"}) } // ---- groups and membership ---- type groupView struct { ID string `json:"id"` Name string `json:"name"` ParentID string `json:"parent_id,omitempty"` Admin bool `json:"admin"` Members int `json:"members"` } func (s *Server) handleListGroups(w http.ResponseWriter, r *http.Request, u *store.User) { out := []groupView{} s.st.Read(func(st *store.State) { counts := map[string]int{} for _, m := range st.Memberships { counts[m.GroupID]++ } reach := st.EffectiveGroups(u.ID) for id, g := range st.Groups { admin := st.IsGroupAdmin(u.ID, id) visible := admin || reach[id] || u.ServerAdmin if !visible { // A group below one the user administers is visible too. for _, anc := range st.Ancestors(id) { if st.IsGroupAdmin(u.ID, anc) { visible = true break } } } if visible { out = append(out, groupView{ID: id, Name: g.Name, ParentID: g.ParentID, Admin: admin, Members: counts[id]}) } } }) sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name }) writeJSON(w, http.StatusOK, map[string]any{"groups": out}) } func (s *Server) handleCreateGroup(w http.ResponseWriter, r *http.Request, u *store.User) { var req struct { Name string `json:"name"` ParentID string `json:"parent_id"` } if !readJSON(w, r, &req) { return } var out groupView err := s.st.Update(func(st *store.State) error { if strings.TrimSpace(req.Name) == "" { return errors.New("name is required") } if req.ParentID == "" { if !u.ServerAdmin { return errForbidden } } else { if st.Groups[req.ParentID] == nil { return errors.New("unknown parent group") } if !st.IsGroupAdmin(u.ID, req.ParentID) { return errForbidden } } g := &store.Group{ID: secret.ID(), Name: strings.TrimSpace(req.Name), ParentID: req.ParentID, CreatedAt: time.Now()} st.Groups[g.ID] = g out = groupView{ID: g.ID, Name: g.Name, ParentID: g.ParentID, Admin: true} return nil }) if err != nil { deny(w, err) return } writeJSON(w, http.StatusCreated, out) } func (s *Server) handleDeleteGroup(w http.ResponseWriter, r *http.Request, u *store.User) { id := r.PathValue("id") err := s.st.Update(func(st *store.State) error { g := st.Groups[id] if g == nil { return errors.New("no such group") } if !st.IsGroupAdmin(u.ID, id) { return errForbidden } if len(st.Descendants(id)) > 1 { return errors.New("the group still has child groups") } for _, db := range st.Databases { if db.OwnerGroupID == id { return errors.New("the group still owns databases") } } for _, m := range st.Memberships { if m.GroupID == id { return errors.New("the group still has members") } } delete(st.Groups, id) st.Grants = filterGrants(st.Grants, func(gr store.Grant) bool { return gr.GroupID != id }) return nil }) if err != nil { deny(w, err) return } writeJSON(w, http.StatusOK, map[string]string{"status": "deleted"}) } func (s *Server) handleSetMember(w http.ResponseWriter, r *http.Request, u *store.User) { groupID := r.PathValue("id") var req struct { Username string `json:"username"` UserID string `json:"user_id"` Role store.Role `json:"role"` } if !readJSON(w, r, &req) { return } err := s.st.Update(func(st *store.State) error { if st.Groups[groupID] == nil { return errors.New("no such group") } if !st.IsGroupAdmin(u.ID, groupID) { return errForbidden } target := st.Users[req.UserID] if target == nil && req.Username != "" { target = st.UserByName(req.Username) } if target == nil { return errors.New("no such user") } role := req.Role if role == "" { role = store.RoleMember } if !role.Valid() { return errors.New("role must be member or admin") } for i := range st.Memberships { if st.Memberships[i].UserID == target.ID && st.Memberships[i].GroupID == groupID { st.Memberships[i].Role = role return nil } } st.Memberships = append(st.Memberships, store.Membership{UserID: target.ID, GroupID: groupID, Role: role}) return nil }) if err != nil { deny(w, err) return } writeJSON(w, http.StatusOK, map[string]string{"status": "ok"}) } func (s *Server) handleRemoveMember(w http.ResponseWriter, r *http.Request, u *store.User) { groupID, userRef := r.PathValue("id"), r.PathValue("user") err := s.st.Update(func(st *store.State) error { if st.Groups[groupID] == nil { return errors.New("no such group") } if !st.IsGroupAdmin(u.ID, groupID) { return errForbidden } target := st.Users[userRef] if target == nil { target = st.UserByName(userRef) } if target == nil { return errors.New("no such user") } out := st.Memberships[:0] for _, m := range st.Memberships { if m.UserID == target.ID && m.GroupID == groupID { continue } out = append(out, m) } st.Memberships = out return nil }) if err != nil { deny(w, err) return } writeJSON(w, http.StatusOK, map[string]string{"status": "removed"}) } // ---- grants ---- func (s *Server) handleSetGrant(w http.ResponseWriter, r *http.Request, u *store.User) { var req struct { GroupID string `json:"group_id"` DatabaseID string `json:"database_id"` Perm store.Perm `json:"perm"` } if !readJSON(w, r, &req) { return } var watchers []string err := s.st.Update(func(st *store.State) error { if st.Databases[req.DatabaseID] == nil { return errors.New("no such database") } if st.Groups[req.GroupID] == nil { return errors.New("no such group") } if st.DatabasePerm(u.ID, req.DatabaseID) != store.PermAdmin { return errForbidden } if !req.Perm.Valid() { return errors.New("perm must be read, write or admin") } for i := range st.Grants { if st.Grants[i].GroupID == req.GroupID && st.Grants[i].DatabaseID == req.DatabaseID { st.Grants[i].Perm = req.Perm watchers = st.Watchers(req.DatabaseID) return nil } } st.Grants = append(st.Grants, store.Grant{GroupID: req.GroupID, DatabaseID: req.DatabaseID, Perm: req.Perm}) watchers = st.Watchers(req.DatabaseID) return nil }) if err != nil { deny(w, err) return } s.hub.Publish(watchers, Event{Type: EventChanged, DatabaseID: req.DatabaseID}) writeJSON(w, http.StatusOK, map[string]string{"status": "ok"}) } func (s *Server) handleDeleteGrant(w http.ResponseWriter, r *http.Request, u *store.User) { groupID := r.URL.Query().Get("group_id") dbID := r.URL.Query().Get("database_id") var watchers []string err := s.st.Update(func(st *store.State) error { if st.Databases[dbID] == nil { return errors.New("no such database") } if st.DatabasePerm(u.ID, dbID) != store.PermAdmin { return errForbidden } watchers = st.Watchers(dbID) st.Grants = filterGrants(st.Grants, func(g store.Grant) bool { return g.GroupID != groupID || g.DatabaseID != dbID }) return nil }) if err != nil { deny(w, err) return } s.hub.Publish(watchers, Event{Type: EventChanged, DatabaseID: dbID}) writeJSON(w, http.StatusOK, map[string]string{"status": "deleted"}) } // ---- invites ---- func (s *Server) handleCreateInvite(w http.ResponseWriter, r *http.Request, u *store.User) { var req struct { GroupID string `json:"group_id"` Role store.Role `json:"role"` TTLHours int `json:"ttl_hours"` MaxUses int `json:"max_uses"` } if !readJSON(w, r, &req) { return } inv := &store.Invite{Token: secret.Token(), GroupID: req.GroupID, Role: req.Role, CreatedBy: u.ID, CreatedAt: time.Now(), MaxUses: req.MaxUses} if inv.Role == "" { inv.Role = store.RoleMember } if req.TTLHours <= 0 { req.TTLHours = 168 } if inv.MaxUses == 0 { inv.MaxUses = 1 } inv.ExpiresAt = time.Now().Add(time.Duration(req.TTLHours) * time.Hour) err := s.st.Update(func(st *store.State) error { if inv.GroupID == "" { if !u.ServerAdmin { return errors.New("group_id is required") } } else { if st.Groups[inv.GroupID] == nil { return errors.New("unknown group") } if !st.IsGroupAdmin(u.ID, inv.GroupID) { return errForbidden } } if !inv.Role.Valid() { return errors.New("role must be member or admin") } st.Invites[inv.Token] = inv return nil }) if err != nil { deny(w, err) return } writeJSON(w, http.StatusCreated, map[string]any{ "token": inv.Token, "url": s.cfg.ExternalURL + "/invite/" + inv.Token, "expires_at": inv.ExpiresAt, "max_uses": inv.MaxUses, "group_id": inv.GroupID, "role": inv.Role, }) } func (s *Server) handleListInvites(w http.ResponseWriter, r *http.Request, u *store.User) { type inviteView struct { Token string `json:"token"` URL string `json:"url"` GroupID string `json:"group_id,omitempty"` Role store.Role `json:"role"` Uses int `json:"uses"` MaxUses int `json:"max_uses"` ExpiresAt time.Time `json:"expires_at"` } out := []inviteView{} s.st.Read(func(st *store.State) { for _, inv := range st.Invites { if !u.ServerAdmin && inv.CreatedBy != u.ID && !(inv.GroupID != "" && st.IsGroupAdmin(u.ID, inv.GroupID)) { continue } out = append(out, inviteView{Token: inv.Token, URL: s.cfg.ExternalURL + "/invite/" + inv.Token, GroupID: inv.GroupID, Role: inv.Role, Uses: inv.Uses, MaxUses: inv.MaxUses, ExpiresAt: inv.ExpiresAt}) } }) sort.Slice(out, func(i, j int) bool { return out[i].ExpiresAt.Before(out[j].ExpiresAt) }) writeJSON(w, http.StatusOK, map[string]any{"invites": out}) } // ---- users ---- func (s *Server) handleListUsers(w http.ResponseWriter, r *http.Request, u *store.User) { out := []userView{} s.st.Read(func(st *store.State) { for id, target := range st.Users { if u.ServerAdmin || id == u.ID || s.sharesAdministeredGroup(st, u.ID, id) { out = append(out, view(target)) } } }) sort.Slice(out, func(i, j int) bool { return out[i].Username < out[j].Username }) writeJSON(w, http.StatusOK, map[string]any{"users": out}) } // sharesAdministeredGroup reports whether admin administers any group the // target belongs to, which is what makes the target visible to them. func (s *Server) sharesAdministeredGroup(st *store.State, adminID, targetID string) bool { for _, m := range st.Memberships { if m.UserID == targetID && st.IsGroupAdmin(adminID, m.GroupID) { return true } } return false } // notifyList tells everyone with access that the database list changed. func (s *Server) notifyList(dbID string) { var watchers []string s.st.Read(func(st *store.State) { watchers = st.Watchers(dbID) }) s.hub.Publish(watchers, Event{Type: EventChanged, DatabaseID: dbID}) } func filterGrants(in []store.Grant, keep func(store.Grant) bool) []store.Grant { out := in[:0] for _, g := range in { if keep(g) { out = append(out, g) } } return out }