Files
dhkatzandClaude Opus 5.5 8742829e77
CI / build (push) Successful in 54s
t5de-catalog: store and serve community product files
A Go server that takes product uploads with hub upload tokens, checks them by
t5de-api's pack rules, stores files by sha256, registers revisions with the hub
(POST /api/v1/catalog/revisions), and serves files with immutable caching;
files used only by adult revisions need an adult access token. Contract:
t5de-hub's docs/products.md.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 18:59:16 -07:00

195 lines
5.8 KiB
Go

// Package server is the catalog's HTTP API (t5de-hub's docs/products.md, "Catalog API").
package server
import (
"encoding/json"
"errors"
"log"
"net/http"
"strconv"
"strings"
"time"
"git.t5de.dev/t5de/t5de-catalog/internal/hub"
"git.t5de.dev/t5de/t5de-catalog/internal/store"
"git.t5de.dev/t5de/t5de-catalog/internal/token"
)
// Config is what the server needs.
type Config struct {
Store *store.Store
Verifier *token.Verifier
Hub *hub.Client
CatalogID string
WebOrigin string // the hub website's origin, allowed to upload from browsers (CORS); empty for none
Logger *log.Logger
// MaxUploads is how many uploads are checked at once (each can hold up to 256 MiB of files in memory);
// default 2.
MaxUploads int
}
// Server serves the catalog's API.
type Server struct {
cfg Config
uploads chan struct{}
mux *http.ServeMux
}
// New returns the catalog's API.
func New(cfg Config) *Server {
if cfg.MaxUploads < 1 {
cfg.MaxUploads = 2
}
if cfg.Logger == nil {
cfg.Logger = log.Default()
}
cfg.WebOrigin = strings.TrimRight(cfg.WebOrigin, "/")
s := &Server{cfg: cfg, uploads: make(chan struct{}, cfg.MaxUploads), mux: http.NewServeMux()}
s.mux.HandleFunc("GET /health", s.health)
s.mux.HandleFunc("OPTIONS /api/v1/uploads", s.preflight)
s.mux.HandleFunc("POST /api/v1/uploads", s.upload)
s.mux.HandleFunc("GET /api/v1/files/{sha256}", s.file)
s.mux.HandleFunc("GET /api/v1/revisions/{product}/{revision}", s.revision)
return s
}
// ServeHTTP serves a request and logs it.
func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
start := time.Now()
rec := &recorder{ResponseWriter: w, status: http.StatusOK}
s.mux.ServeHTTP(rec, r)
s.cfg.Logger.Printf("%s %s %d %dB %s", r.Method, r.URL.Path, rec.status, rec.bytes, time.Since(start).Round(time.Millisecond))
}
type recorder struct {
http.ResponseWriter
status int
bytes int64
wrote bool
}
func (r *recorder) WriteHeader(status int) {
if !r.wrote {
r.status, r.wrote = status, true
}
r.ResponseWriter.WriteHeader(status)
}
func (r *recorder) Write(b []byte) (int, error) {
r.wrote = true
n, err := r.ResponseWriter.Write(b)
r.bytes += int64(n)
return n, err
}
func (r *recorder) Unwrap() http.ResponseWriter { return r.ResponseWriter }
func writeJSON(w http.ResponseWriter, status int, v any) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(status)
json.NewEncoder(w).Encode(v)
}
func writeError(w http.ResponseWriter, status int, code, message string) {
if w.Header().Get("Cache-Control") == "" {
w.Header().Set("Cache-Control", "no-store")
}
writeJSON(w, status, map[string]string{"error": code, "message": message})
}
func (s *Server) health(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Cache-Control", "no-store")
writeJSON(w, http.StatusOK, map[string]bool{"ok": true})
}
// bearer returns the request's bearer token, if any.
func bearer(r *http.Request) string {
scheme, tok, ok := strings.Cut(r.Header.Get("Authorization"), " ")
if !ok || !strings.EqualFold(scheme, "Bearer") {
return ""
}
return strings.TrimSpace(tok)
}
func (s *Server) file(w http.ResponseWriter, r *http.Request) {
hash := r.PathValue("sha256")
if !store.ValidHash(hash) {
writeError(w, http.StatusNotFound, "not_found", "No such file.")
return
}
hosted, adultOnly, err := s.cfg.Store.FileAccess(hash)
if err != nil {
s.cfg.Logger.Printf("files: %s: %v", hash, err)
writeError(w, http.StatusInternalServerError, "internal", "Something went wrong.")
return
}
if !hosted {
writeError(w, http.StatusNotFound, "not_found", "No such file.")
return
}
h := w.Header()
if adultOnly {
h.Set("Cache-Control", "private, no-store")
h.Set("Vary", "Authorization")
tok := bearer(r)
if tok == "" {
h.Set("WWW-Authenticate", `Bearer realm="t5de-catalog"`)
writeError(w, http.StatusUnauthorized, "token_required", "This file needs an access token.")
return
}
c, err := s.cfg.Verifier.Verify(r.Context(), tok, token.Access)
if err != nil {
h.Set("WWW-Authenticate", `Bearer realm="t5de-catalog", error="invalid_token"`)
writeError(w, http.StatusUnauthorized, "bad_token", "The access token isn't valid.")
return
}
if !c.Adult {
writeError(w, http.StatusForbidden, "adult_only", "This file is for adults only.")
return
}
}
f, err := s.cfg.Store.Open(hash)
if errors.Is(err, store.ErrNotFound) {
s.cfg.Logger.Printf("files: %s is in a hosted revision but missing on disk", hash)
writeError(w, http.StatusNotFound, "not_found", "No such file.")
return
}
if err != nil {
s.cfg.Logger.Printf("files: %s: %v", hash, err)
writeError(w, http.StatusInternalServerError, "internal", "Something went wrong.")
return
}
defer f.Close()
if adultOnly {
h.Set("Cache-Control", "private, max-age=31536000, immutable")
} else {
h.Set("Cache-Control", "public, max-age=31536000, immutable")
}
h.Set("ETag", `"`+hash+`"`)
h.Set("Content-Type", "application/octet-stream")
h.Set("X-Content-Type-Options", "nosniff")
http.ServeContent(w, r, "", time.Time{}, f)
}
func (s *Server) revision(w http.ResponseWriter, r *http.Request) {
product := r.PathValue("product")
n, err := strconv.Atoi(r.PathValue("revision"))
if !hub.ValidProduct(product) || err != nil || n < 1 {
writeError(w, http.StatusNotFound, "not_found", "No such revision.")
return
}
rev, err := s.cfg.Store.Revision(product, n)
if errors.Is(err, store.ErrNotFound) {
writeError(w, http.StatusNotFound, "not_found", "No such revision.")
return
}
if err != nil {
s.cfg.Logger.Printf("revisions: %s/%d: %v", product, n, err)
writeError(w, http.StatusInternalServerError, "internal", "Something went wrong.")
return
}
// Revisions never change, but moderation can remove one.
w.Header().Set("Cache-Control", "public, max-age=3600")
writeJSON(w, http.StatusOK, rev)
}