// 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) }