Files
beardrive/internal/webapp/shares.go
T
Snow LeeandClaude Fable 5 28dc882c66 feat(web): read heatmap phase 1 — ledger, heat API, folder heat dots
Read telemetry per docs/design/read-heatmap.md: a ReadLedger over a new
batch-oriented MetaStore ReadRepo (file reads.json + SQL read_stats)
aggregates viewer and share reads into daily per-actor buckets, debounced
to visits, folded into all-time rows past retention. GET /api/p/<id>/heat
serves per-path counts (human/agent/share, distinct readers, last read) —
never identities. /store sync traffic and history blob views are not reads.
The viewer shows heat dots and read counts on folder listings and the file
meta line.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P5cxPQdSGJnjXCYY9GeWXt
2026-07-11 14:41:07 -07:00

318 lines
9.9 KiB
Go

package webapp
import (
"encoding/json"
"fmt"
"html"
"io"
"net/http"
"path"
"strings"
"sync"
"time"
)
// Share links make one file publicly readable at /s/<unguessable-token> —
// no sign-in needed, which is the whole point: "here's the report" is just a
// URL. A link always serves the file's LATEST synced content (living wiki
// pages, evolving reports) and lives until revoked, unless created with an
// expiry. Everything else on the hub stays behind auth.
//
// Shared content renders (HTML as a page, markdown Obsidian-style, PDFs
// inline) but sandboxed: /s/ responses carry a strict CSP sandbox and never
// see auth cookies, so a malicious shared file's scripts run in an opaque
// origin and can't touch hub sessions.
// Share is one public link.
type Share struct {
Token string `json:"token"`
Project string `json:"project"`
Path string `json:"path"`
Creator string `json:"creator,omitempty"` // account email
Created time.Time `json:"created"`
Expires time.Time `json:"expires,omitzero"` // zero = permanent until revoked
}
func (s Share) expired() bool {
return !s.Expires.IsZero() && time.Now().After(s.Expires)
}
// ShareDB is the in-memory share registry over a MetaStore ShareRepo.
type ShareDB struct {
repo ShareRepo
mu sync.Mutex
byToken map[string]Share
}
// NewShareDB builds the registry over a repo, loading its contents.
func NewShareDB(repo ShareRepo) (*ShareDB, error) {
db := &ShareDB{repo: repo, byToken: make(map[string]Share)}
list, err := repo.Load()
if err != nil {
return nil, err
}
for _, s := range list {
db.byToken[s.Token] = s
}
return db, nil
}
// OpenShareDB loads the file-backed registry at path.
func OpenShareDB(path string) (*ShareDB, error) {
return NewShareDB(newFileShareRepo(path))
}
// Create returns a share for (project, path), reusing an existing live one
// so repeated shares of the same file hand out the same URL.
func (db *ShareDB) Create(project, p, creator string, ttl time.Duration) (Share, error) {
db.mu.Lock()
defer db.mu.Unlock()
for _, s := range db.byToken {
if s.Project == project && s.Path == p && !s.expired() && s.Expires.IsZero() && ttl == 0 {
return s, nil
}
}
s := Share{
Token: randHex(16), Project: project, Path: p,
Creator: creator, Created: time.Now().UTC(),
}
if ttl > 0 {
s.Expires = time.Now().UTC().Add(ttl)
}
db.byToken[s.Token] = s
if err := db.repo.Put(s); err != nil {
delete(db.byToken, s.Token)
return Share{}, err
}
return s, nil
}
// Get resolves a live (non-expired) share.
func (db *ShareDB) Get(token string) (Share, bool) {
if db == nil {
return Share{}, false
}
db.mu.Lock()
defer db.mu.Unlock()
s, ok := db.byToken[token]
if !ok || s.expired() {
return Share{}, false
}
return s, true
}
func (db *ShareDB) Revoke(token string) bool {
db.mu.Lock()
defer db.mu.Unlock()
if _, ok := db.byToken[token]; !ok {
return false
}
delete(db.byToken, token)
db.repo.Delete(token)
return true
}
// List returns a project's live shares.
func (db *ShareDB) List(project string) []Share {
db.mu.Lock()
defer db.mu.Unlock()
var out []Share
for _, s := range db.byToken {
if s.Project == project && !s.expired() {
out = append(out, s)
}
}
return out
}
// ---- HTTP ----
// handleShareCreate mints (or returns) the share link for a file. Any
// signed-in member can share; the file must already be synced.
func (s *Server) handleShareCreate(v *volume, w http.ResponseWriter, r *http.Request) {
if s.Shares == nil {
http.Error(w, "sharing is not enabled on this server", http.StatusNotFound)
return
}
var req struct {
Path string `json:"path"`
ExpiresIn string `json:"expires_in,omitempty"` // Go duration, e.g. "168h"
}
if err := json.NewDecoder(io.LimitReader(r.Body, 1<<16)).Decode(&req); err != nil {
http.Error(w, "bad request: "+err.Error(), http.StatusBadRequest)
return
}
p, err := cleanUploadPath(req.Path)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
snap, err := v.snapshot(r.Context())
if err != nil {
http.Error(w, err.Error(), http.StatusBadGateway)
return
}
if _, ok := snap.files[p]; !ok {
http.Error(w, fmt.Sprintf("%s is not synced to this project yet", p), http.StatusNotFound)
return
}
var ttl time.Duration
if req.ExpiresIn != "" {
if ttl, err = time.ParseDuration(req.ExpiresIn); err != nil || ttl <= 0 {
http.Error(w, "invalid expires_in", http.StatusBadRequest)
return
}
}
sh, err := s.Shares.Create(r.PathValue("project"), p, s.requestUser(r).Email, ttl)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
writeJSON(w, shareJSON(r, sh))
}
func (s *Server) handleShareList(v *volume, w http.ResponseWriter, r *http.Request) {
if s.Shares == nil {
http.Error(w, "sharing is not enabled on this server", http.StatusNotFound)
return
}
shares := s.Shares.List(r.PathValue("project"))
out := make([]map[string]any, 0, len(shares))
for _, sh := range shares {
out = append(out, shareJSON(r, sh))
}
writeJSON(w, map[string]any{"shares": out})
}
func (s *Server) handleShareRevoke(w http.ResponseWriter, r *http.Request) {
if s.Shares == nil {
http.Error(w, "sharing is not enabled on this server", http.StatusNotFound)
return
}
sh, ok := s.Shares.Get(r.PathValue("token"))
if ok && !s.projectAllowed(r, sh.Project) {
http.Error(w, "you are not a member of this project's organization", http.StatusForbidden)
return
}
if s.Shares.Revoke(r.PathValue("token")) {
writeJSON(w, map[string]any{"ok": true})
return
}
http.Error(w, "no such share", http.StatusNotFound)
}
func shareJSON(r *http.Request, sh Share) map[string]any {
out := map[string]any{
"token": sh.Token, "path": sh.Path, "project": sh.Project,
"url": requestBaseURL(r) + "/s/" + sh.Token, "created": sh.Created,
}
if sh.Creator != "" {
out["creator"] = sh.Creator
}
if !sh.Expires.IsZero() {
out["expires"] = sh.Expires
}
return out
}
// handleShared serves a share link: public, sandboxed, always the latest
// synced content.
func (s *Server) handleShared(w http.ResponseWriter, r *http.Request) {
if !s.shareLimiter().allow(clientIP(r)) {
http.Error(w, "too many requests — slow down", http.StatusTooManyRequests)
return
}
sh, ok := s.Shares.Get(r.PathValue("token"))
if !ok {
http.Error(w, "this link does not exist or was revoked", http.StatusNotFound)
return
}
v, err := s.projectVolume(sh.Project)
if err != nil {
http.Error(w, "this link does not exist or was revoked", http.StatusNotFound)
return
}
snap, err := v.snapshot(r.Context())
if err != nil {
http.Error(w, "content temporarily unavailable", http.StatusBadGateway)
return
}
fi, ok := snap.files[sh.Path]
if !ok {
http.Error(w, "the shared file no longer exists", http.StatusNotFound)
return
}
// A share hit is external consumption. Actor is token+IP: one audience
// member reloading is debounced to a visit, distinct visitors still count.
s.Reads.Record(sh.Project, sh.Path, ReadKindShare, sh.Token+"/"+clientIP(r))
// Sandbox everything under /s/: shared content executes in an opaque
// origin (scripts allowed — charts in reports — but no cookies, no
// same-origin reach back into the hub).
w.Header().Set("Content-Security-Policy", "sandbox allow-scripts allow-popups")
w.Header().Set("X-Content-Type-Options", "nosniff")
w.Header().Set("Referrer-Policy", "no-referrer")
rc, err := v.source.Open(r.Context(), sh.Path, fi)
if err != nil {
http.Error(w, "content temporarily unavailable", http.StatusBadGateway)
return
}
defer rc.Close()
if r.URL.Query().Get("download") == "1" {
w.Header().Set("Content-Type", contentType(sh.Path))
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%q", sanitizeFilename(path.Base(sh.Path))))
io.Copy(w, rc)
return
}
switch strings.ToLower(path.Ext(sh.Path)) {
case ".md", ".markdown":
src, err := io.ReadAll(rc)
if err != nil {
http.Error(w, "content temporarily unavailable", http.StatusBadGateway)
return
}
body, err := RenderMarkdown(src)
if err != nil {
http.Error(w, "render failed", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
fmt.Fprintf(w, sharedMarkdownShell, html.EscapeString(path.Base(sh.Path)), body)
case ".html", ".htm":
w.Header().Set("Content-Type", "text/html; charset=utf-8")
io.Copy(w, rc)
default:
w.Header().Set("Content-Type", contentType(sh.Path))
w.Header().Set("Content-Length", fmt.Sprint(fi.Size))
io.Copy(w, rc)
}
}
// sharedMarkdownShell wraps rendered markdown in a minimal readable page.
const sharedMarkdownShell = `<!doctype html><html lang="en"><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1"><title>%s</title>
<style>
body{font:16px/1.7 -apple-system,BlinkMacSystemFont,"SF Pro Text","Inter","Segoe UI",sans-serif;color:#24292f;
max-width:720px;margin:0 auto;padding:52px 24px 96px}
a{color:#b26a00}
@media (prefers-color-scheme: dark){body{background:#0a0b0d;color:#c6cbd3}
a{color:#ffcf85}code,pre{background:#15171b}h1,h2,h3{color:#f4f6f9}}
h1,h2,h3{line-height:1.25;letter-spacing:-.018em}
pre{padding:12px;border-radius:8px;overflow-x:auto;background:#f6f8fa}
code{background:#f6f8fa;padding:2px 5px;border-radius:4px;font-size:.9em}
pre code{padding:0;background:none}
img{max-width:100%%}
blockquote{margin:0;padding-left:16px;border-left:3px solid #d0d7de;color:#57606a}
table{border-collapse:collapse;display:block;overflow-x:auto;max-width:100%%}td,th{border:1px solid #d0d7de;padding:5px 10px}
pre{max-width:100%%}
footer.bdrive{margin-top:64px;padding-top:14px;border-top:1px solid #d0d7de;font-size:12.5px;color:#57606a}
footer.bdrive a{color:inherit}
@media (prefers-color-scheme: dark){footer.bdrive{border-color:#3a3a44;color:#888}}
</style></head><body>%s
<footer class="bdrive">Shared with <a href="https://github.com/runbear-io/beardrive" rel="noopener">BearDrive</a> — synced files for AI agent teams</footer>
</body></html>`