Added avatars to make it a bit more friendly

This commit is contained in:
2025-08-22 19:35:00 -04:00
parent 5dfc710ae9
commit 0bf00e3f00
7 changed files with 521 additions and 446 deletions

View File

@@ -14,6 +14,7 @@ import (
"io"
"log"
"math/big"
"net"
"net/http"
"os"
"sort"
@@ -115,8 +116,8 @@ func (s *Server) routes() *http.ServeMux {
mux.HandleFunc("/v1/auth/key/verify", s.handleAuthKeyVerify)
// Objects
mux.HandleFunc("/v1/object", s.handlePutObject) // PUT (requires bearer+PoP)
mux.HandleFunc("/v1/object/", s.handleObjectByHash) // GET/DELETE (DELETE requires bearer+PoP)
mux.HandleFunc("/v1/object", s.handlePutObject) // PUT
mux.HandleFunc("/v1/object/", s.handleObjectByHash) // GET/DELETE
// Index
mux.HandleFunc("/v1/index", s.handleIndexList)
@@ -128,7 +129,7 @@ func (s *Server) routes() *http.ServeMux {
// GDPR
mux.HandleFunc("/v1/gdpr/policy", s.handleGDPRPolicy)
// Session panic wipe (no auth; clears browser caches)
// Panic wipe signal (client clears itself)
mux.HandleFunc("/v1/session/clear", s.handleSessionClear)
return mux
@@ -149,7 +150,7 @@ func (s *Server) cors(next http.Handler) http.Handler {
w.Header().Set("X-Content-Type-Options", "nosniff")
w.Header().Set("Strict-Transport-Security", "max-age=15552000; includeSubDomains; preload")
// Preflight is free (dont rate-limit OPTIONS)
// Preflight is free
if r.Method == http.MethodOptions {
w.WriteHeader(http.StatusNoContent)
return
@@ -175,15 +176,6 @@ func (s *Server) cors(next http.Handler) http.Handler {
})
}
func (s *Server) handleSessionClear(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
w.Header().Set("Clear-Site-Data", `"cache","storage"`)
w.WriteHeader(http.StatusNoContent)
}
// ---------- Helpers ----------
func b64u(b []byte) string {
@@ -197,7 +189,7 @@ func b64ud(s string) ([]byte, error) {
}
func nowUTC() time.Time { return time.Now().UTC() }
// Admin allow-list: env GC_ADMIN_SUBS="sub1,sub2"
// Admin allow-list: env GC_ADMIN_SUBS="sub1,sub2" (subs are thumbprints)
func (s *Server) isAdmin(ac authContext) bool {
raw := strings.TrimSpace(os.Getenv("GC_ADMIN_SUBS"))
if raw == "" || ac.CNF == "" {
@@ -212,6 +204,19 @@ func (s *Server) isAdmin(ac authContext) bool {
return false
}
func clientIP(r *http.Request) string {
xff := r.Header.Get("X-Forwarded-For")
if xff != "" {
parts := strings.Split(xff, ",")
return strings.TrimSpace(parts[0])
}
host, _, err := net.SplitHostPort(r.RemoteAddr)
if err == nil {
return host
}
return r.RemoteAddr
}
// ---------- Health ----------
func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
@@ -230,7 +235,7 @@ func (s *Server) handleAuthKeyChallenge(w http.ResponseWriter, r *http.Request)
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
// Nonce (32B hex) valid 5 minutes
// Nonce valid 5 minutes
sum := sha256.Sum256([]byte(fmt.Sprintf("%d:%d", time.Now().UnixNano(), time.Now().Unix())))
nonce := hex.EncodeToString(sum[:])
exp := nowUTC().Add(5 * time.Minute)
@@ -371,7 +376,7 @@ func parseECDSADER(der []byte) (*big.Int, *big.Int, bool) {
seqLen = int(der[i])
i++
}
_ = seqLen // not strictly needed
_ = seqLen
// INTEGER R
if i >= len(der) || der[i] != 0x02 {
@@ -536,7 +541,7 @@ func (s *Server) verifyPoP(w http.ResponseWriter, r *http.Request, ac authContex
http.Error(w, "ts window", http.StatusUnauthorized)
return false
}
// Replay cache
// Replay cache cleanup + check
s.rpMu.Lock()
for k, v := range s.replay {
if nowUTC().After(v) {
@@ -635,6 +640,12 @@ func (s *Server) handlePutObject(w http.ResponseWriter, r *http.Request) {
Private: isPrivate,
CreatorTZ: creatorTZ,
}
// Optional author fingerprint for avatars (pseudonymous).
// Disable by setting GC_SUPPRESS_AUTHOR=1
if os.Getenv("GC_SUPPRESS_AUTHOR") != "1" && ac.CNF != "" {
ent.Author = thumbprintFromCNF(ac.CNF)
}
if err := s.idx.Put(ent); err != nil {
http.Error(w, "index error", http.StatusInternalServerError)
return
@@ -667,6 +678,7 @@ func (s *Server) handleObjectByHash(w http.ResponseWriter, r *http.Request) {
defer rc.Close()
w.Header().Set("Content-Type", "application/octet-stream")
w.Header().Set("Content-Length", fmt.Sprintf("%d", size))
w.Header().Set("Content-Disposition", "attachment; filename="+hash)
_, _ = io.Copy(w, rc)
case http.MethodDelete:
@@ -777,22 +789,21 @@ func (s *Server) handleAdminReindex(w http.ResponseWriter, r *http.Request) {
}
var walked, indexed int64
err := s.store.Walk(func(hash string, size int64, mod time.Time) error {
walked++
ent := index.Entry{
Hash: hash,
Bytes: size,
StoredAt: mod.UTC().Format(time.RFC3339Nano),
Private: false,
}
if err := s.idx.Put(ent); err == nil {
indexed++
}
walked++
return nil
return s.idx.Put(ent)
})
if err != nil {
http.Error(w, "walk error", http.StatusInternalServerError)
return
}
ents := s.idx.All()
indexed = int64(len(ents))
w.Header().Set("Content-Type", "application/json; charset=utf-8")
_ = json.NewEncoder(w).Encode(map[string]any{
"walked": walked,
@@ -834,3 +845,14 @@ func (s *Server) handleGDPRPolicy(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
_ = json.NewEncoder(w).Encode(p)
}
// ---------- Session clear ----------
func (s *Server) handleSessionClear(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
w.Header().Set("Clear-Site-Data", `"cache","storage"`)
w.WriteHeader(http.StatusNoContent)
}

View File

@@ -1,31 +1,33 @@
package api
import (
"net"
"net/http"
"sync"
"time"
)
// Simple token-bucket rate limiter used by Server.cors middleware.
type tokenBucket struct {
tokens float64
lastFill time.Time
}
type rateLimiter struct {
mu sync.Mutex
bk map[string]*bucket
rate float64 // tokens per second
burst float64
window time.Duration
rate float64 // tokens per second
burst float64
mu sync.Mutex
bk map[string]*tokenBucket
evictDur time.Duration
lastGC time.Time
}
type bucket struct {
tokens float64
last time.Time
}
func newRateLimiter(rps float64, burst int, window time.Duration) *rateLimiter {
func newRateLimiter(rate float64, burst int, evict time.Duration) *rateLimiter {
return &rateLimiter{
bk: make(map[string]*bucket),
rate: rps,
burst: float64(burst),
window: window,
rate: rate,
burst: float64(burst),
bk: make(map[string]*tokenBucket),
evictDur: evict,
lastGC: time.Now(),
}
}
@@ -34,45 +36,37 @@ func (rl *rateLimiter) allow(key string) bool {
rl.mu.Lock()
defer rl.mu.Unlock()
b := rl.bk[key]
if b == nil {
b = &bucket{tokens: rl.burst, last: now}
// GC old buckets occasionally
if now.Sub(rl.lastGC) > rl.evictDur {
for k, b := range rl.bk {
if now.Sub(b.lastFill) > rl.evictDur {
delete(rl.bk, k)
}
}
rl.lastGC = now
}
b, ok := rl.bk[key]
if !ok {
b = &tokenBucket{tokens: rl.burst, lastFill: now}
rl.bk[key] = b
}
// refill
elapsed := now.Sub(b.last).Seconds()
b.tokens = min(rl.burst, b.tokens+elapsed*rl.rate)
b.last = now
if b.tokens < 1.0 {
return false
}
b.tokens -= 1.0
// Refill
elapsed := now.Sub(b.lastFill).Seconds()
b.tokens = minf(rl.burst, b.tokens+elapsed*rl.rate)
b.lastFill = now
// occasional cleanup
for k, v := range rl.bk {
if now.Sub(v.last) > rl.window {
delete(rl.bk, k)
}
if b.tokens >= 1.0 {
b.tokens -= 1.0
return true
}
return true
return false
}
func min(a, b float64) float64 {
func minf(a, b float64) float64 {
if a < b {
return a
}
return b
}
func clientIP(r *http.Request) string {
// Prefer Cloudflares header if present; fall back to RemoteAddr.
if ip := r.Header.Get("CF-Connecting-IP"); ip != "" {
return ip
}
host, _, err := net.SplitHostPort(r.RemoteAddr)
if err != nil {
return r.RemoteAddr
}
return host
}

View File

@@ -1,62 +1,48 @@
package index
import (
"errors"
"sync"
)
// Entry is the minimal metadata we expose to clients.
// Entry is the index record returned to clients.
// Keep metadata minimal to protect users.
type Entry struct {
Hash string `json:"hash"`
Bytes int64 `json:"bytes"`
StoredAt string `json:"stored_at"` // RFC3339Nano
Private bool `json:"private"` // true if client marked encrypted
CreatorTZ string `json:"creator_tz,omitempty"` // optional IANA TZ from client
StoredAt string `json:"stored_at"` // RFC3339Nano string
Private bool `json:"private"`
CreatorTZ string `json:"creator_tz,omitempty"`
Author string `json:"author,omitempty"` // pseudonymous (thumbprint), optional
}
// Index is an in-memory map from hash -> Entry, safe for concurrent use.
type Index struct {
mu sync.RWMutex
m map[string]Entry
mu sync.RWMutex
data map[string]Entry
}
func New() *Index {
return &Index{m: make(map[string]Entry)}
return &Index{data: make(map[string]Entry)}
}
func (ix *Index) Put(e Entry) error {
if e.Hash == "" {
return errors.New("empty hash")
}
ix.mu.Lock()
ix.m[e.Hash] = e
ix.data[e.Hash] = e
ix.mu.Unlock()
return nil
}
func (ix *Index) Delete(hash string) error {
if hash == "" {
return errors.New("empty hash")
}
ix.mu.Lock()
delete(ix.m, hash)
delete(ix.data, hash)
ix.mu.Unlock()
return nil
}
func (ix *Index) Get(hash string) (Entry, bool) {
ix.mu.RLock()
e, ok := ix.m[hash]
ix.mu.RUnlock()
return e, ok
}
// All returns an unsorted copy of all entries.
func (ix *Index) All() []Entry {
ix.mu.RLock()
out := make([]Entry, 0, len(ix.m))
for _, v := range ix.m {
out = append(out, v)
out := make([]Entry, 0, len(ix.data))
for _, e := range ix.data {
out = append(out, e)
}
ix.mu.RUnlock()
return out