package app import ( "crypto/sha256" "encoding/hex" "errors" "fmt" "io" "net/http" "net/url" "strings" ) const uploadFormOverhead = 1 << 20 type byteCounter int64 func (counter *byteCounter) Write(value []byte) (int, error) { *counter += byteCounter(len(value)) return len(value), nil } func (s *Server) handleUploadPackage(w http.ResponseWriter, r *http.Request) { tech, _ := s.currentTechnician(r) if r.ContentLength > s.cfg.MaxUploadBytes+uploadFormOverhead { writeJSON(w, http.StatusRequestEntityTooLarge, map[string]string{"error": "package exceeds the upload limit"}) return } r.Body = http.MaxBytesReader(w, r.Body, s.cfg.MaxUploadBytes+uploadFormOverhead) reader, err := r.MultipartReader() if err != nil { writeJSON(w, http.StatusBadRequest, map[string]string{"error": "multipart upload is required"}) return } fields := make(map[string]string) for { part, err := reader.NextPart() if errors.Is(err, io.EOF) { writeJSON(w, http.StatusBadRequest, map[string]string{"error": "package file is required"}) return } if err != nil { writeJSON(w, http.StatusBadRequest, map[string]string{"error": "unable to read upload"}) return } if part.FileName() == "" { value, err := io.ReadAll(io.LimitReader(part, 4097)) _ = part.Close() if err != nil || len(value) > 4096 { writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid upload field"}) return } fields[part.FormName()] = strings.TrimSpace(string(value)) continue } if part.FormName() != "package_file" { _ = part.Close() writeJSON(w, http.StatusBadRequest, map[string]string{"error": "unexpected uploaded file"}) return } fileName := part.FileName() if !validRegistrySegment(fileName, 255) { _ = part.Close() writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid package filename"}) return } if err := validateUploadFields(fields); err != nil { _ = part.Close() writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) return } checksum, size, status, err := s.streamPackageToGitea(r, fields, fileName, part) _ = part.Close() if err != nil { var maxBytesError *http.MaxBytesError if errors.As(err, &maxBytesError) { status = http.StatusRequestEntityTooLarge } _ = s.audit(r.Context(), "package_upload_failed", tech.Login, nil, "", fields["slug"], s.clientIP(r), err.Error()) writeJSON(w, status, map[string]string{"error": err.Error()}) return } enabled := fields["enabled"] == "1" if err := s.savePackage( r, fields["slug"], fields["display_name"], fields["package_name"], fields["package_version"], fileName, checksum, enabled, ); err != nil { _ = s.audit(r.Context(), "package_upload_failed", tech.Login, nil, "", fields["slug"], s.clientIP(r), err.Error()) writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "package uploaded but catalog update failed"}) return } _ = s.audit( r.Context(), "package_uploaded", tech.Login, nil, "", fields["slug"], s.clientIP(r), fmt.Sprintf( "version=%s filename=%s bytes=%d sha256=%s enabled=%t", fields["package_version"], fileName, size, checksum, enabled, ), ) writeJSON(w, http.StatusCreated, map[string]any{ "status": "uploaded", "sha256": checksum, "bytes": size, "filename": fileName, }) return } } func validateUploadFields(fields map[string]string) error { if !validSlug(fields["slug"]) { return errors.New("invalid package slug") } if fields["display_name"] == "" || len(fields["display_name"]) > 255 { return errors.New("invalid display name") } if !validRegistrySegment(fields["package_name"], 255) { return errors.New("invalid registry package name") } if !validRegistrySegment(fields["package_version"], 100) { return errors.New("invalid package version") } return nil } func validRegistrySegment(value string, maxLength int) bool { if value == "" || len(value) > maxLength { return false } for _, character := range value { if (character < 'a' || character > 'z') && (character < 'A' || character > 'Z') && (character < '0' || character > '9') && character != '.' && character != '-' && character != '+' && character != '_' { return false } } return true } func (s *Server) streamPackageToGitea( r *http.Request, fields map[string]string, fileName string, source io.Reader, ) (string, int64, int, error) { registryURL := joinURL( s.cfg.GiteaURL, fmt.Sprintf( "/api/packages/%s/generic/%s/%s/%s", url.PathEscape(s.cfg.GiteaPackageOwner), url.PathEscape(fields["package_name"]), url.PathEscape(fields["package_version"]), url.PathEscape(fileName), ), ) hasher := sha256.New() var size byteCounter body := io.TeeReader(source, io.MultiWriter(hasher, &size)) request, err := http.NewRequestWithContext(r.Context(), http.MethodPut, registryURL, body) if err != nil { return "", 0, http.StatusInternalServerError, errors.New("unable to prepare registry upload") } request.Header.Set("Content-Type", "application/octet-stream") request.SetBasicAuth(s.cfg.GiteaWriteUser, s.cfg.GiteaWriteToken) response, err := s.packageClient.Do(request) if err != nil { var maxBytesError *http.MaxBytesError if errors.As(err, &maxBytesError) { return "", int64(size), http.StatusRequestEntityTooLarge, maxBytesError } return "", int64(size), http.StatusBadGateway, errors.New("package registry upload failed") } defer response.Body.Close() _, _ = io.Copy(io.Discard, io.LimitReader(response.Body, 8192)) switch response.StatusCode { case http.StatusCreated: case http.StatusConflict: return "", int64(size), http.StatusConflict, errors.New("that package version and filename already exist") case http.StatusUnauthorized, http.StatusForbidden: return "", int64(size), http.StatusBadGateway, errors.New("package registry rejected the publisher credentials") default: return "", int64(size), http.StatusBadGateway, fmt.Errorf("package registry returned %s", response.Status) } return hex.EncodeToString(hasher.Sum(nil)), int64(size), http.StatusCreated, nil }