CI / build (push) Successful in 54s
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]>
195 lines
5.8 KiB
Go
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)
|
|
}
|