Update application UI and functionality
This commit is contained in:
@@ -1,9 +1,15 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"ymhut-box/server/unified-management/internal/notices"
|
||||
@@ -22,27 +28,15 @@ func (r *router) handleAdminReleases(w http.ResponseWriter, req *http.Request) {
|
||||
writeError(w, http.StatusMethodNotAllowed, "METHOD_NOT_ALLOWED", errors.New("POST required"))
|
||||
return
|
||||
}
|
||||
if err := req.ParseMultipartForm(256 << 20); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "INVALID_UPLOAD", err)
|
||||
uploaded, opts, cleanup, err := r.readReleasePackageUpload(w, req)
|
||||
if err != nil {
|
||||
writeReleaseUploadError(w, err)
|
||||
return
|
||||
}
|
||||
file, header, err := req.FormFile("file")
|
||||
defer cleanup()
|
||||
pkg, err := r.releases.SavePreparedPackage(req, uploaded, opts, "admin")
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "FILE_REQUIRED", err)
|
||||
return
|
||||
}
|
||||
defer file.Close()
|
||||
pkg, err := r.releases.SaveUploadedPackage(req, file, releases.UploadOptions{
|
||||
FileName: firstNonEmpty(req.FormValue("fileName"), header.Filename),
|
||||
Version: req.FormValue("version"),
|
||||
Platform: req.FormValue("platform"),
|
||||
Arch: req.FormValue("arch"),
|
||||
Channel: req.FormValue("channel"),
|
||||
Notes: req.FormValue("notes"),
|
||||
UpdateManifest: req.FormValue("updateManifest") == "true" || req.FormValue("updateManifest") == "1",
|
||||
}, "admin")
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "PACKAGE_UPLOAD_FAILED", err)
|
||||
writeReleaseUploadError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "package": pkg})
|
||||
@@ -55,6 +49,176 @@ func (r *router) handleAdminReleases(w http.ResponseWriter, req *http.Request) {
|
||||
}
|
||||
}
|
||||
|
||||
const releaseUploadFieldMaxBytes = 1 << 20
|
||||
|
||||
type releaseUploadError struct {
|
||||
status int
|
||||
code string
|
||||
err error
|
||||
}
|
||||
|
||||
func (e releaseUploadError) Error() string { return e.err.Error() }
|
||||
|
||||
func newReleaseUploadError(status int, code string, err error) error {
|
||||
return releaseUploadError{status: status, code: code, err: err}
|
||||
}
|
||||
|
||||
func (r *router) readReleasePackageUpload(w http.ResponseWriter, req *http.Request) (releases.UploadedPackageFile, releases.UploadOptions, func(), error) {
|
||||
limit := r.releaseUploadLimit()
|
||||
req.Body = http.MaxBytesReader(w, req.Body, limit+(8<<20))
|
||||
reader, err := req.MultipartReader()
|
||||
if err != nil {
|
||||
return releases.UploadedPackageFile{}, releases.UploadOptions{}, func() {}, newReleaseUploadError(http.StatusBadRequest, "INVALID_UPLOAD", err)
|
||||
}
|
||||
if err := os.MkdirAll(r.cfg.DownloadsDir, 0o750); err != nil {
|
||||
return releases.UploadedPackageFile{}, releases.UploadOptions{}, func() {}, newReleaseUploadError(http.StatusInternalServerError, "UPLOAD_STORAGE_FAILED", err)
|
||||
}
|
||||
|
||||
fields := map[string]string{}
|
||||
uploadedName := ""
|
||||
uploaded := releases.UploadedPackageFile{}
|
||||
cleanup := func() {}
|
||||
for {
|
||||
part, err := reader.NextPart()
|
||||
if errors.Is(err, io.EOF) {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
cleanup()
|
||||
return releases.UploadedPackageFile{}, releases.UploadOptions{}, func() {}, classifyMultipartReadError(err)
|
||||
}
|
||||
name := part.FormName()
|
||||
if name == "" {
|
||||
_ = part.Close()
|
||||
continue
|
||||
}
|
||||
if name != "file" {
|
||||
value, err := readReleaseUploadField(part)
|
||||
_ = part.Close()
|
||||
if err != nil {
|
||||
cleanup()
|
||||
return releases.UploadedPackageFile{}, releases.UploadOptions{}, func() {}, err
|
||||
}
|
||||
fields[name] = value
|
||||
continue
|
||||
}
|
||||
if uploaded.TempPath != "" {
|
||||
_ = part.Close()
|
||||
cleanup()
|
||||
return releases.UploadedPackageFile{}, releases.UploadOptions{}, func() {}, newReleaseUploadError(http.StatusBadRequest, "INVALID_UPLOAD", errors.New("multiple file fields are not supported"))
|
||||
}
|
||||
uploadedName = part.FileName()
|
||||
prepared, err := r.streamReleaseUploadPart(part, limit)
|
||||
_ = part.Close()
|
||||
if err != nil {
|
||||
cleanup()
|
||||
return releases.UploadedPackageFile{}, releases.UploadOptions{}, func() {}, err
|
||||
}
|
||||
uploaded = prepared
|
||||
cleanup = func() { _ = os.Remove(prepared.TempPath) }
|
||||
}
|
||||
if uploaded.TempPath == "" {
|
||||
return releases.UploadedPackageFile{}, releases.UploadOptions{}, cleanup, newReleaseUploadError(http.StatusBadRequest, "FILE_REQUIRED", errors.New("file is required"))
|
||||
}
|
||||
opts := releases.UploadOptions{
|
||||
FileName: firstNonEmpty(fields["fileName"], uploadedName),
|
||||
Version: fields["version"],
|
||||
Platform: fields["platform"],
|
||||
Arch: fields["arch"],
|
||||
Channel: fields["channel"],
|
||||
Notes: fields["notes"],
|
||||
UpdateManifest: fields["updateManifest"] == "true" || fields["updateManifest"] == "1",
|
||||
}
|
||||
return uploaded, opts, cleanup, nil
|
||||
}
|
||||
|
||||
func (r *router) streamReleaseUploadPart(part *multipart.Part, limit int64) (releases.UploadedPackageFile, error) {
|
||||
tmp, err := os.CreateTemp(r.cfg.DownloadsDir, ".release-package-*.upload")
|
||||
if err != nil {
|
||||
return releases.UploadedPackageFile{}, newReleaseUploadError(http.StatusInternalServerError, "UPLOAD_STORAGE_FAILED", err)
|
||||
}
|
||||
tmpName := tmp.Name()
|
||||
removeOnError := true
|
||||
defer func() {
|
||||
if removeOnError {
|
||||
_ = os.Remove(tmpName)
|
||||
}
|
||||
}()
|
||||
hash := sha256.New()
|
||||
limited := &io.LimitedReader{R: part, N: limit + 1}
|
||||
written, err := io.Copy(tmp, io.TeeReader(limited, hash))
|
||||
if closeErr := tmp.Close(); err == nil {
|
||||
err = closeErr
|
||||
}
|
||||
if err != nil {
|
||||
return releases.UploadedPackageFile{}, classifyMultipartReadError(err)
|
||||
}
|
||||
if written > limit {
|
||||
return releases.UploadedPackageFile{}, newReleaseUploadError(http.StatusRequestEntityTooLarge, "PACKAGE_TOO_LARGE", fmt.Errorf("发布包超过上传上限 %s", formatReleaseUploadBytes(limit)))
|
||||
}
|
||||
if written <= 0 {
|
||||
return releases.UploadedPackageFile{}, newReleaseUploadError(http.StatusBadRequest, "PACKAGE_EMPTY", releases.ErrUploadedPackageEmpty)
|
||||
}
|
||||
removeOnError = false
|
||||
return releases.UploadedPackageFile{TempPath: tmpName, Size: written, SHA256: hex.EncodeToString(hash.Sum(nil))}, nil
|
||||
}
|
||||
|
||||
func readReleaseUploadField(part *multipart.Part) (string, error) {
|
||||
data, err := io.ReadAll(io.LimitReader(part, releaseUploadFieldMaxBytes+1))
|
||||
if err != nil {
|
||||
return "", classifyMultipartReadError(err)
|
||||
}
|
||||
if len(data) > releaseUploadFieldMaxBytes {
|
||||
return "", newReleaseUploadError(http.StatusBadRequest, "INVALID_UPLOAD", errors.New("form field is too large"))
|
||||
}
|
||||
return strings.TrimSpace(string(data)), nil
|
||||
}
|
||||
|
||||
func classifyMultipartReadError(err error) error {
|
||||
var maxErr *http.MaxBytesError
|
||||
if errors.As(err, &maxErr) {
|
||||
return newReleaseUploadError(http.StatusRequestEntityTooLarge, "PACKAGE_TOO_LARGE", errors.New("发布包或表单数据超过上传上限"))
|
||||
}
|
||||
return newReleaseUploadError(http.StatusBadRequest, "INVALID_UPLOAD", err)
|
||||
}
|
||||
|
||||
func (r *router) releaseUploadLimit() int64 {
|
||||
if r.cfg != nil && r.cfg.ReleaseUploadMaxBytes > 0 {
|
||||
return r.cfg.ReleaseUploadMaxBytes
|
||||
}
|
||||
return 1024 * 1024 * 1024
|
||||
}
|
||||
|
||||
func formatReleaseUploadBytes(value int64) string {
|
||||
const megabyte = 1024 * 1024
|
||||
if value < megabyte {
|
||||
return fmt.Sprintf("%d KB", (value+1023)/1024)
|
||||
}
|
||||
return fmt.Sprintf("%.1f MB", float64(value)/megabyte)
|
||||
}
|
||||
|
||||
func writeReleaseUploadError(w http.ResponseWriter, err error) {
|
||||
var uploadErr releaseUploadError
|
||||
if errors.As(err, &uploadErr) {
|
||||
writeError(w, uploadErr.status, uploadErr.code, uploadErr.err)
|
||||
return
|
||||
}
|
||||
status := http.StatusBadRequest
|
||||
code := "PACKAGE_UPLOAD_FAILED"
|
||||
if errors.Is(err, releases.ErrUploadedPackageManifestFailed) {
|
||||
status = http.StatusInternalServerError
|
||||
code = "MANIFEST_UPDATE_FAILED"
|
||||
} else if errors.Is(err, releases.ErrUploadedPackageStorageFailed) {
|
||||
status = http.StatusInternalServerError
|
||||
code = "UPLOAD_STORAGE_FAILED"
|
||||
} else if errors.Is(err, releases.ErrUploadedPackageEmpty) {
|
||||
code = "PACKAGE_EMPTY"
|
||||
} else if errors.Is(err, releases.ErrUploadedPackageMissing) {
|
||||
code = "FILE_REQUIRED"
|
||||
}
|
||||
writeError(w, status, code, err)
|
||||
}
|
||||
|
||||
func (r *router) handleAdminReleaseNotices(w http.ResponseWriter, req *http.Request) {
|
||||
if r.notices == nil {
|
||||
writeError(w, http.StatusNotFound, "NOTICES_DISABLED", errors.New("release notices are not configured"))
|
||||
|
||||
Reference in New Issue
Block a user