203 lines
5.9 KiB
Go
203 lines
5.9 KiB
Go
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 {
|
|
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
|
|
}
|