Files

183 lines
5.6 KiB
Go

package handlers
import (
"context"
"encoding/json"
"fmt"
"log/slog"
"net/http"
"time"
"atcr.io/pkg/appview/middleware"
"atcr.io/pkg/appview/storage"
"atcr.io/pkg/atproto"
)
// StorageHandler handles the storage quota API endpoint
// Returns an HTML partial for HTMX to swap into the settings page
type StorageHandler struct {
BaseUIHandler
}
// QuotaStats mirrors the hold service response
type QuotaStats struct {
UserDID string `json:"userDid"`
UniqueBlobs int `json:"uniqueBlobs"`
TotalSize int64 `json:"totalSize"`
Limit *int64 `json:"limit,omitempty"` // nil = unlimited
Tier string `json:"tier,omitempty"` // e.g., "deckhand", "bosun", "owner"
}
func (h *StorageHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
user := middleware.GetUser(r)
if user == nil {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
// 5-second timeout for the entire operation (DID resolution + quota fetch)
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
defer cancel()
// Use hold_did query param if provided (for previewing other holds),
// otherwise fall back to the user's saved default hold from their profile.
holdDID := r.URL.Query().Get("hold_did")
if holdDID == "" {
client := atproto.NewClientWithSessionProvider(user.PDSEndpoint, user.DID, h.Refresher)
profile, err := storage.GetProfile(ctx, client)
if err != nil {
slog.Warn("Failed to get profile for storage quota", "did", user.DID, "error", err)
h.renderError(w, "Failed to load profile")
return
}
if profile == nil || profile.DefaultHold == "" {
h.renderNoHold(w)
return
}
holdDID = profile.DefaultHold
}
// Resolve hold URL from DID
holdURL, err := atproto.ResolveHoldURL(ctx, holdDID)
if err != nil {
slog.Warn("Failed to resolve hold URL", "did", user.DID, "holdDid", holdDID, "error", err)
h.renderError(w, "Failed to resolve hold service")
return
}
// Call the hold's quota endpoint
quotaURL := fmt.Sprintf("%s%s?userDid=%s", holdURL, atproto.HoldGetQuota, user.DID)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, quotaURL, nil)
if err != nil {
slog.Warn("Failed to create quota request", "did", user.DID, "error", err)
h.renderError(w, "Failed to connect to hold service")
return
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
slog.Warn("Failed to fetch quota from hold", "did", user.DID, "holdURL", holdURL, "error", err)
h.renderError(w, "Failed to connect to hold service")
return
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
slog.Warn("Hold returned error for quota", "did", user.DID, "status", resp.StatusCode)
h.renderError(w, "Hold service returned an error")
return
}
var stats QuotaStats
if err := json.NewDecoder(resp.Body).Decode(&stats); err != nil {
slog.Warn("Failed to decode quota response", "did", user.DID, "error", err)
h.renderError(w, "Failed to parse quota data")
return
}
// Render compact or full stats
if r.URL.Query().Get("compact") == "true" {
h.renderCompact(w, stats)
return
}
h.renderStats(w, stats, holdDID)
}
func (h *StorageHandler) renderCompact(w http.ResponseWriter, stats QuotaStats) {
w.Header().Set("Content-Type", "text/html")
if stats.TotalSize == 0 && stats.UniqueBlobs == 0 {
fmt.Fprint(w, `<span class="text-base-content/40">No data</span>`)
} else {
fmt.Fprint(w, humanizeBytes(stats.TotalSize))
}
}
func (h *StorageHandler) renderStats(w http.ResponseWriter, stats QuotaStats, holdDID string) {
// Calculate usage percentage if limit exists
var usagePercent int
var hasLimit bool
var humanLimit string
if stats.Limit != nil && *stats.Limit > 0 {
hasLimit = true
humanLimit = humanizeBytes(*stats.Limit)
usagePercent = min(int(float64(stats.TotalSize)/float64(*stats.Limit)*100), 100)
}
data := struct {
UniqueBlobs int
TotalSize int64
HumanSize string
HasLimit bool
HumanLimit string
UsagePercent int
Tier string
}{
UniqueBlobs: stats.UniqueBlobs,
TotalSize: stats.TotalSize,
HumanSize: humanizeBytes(stats.TotalSize),
HasLimit: hasLimit,
HumanLimit: humanLimit,
UsagePercent: usagePercent,
Tier: stats.Tier,
}
w.Header().Set("Content-Type", "text/html")
if err := h.Templates.ExecuteTemplate(w, "storage_stats", data); err != nil {
slog.Error("Failed to render storage stats template", "error", err)
http.Error(w, "Failed to render template", http.StatusInternalServerError)
}
}
func (h *StorageHandler) renderError(w http.ResponseWriter, message string) {
w.Header().Set("Content-Type", "text/html")
// Route through the alert partial so the error matches the rest of the
// UI; previous hand-rolled markup referenced a non-existent
// `storage-error` class.
if err := h.Templates.ExecuteTemplate(w, "alert", map[string]string{
"Type": "error",
"Message": message,
}); err != nil {
slog.Error("Failed to render storage alert", "error", err)
fmt.Fprintf(w, `<p class="text-sm text-error">%s</p>`, message)
}
}
func (h *StorageHandler) renderNoHold(w http.ResponseWriter) {
w.Header().Set("Content-Type", "text/html")
fmt.Fprint(w, `<p class="flex items-center gap-2 text-sm text-base-content/70"><svg class="icon size-4 shrink-0" aria-hidden="true"><use href="/icons.svg#info"></use></svg> No hold configured. Set a default hold above to see storage usage.</p>`)
}
// humanizeBytes converts bytes to human-readable format
func humanizeBytes(bytes int64) string {
const unit = 1024
if bytes < unit {
return fmt.Sprintf("%d B", bytes)
}
div, exp := int64(unit), 0
for n := bytes / unit; n >= unit; n /= unit {
div *= unit
exp++
}
return fmt.Sprintf("%.1f %cB", float64(bytes)/float64(div), "KMGTPE"[exp])
}