Update application UI and functionality

This commit is contained in:
2026-07-26 16:20:36 +08:00
parent b9aff58f32
commit 97ea6fb7aa
48 changed files with 2790 additions and 628 deletions
@@ -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"))