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]>
300 lines
10 KiB
Go
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)
|
|
}
|