Files
at-container-registry/pkg/appview/handlers/storage.go
T

152 lines
4.4 KiB
Go

package handlers
import (
"encoding/json"
"fmt"
"log/slog"
"net/http"
"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
}
// 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(r.Context(), 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 := atproto.ResolveHoldURL(holdDID)
if holdURL == "" {
slog.Warn("Failed to resolve hold URL", "did", user.DID, "holdDid", holdDID)
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)
resp, err := http.Get(quotaURL)
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 the stats partial
h.renderStats(w, stats)
}
func (h *StorageHandler) renderStats(w http.ResponseWriter, stats QuotaStats) {
// 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 = int(float64(stats.TotalSize) / float64(*stats.Limit) * 100)
if usagePercent > 100 {
usagePercent = 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")
fmt.Fprintf(w, `<div class="storage-error"><i data-lucide="alert-circle"></i> %s</div>`, message)
}
func (h *StorageHandler) renderNoHold(w http.ResponseWriter) {
w.Header().Set("Content-Type", "text/html")
fmt.Fprint(w, `<div class="storage-info"><i data-lucide="info"></i> No hold configured. Set a default hold above to see storage usage.</div>`)
}
// 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])
}