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