2026-07-08 22:23:27 -07:00
|
|
|
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.
|
|
|
|
|
|
2026-07-27 10:41:57 +09:00
|
|
|
// handleProjectUpdate edits a project's name, description and icon. Project
|
|
|
|
|
// admins (and, implicitly, the owners of its org) only. It's a partial update:
|
|
|
|
|
// every field is a pointer, so only the keys actually present in the body
|
|
|
|
|
// change — {"description":""} clears the description, omitting the key leaves
|
|
|
|
|
// it alone.
|
2026-07-27 09:56:59 +09:00
|
|
|
func (s *Server) handleProjectUpdate(w http.ResponseWriter, r *http.Request) {
|
2026-07-08 22:23:27 -07:00
|
|
|
id := r.PathValue("project")
|
2026-07-27 10:41:57 +09:00
|
|
|
if _, ok := s.project(w, r, id, PermAdmin); !ok {
|
2026-07-08 22:23:27 -07:00
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
var req struct {
|
2026-07-27 09:56:59 +09:00
|
|
|
Name *string `json:"name"`
|
|
|
|
|
Description *string `json:"description"`
|
|
|
|
|
Icon *string `json:"icon"`
|
2026-07-08 22:23:27 -07:00
|
|
|
}
|
|
|
|
|
if err := json.NewDecoder(io.LimitReader(r.Body, 1<<16)).Decode(&req); err != nil {
|
|
|
|
|
http.Error(w, "bad request: "+err.Error(), http.StatusBadRequest)
|
|
|
|
|
return
|
|
|
|
|
}
|
2026-07-27 09:56:59 +09:00
|
|
|
if err := s.Projects.Update(id, req.Name, req.Description, req.Icon); err != nil {
|
2026-07-08 22:23:27 -07:00
|
|
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
writeJSON(w, map[string]any{"ok": true})
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-27 10:41:57 +09:00
|
|
|
// handleProjectDelete removes a project from the registry. Project admins
|
|
|
|
|
// only. Storage (blobs, journals) is intentionally left in place.
|
2026-07-08 22:23:27 -07:00
|
|
|
func (s *Server) handleProjectDelete(w http.ResponseWriter, r *http.Request) {
|
|
|
|
|
id := r.PathValue("project")
|
2026-07-27 10:41:57 +09:00
|
|
|
if _, ok := s.project(w, r, id, PermAdmin); !ok {
|
2026-07-08 22:23:27 -07:00
|
|
|
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) {
|
2026-07-20 03:06:19 -07:00
|
|
|
if s.Shares == nil || s.Dir == nil {
|
2026-07-08 22:23:27 -07:00
|
|
|
http.Error(w, "sharing is not enabled on this server", http.StatusNotFound)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
orgID := r.PathValue("org")
|
2026-07-20 03:06:19 -07:00
|
|
|
if s.Dir.Role(orgID, s.requestUser(r).Email) == "" {
|
2026-07-08 22:23:27 -07:00
|
|
|
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
|
|
|
|
|
}
|
2026-08-03 16:20:51 +09:00
|
|
|
// Org membership is not access to every project in the org: a share
|
|
|
|
|
// row carries the public /s/ token, so listing one to a member who is
|
|
|
|
|
// denied that project hands them the file the denial exists to
|
|
|
|
|
// withhold. Same resolver the per-project /shares route uses.
|
|
|
|
|
if !atLeast(s.projectPermOf(r, p), PermRead) {
|
|
|
|
|
continue
|
|
|
|
|
}
|
2026-08-11 00:38:44 +09:00
|
|
|
// One scan per visible project, zero per share — and after the
|
|
|
|
|
// permission check, since there is no reason to scan for a project
|
|
|
|
|
// the caller cannot see.
|
|
|
|
|
opens := s.Reads.ShareOpens(p.ID)
|
2026-07-08 22:23:27 -07:00
|
|
|
for _, sh := range s.Shares.List(p.ID) {
|
2026-08-11 00:38:44 +09:00
|
|
|
j := shareJSON(r, sh, opens)
|
2026-07-08 22:23:27 -07:00
|
|
|
j["project_name"] = p.Name
|
|
|
|
|
out = append(out, j)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
writeJSON(w, map[string]any{"shares": out})
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-20 03:06:19 -07:00
|
|
|
// approver returns the auth provider's account-administration half, if it has
|
|
|
|
|
// one. A provider whose accounts live in an external identity system does not:
|
|
|
|
|
// there is no local approval queue to show and no local policy to flip.
|
|
|
|
|
func (s *Server) approver(w http.ResponseWriter) (AccountApprover, bool) {
|
|
|
|
|
a, ok := s.Auth.(AccountApprover)
|
|
|
|
|
if !ok {
|
|
|
|
|
// 503, not an empty list: "no queue here" and "queue is empty" are
|
|
|
|
|
// different answers, and only one of them is true.
|
|
|
|
|
http.Error(w, "accounts on this hub are administered in its identity provider",
|
|
|
|
|
http.StatusServiceUnavailable)
|
|
|
|
|
return nil, false
|
|
|
|
|
}
|
|
|
|
|
return a, true
|
2026-07-08 22:23:27 -07:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 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
|
|
|
|
|
}
|
2026-07-20 03:06:19 -07:00
|
|
|
a, ok := s.approver(w)
|
|
|
|
|
if !ok {
|
2026-07-08 22:23:27 -07:00
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
writeJSON(w, map[string]any{"pending": a.PendingUsers()})
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-08 22:55:14 -07:00
|
|
|
// handleAdminPolicy reads (GET) or updates (POST) the signup/access policy.
|
|
|
|
|
// Domains and the admin list are reported read-only — they're server-config
|
|
|
|
|
// owned so a browser session can't widen access — while verification and
|
|
|
|
|
// approval toggles can be flipped live and are persisted.
|
|
|
|
|
func (s *Server) handleAdminPolicy(w http.ResponseWriter, r *http.Request) {
|
|
|
|
|
if !s.requestUser(r).Admin {
|
|
|
|
|
http.Error(w, "hub admins only", http.StatusForbidden)
|
|
|
|
|
return
|
|
|
|
|
}
|
2026-07-20 03:06:19 -07:00
|
|
|
a, ok := s.approver(w)
|
|
|
|
|
if !ok {
|
2026-07-08 22:55:14 -07:00
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
if r.Method == http.MethodPost {
|
|
|
|
|
var req struct {
|
|
|
|
|
RequireVerification bool `json:"require_verification"`
|
|
|
|
|
RequireApproval bool `json:"require_approval"`
|
|
|
|
|
}
|
|
|
|
|
if err := json.NewDecoder(io.LimitReader(r.Body, 1<<16)).Decode(&req); err != nil {
|
|
|
|
|
http.Error(w, "bad request: "+err.Error(), http.StatusBadRequest)
|
|
|
|
|
return
|
|
|
|
|
}
|
2026-08-03 16:20:51 +09:00
|
|
|
// No gating rule of its own: SetPolicy runs the same validator startup
|
|
|
|
|
// does, so a browser session cannot reach a posture the binary refuses
|
|
|
|
|
// to boot in — which is what the mailer check here used to be one third
|
|
|
|
|
// of. 400, because a refusal here is the policy, not the store.
|
2026-07-08 22:55:14 -07:00
|
|
|
if err := a.SetPolicy(req.RequireVerification, req.RequireApproval); err != nil {
|
2026-08-03 16:20:51 +09:00
|
|
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
2026-07-08 22:55:14 -07:00
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-07-20 03:06:19 -07:00
|
|
|
writeJSON(w, a.Policy())
|
2026-07-08 22:55:14 -07:00
|
|
|
}
|
|
|
|
|
|
2026-07-08 22:23:27 -07:00
|
|
|
// 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
|
|
|
|
|
}
|
2026-07-20 03:06:19 -07:00
|
|
|
a, ok := s.approver(w)
|
|
|
|
|
if !ok {
|
2026-07-08 22:23:27 -07:00
|
|
|
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
|
|
|
|
|
}
|
2026-07-20 03:06:19 -07:00
|
|
|
a, ok := s.approver(w)
|
|
|
|
|
if !ok {
|
2026-07-08 22:23:27 -07:00
|
|
|
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})
|
|
|
|
|
}
|