mirror of
https://github.com/runbear-io/beardrive.git
synced 2026-08-25 08:08:08 +02:00
Every /s/<token> hit was already recorded as a share-kind read, carrying
both a count and a timestamp — and then thrown away at the UI layer. The
Public links table showed only who shared a file and when, six inches
below a file header that already said "1 shared". Two personas filed it
independently on the same tour.
The number now rides the shares list:
* ReadLedger.ShareOpens(project) aggregates share-kind buckets per path,
all-time. Share-kind only is what makes Last mean *last opened* —
HeatEntry.LastRead is cross-kind, so a member viewing the file in the
hub would otherwise move the date.
* shareJSON takes the project's opens map, built ONCE per project by the
caller and indexed per row. Both callers — the project list and the
org-wide audit — hoist it above their loops; a per-share call would be
a full byKey scan per row.
* Counts, never openers. The share actor is token+"/"+IP, a public
credential joined to an IP, and it stays in the ledger. There is no
distinct-openers field, deliberately.
* Reads off means the keys are ABSENT, not zero: `0` would claim nobody
has opened a link on a hub that never looked.
shareDetail() is the leverage — the settings table, the org-wide audit and
the file page's share banner all render through it, so one string function
covers three surfaces. Once the row carried the receipt it truncated to
"3 op…", so the detail cell wraps instead of ellipsizing; the path keeps
its ellipsis, since it is a link with a tooltip and the column that can be
arbitrarily long.
Counted per FILE, not per link: heat is keyed by path, so two tokens on one
file report the same number. Worded that way in the section copy, alongside
the other honesty — opens are debounced visits, not requests.
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
188 lines
6.4 KiB
Go
188 lines
6.4 KiB
Go
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.
|
|
|
|
// 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.
|
|
func (s *Server) handleProjectUpdate(w http.ResponseWriter, r *http.Request) {
|
|
id := r.PathValue("project")
|
|
if _, ok := s.project(w, r, id, PermAdmin); !ok {
|
|
return
|
|
}
|
|
var req struct {
|
|
Name *string `json:"name"`
|
|
Description *string `json:"description"`
|
|
Icon *string `json:"icon"`
|
|
}
|
|
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.Update(id, req.Name, req.Description, req.Icon); err != nil {
|
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
|
return
|
|
}
|
|
writeJSON(w, map[string]any{"ok": true})
|
|
}
|
|
|
|
// handleProjectDelete removes a project from the registry. Project admins
|
|
// only. Storage (blobs, journals) is intentionally left in place.
|
|
func (s *Server) handleProjectDelete(w http.ResponseWriter, r *http.Request) {
|
|
id := r.PathValue("project")
|
|
if _, ok := s.project(w, r, id, PermAdmin); !ok {
|
|
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.Dir == nil {
|
|
http.Error(w, "sharing is not enabled on this server", http.StatusNotFound)
|
|
return
|
|
}
|
|
orgID := r.PathValue("org")
|
|
if s.Dir.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
|
|
}
|
|
// 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
|
|
}
|
|
// 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)
|
|
for _, sh := range s.Shares.List(p.ID) {
|
|
j := shareJSON(r, sh, opens)
|
|
j["project_name"] = p.Name
|
|
out = append(out, j)
|
|
}
|
|
}
|
|
writeJSON(w, map[string]any{"shares": out})
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
// 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, ok := s.approver(w)
|
|
if !ok {
|
|
return
|
|
}
|
|
writeJSON(w, map[string]any{"pending": a.PendingUsers()})
|
|
}
|
|
|
|
// 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
|
|
}
|
|
a, ok := s.approver(w)
|
|
if !ok {
|
|
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
|
|
}
|
|
// 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.
|
|
if err := a.SetPolicy(req.RequireVerification, req.RequireApproval); err != nil {
|
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
|
return
|
|
}
|
|
}
|
|
writeJSON(w, a.Policy())
|
|
}
|
|
|
|
// 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, ok := s.approver(w)
|
|
if !ok {
|
|
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, ok := s.approver(w)
|
|
if !ok {
|
|
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})
|
|
}
|