Files
t5de-catalog/internal/server/upload.go
T
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

300 lines
10 KiB
Go

package server
import (
"archive/zip"
"encoding/json"
"errors"
"fmt"
"io"
"mime"
"net/http"
"os"
"path/filepath"
"strings"
"unicode/utf8"
"git.t5de.dev/t5de/t5de-api/pack"
"git.t5de.dev/t5de/t5de-catalog/internal/hub"
"git.t5de.dev/t5de/t5de-catalog/internal/store"
"git.t5de.dev/t5de/t5de-catalog/internal/token"
)
// MaxBody caps an upload request: the zip (whose files may hold pack.MaxTotalSize uncompressed) and the notes.
const MaxBody = 300 << 20
// MaxNotes caps the revision notes.
const MaxNotes = 64 << 10
// corsHeaders adds CORS headers for the hub website's origin.
func (s *Server) corsHeaders(w http.ResponseWriter, r *http.Request) bool {
h := w.Header()
h.Add("Vary", "Origin")
origin := r.Header.Get("Origin")
if origin == "" || s.cfg.WebOrigin == "" || origin != s.cfg.WebOrigin {
return false
}
h.Set("Access-Control-Allow-Origin", origin)
return true
}
func (s *Server) preflight(w http.ResponseWriter, r *http.Request) {
if s.corsHeaders(w, r) {
h := w.Header()
h.Add("Vary", "Access-Control-Request-Method")
h.Add("Vary", "Access-Control-Request-Headers")
h.Set("Access-Control-Allow-Methods", "POST")
h.Set("Access-Control-Allow-Headers", "Authorization, Content-Type")
h.Set("Access-Control-Max-Age", "600")
}
w.WriteHeader(http.StatusNoContent)
}
// upload takes a product (docs/products.md, POST /api/v1/uploads): checks the token and the files, stores the
// files, registers the revision with the hub, and records it as hosted once the hub has accepted it.
func (s *Server) upload(w http.ResponseWriter, r *http.Request) {
s.corsHeaders(w, r)
tok := bearer(r)
if tok == "" {
writeError(w, http.StatusUnauthorized, "bad_token", "An upload token is needed.")
return
}
claims, err := s.cfg.Verifier.Verify(r.Context(), tok, token.Upload)
if err != nil {
s.cfg.Logger.Printf("uploads: %v", err)
writeError(w, http.StatusUnauthorized, "bad_token", "The upload token isn't valid. Start the upload again.")
return
}
if claims.Catalog != s.cfg.CatalogID {
writeError(w, http.StatusUnauthorized, "bad_token", "The upload token is for another catalog.")
return
}
if claims.Product != "" && !hub.ValidProduct(claims.Product) {
writeError(w, http.StatusUnauthorized, "bad_token", "The upload token names no valid product.")
return
}
select {
case s.uploads <- struct{}{}:
defer func() { <-s.uploads }()
case <-r.Context().Done():
return
}
tmp, err := os.MkdirTemp(s.cfg.Store.TempDir(), "upload-*")
if err != nil {
s.cfg.Logger.Printf("uploads: %v", err)
writeError(w, http.StatusInternalServerError, "internal", "Something went wrong.")
return
}
defer os.RemoveAll(tmp)
r.Body = http.MaxBytesReader(w, r.Body, MaxBody)
notes, status, code, msg := readForm(r, filepath.Join(tmp, pack.ChknName))
if status != 0 {
if status == http.StatusInternalServerError {
s.cfg.Logger.Printf("uploads: %s", msg)
msg = "Something went wrong."
}
writeError(w, status, code, msg)
return
}
if claims.Product != "" && notes == "" {
writeError(w, http.StatusBadRequest, "notes_required", "Say what changed in this revision.")
return
}
p, rating, err := checkProduct(tmp)
if err != nil {
writeError(w, http.StatusBadRequest, "bad_files", "These product files can't be used: "+err.Error())
return
}
files := make([]store.File, len(p.Files))
for i, f := range p.Files {
hash, err := s.cfg.Store.PutFile(f.Data)
if err != nil {
s.cfg.Logger.Printf("uploads: storing %q: %v", f.Name, err)
writeError(w, http.StatusInternalServerError, "internal", "Something went wrong.")
return
}
files[i] = store.File{Name: f.Name, SHA256: hash, Size: int64(len(f.Data))}
}
index := p.Index
p = nil // the files' data can go before the hub answers
reg, err := s.cfg.Hub.RegisterRevision(r.Context(), hub.NewRevision{
UploadToken: tok, Files: files, Rating: rating, Parent: index.Parent, Name: strings.TrimSpace(index.Name), Notes: notes,
})
if err != nil {
var refused *hub.Error
if errors.As(err, &refused) {
s.cfg.Logger.Printf("uploads: %v", err)
code, msg := refused.Code, refused.Message
if code == "" {
code = "hub_refused"
}
if msg == "" {
msg = fmt.Sprintf("T5DE's hub refused the upload (%d).", refused.Status)
}
writeError(w, http.StatusBadGateway, code, msg)
return
}
s.cfg.Logger.Printf("uploads: registering with the hub: %v", err)
writeError(w, http.StatusBadGateway, "hub_unavailable", "T5DE's hub couldn't be reached. Try again later.")
return
}
if claims.Product != "" && reg.Product != claims.Product {
s.cfg.Logger.Printf("uploads: the hub registered product %s, but the token was for %s", reg.Product, claims.Product)
writeError(w, http.StatusBadGateway, "hub_failed", "T5DE's hub answered for another product.")
return
}
if err := s.cfg.Store.AddRevision(store.Revision{Product: reg.Product, Revision: reg.Revision, Rating: rating, Files: files}); err != nil {
s.cfg.Logger.Printf("uploads: recording %s/%d: %v", reg.Product, reg.Revision, err)
writeError(w, http.StatusInternalServerError, "internal", "Something went wrong.")
return
}
s.cfg.Logger.Printf("uploads: %s/%d (%s, %d files) from %s", reg.Product, reg.Revision, rating, len(files), claims.Subject)
writeJSON(w, http.StatusCreated, reg)
}
// readForm reads the upload's multipart form: the zip (part "file") into chkn, and the notes (part "notes").
// Other parts are skipped. A non-zero status is an error to answer.
func readForm(r *http.Request, chkn string) (notes string, status int, code, msg string) {
mt, _, err := mime.ParseMediaType(r.Header.Get("Content-Type"))
if err != nil || mt != "multipart/form-data" {
return "", http.StatusBadRequest, "bad_request", "Send the product as multipart/form-data."
}
mr, err := r.MultipartReader()
if err != nil {
return "", http.StatusBadRequest, "bad_request", "Send the product as multipart/form-data."
}
tooLarge := func(err error) bool {
var mbe *http.MaxBytesError
return errors.As(err, &mbe)
}
haveFile, haveNotes := false, false
for {
part, err := mr.NextPart()
if err == io.EOF {
break
}
if err != nil {
if tooLarge(err) {
return "", http.StatusRequestEntityTooLarge, "too_large", fmt.Sprintf("Uploads can be at most %d MiB.", MaxBody>>20)
}
return "", http.StatusBadRequest, "bad_request", "The upload is cut short or malformed."
}
switch part.FormName() {
case "file":
if haveFile {
return "", http.StatusBadRequest, "bad_request", "Send one file."
}
haveFile = true
f, err := os.Create(chkn)
if err != nil {
return "", http.StatusInternalServerError, "internal", err.Error()
}
_, err = io.Copy(f, part)
if cerr := f.Close(); err == nil && cerr != nil {
return "", http.StatusInternalServerError, "internal", cerr.Error()
}
if err != nil {
if tooLarge(err) {
return "", http.StatusRequestEntityTooLarge, "too_large", fmt.Sprintf("Uploads can be at most %d MiB.", MaxBody>>20)
}
return "", http.StatusBadRequest, "bad_request", "The upload is cut short or malformed."
}
case "notes":
if haveNotes {
return "", http.StatusBadRequest, "bad_request", "Send the notes once."
}
haveNotes = true
data, err := io.ReadAll(io.LimitReader(part, MaxNotes+1))
if err != nil {
if tooLarge(err) {
return "", http.StatusRequestEntityTooLarge, "too_large", fmt.Sprintf("Uploads can be at most %d MiB.", MaxBody>>20)
}
return "", http.StatusBadRequest, "bad_request", "The upload is cut short or malformed."
}
if len(data) > MaxNotes {
return "", http.StatusBadRequest, "bad_request", fmt.Sprintf("The notes can be at most %d KiB.", MaxNotes>>10)
}
if !utf8.Valid(data) {
return "", http.StatusBadRequest, "bad_request", "The notes aren't valid UTF-8."
}
notes = strings.TrimSpace(string(data))
default:
if _, err := io.Copy(io.Discard, part); err != nil {
if tooLarge(err) {
return "", http.StatusRequestEntityTooLarge, "too_large", fmt.Sprintf("Uploads can be at most %d MiB.", MaxBody>>20)
}
return "", http.StatusBadRequest, "bad_request", "The upload is cut short or malformed."
}
}
part.Close()
}
if !haveFile {
return "", http.StatusBadRequest, "bad_request", "No file: send the product's .chkn as \"file\"."
}
return notes, 0, "", ""
}
// ratings maps index.xml's ProductRating to the hub's ratings.
var ratings = map[string]string{"GA": store.General, "AP": store.Adult}
// checkProduct checks the zip dir/product.chkn by the pack rules (t5de-api's pack package): it reads the rating
// from its index.xml, writes a manifest with that rating beside it, and loads dir as a pack. It returns the pack
// and its rating.
func checkProduct(dir string) (*pack.Pack, string, error) {
index, err := readIndex(filepath.Join(dir, pack.ChknName))
if err != nil {
return nil, "", err
}
rating := ratings[index.Rating]
manifest, err := json.Marshal(pack.Manifest{Format: pack.Format, ID: "upload", Revision: 1, Name: "upload", Rating: rating})
if err != nil {
return nil, "", err
}
if err := os.WriteFile(filepath.Join(dir, pack.ManifestName), manifest, 0o644); err != nil {
return nil, "", err
}
p, err := pack.Load(dir)
if err != nil {
return nil, "", err
}
return p, rating, nil
}
// readIndex reads index.xml from the zip at path, its size counted while reading.
func readIndex(path string) (*pack.Index, error) {
z, err := zip.OpenReader(path)
if err != nil {
return nil, errors.New("not a zip file")
}
defer z.Close()
for _, f := range z.File {
if f.Name != pack.IndexName {
continue
}
rc, err := f.Open()
if err != nil {
return nil, fmt.Errorf("%s: %v", pack.IndexName, err)
}
data, err := io.ReadAll(io.LimitReader(rc, pack.MaxFileSize+1))
rc.Close()
if err != nil {
return nil, fmt.Errorf("%s: %v", pack.IndexName, err)
}
if len(data) > pack.MaxFileSize {
return nil, fmt.Errorf("%s: over %d MiB", pack.IndexName, pack.MaxFileSize>>20)
}
index, err := pack.ParseIndex(data)
if err != nil {
return nil, fmt.Errorf("%s: %v", pack.IndexName, err)
}
return index, nil
}
return nil, fmt.Errorf("no %s at the top of the zip", pack.IndexName)
}