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]>
This commit is contained in:
commit
8742829e77
20 files changed
+2348
No files matched your search
@@ -0,0 +1,194 @@
|
||||
// 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)
|
||||
}
|
||||
Reference in new issue
Block a user