Added avatars to make it a bit more friendly
This commit is contained in:
@@ -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 (don’t 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)
|
||||
}
|
||||
|
@@ -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 Cloudflare’s 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
|
||||
}
|
||||
|
Reference in New Issue
Block a user