308 lines
10 KiB
Go
308 lines
10 KiB
Go
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"
|
|
"ymhut-box/server/unified-management/internal/releases"
|
|
)
|
|
|
|
func (r *router) handleAdminReleases(w http.ResponseWriter, req *http.Request) {
|
|
path := cleanPath(req.URL.Path)
|
|
if strings.HasPrefix(path, "/api/admin/releases/notices") {
|
|
r.handleAdminReleaseNotices(w, req)
|
|
return
|
|
}
|
|
switch path {
|
|
case "/api/admin/releases/packages":
|
|
if req.Method != http.MethodPost {
|
|
writeError(w, http.StatusMethodNotAllowed, "METHOD_NOT_ALLOWED", errors.New("POST required"))
|
|
return
|
|
}
|
|
uploaded, opts, cleanup, err := r.readReleasePackageUpload(w, req)
|
|
if err != nil {
|
|
writeReleaseUploadError(w, err)
|
|
return
|
|
}
|
|
defer cleanup()
|
|
pkg, err := r.releases.SavePreparedPackage(req, uploaded, opts, "admin")
|
|
if err != nil {
|
|
writeReleaseUploadError(w, err)
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "package": pkg})
|
|
case "/api/admin/releases":
|
|
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "manifest": r.releases.Manifest(req)})
|
|
case "/api/admin/releases/legacy-preview":
|
|
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "updateInfo": r.releases.LegacyUpdateInfo(req), "toolStatus": r.releases.StaticJSON("tool-status.json")})
|
|
default:
|
|
http.NotFound(w, req)
|
|
}
|
|
}
|
|
|
|
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"))
|
|
return
|
|
}
|
|
path := cleanPath(req.URL.Path)
|
|
if req.Method == http.MethodPost && path == "/api/admin/releases/notices/import" {
|
|
if err := r.notices.Import(req.Context()); err != nil {
|
|
writeError(w, http.StatusInternalServerError, "NOTICE_IMPORT_FAILED", err)
|
|
return
|
|
}
|
|
items, _ := r.notices.List(100)
|
|
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "items": items})
|
|
return
|
|
}
|
|
if req.Method == http.MethodGet && path == "/api/admin/releases/notices" {
|
|
items, err := r.notices.List(100)
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "NOTICE_LIST_FAILED", err)
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "items": items})
|
|
return
|
|
}
|
|
rest := strings.TrimPrefix(path, "/api/admin/releases/notices/")
|
|
if rest == "" || rest == path {
|
|
http.NotFound(w, req)
|
|
return
|
|
}
|
|
parts := strings.Split(rest, "/")
|
|
version := parts[0]
|
|
if req.Method == http.MethodGet && len(parts) == 1 {
|
|
doc, err := r.notices.Get(version)
|
|
if err != nil {
|
|
writeError(w, http.StatusNotFound, "NOTICE_NOT_FOUND", err)
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "document": doc})
|
|
return
|
|
}
|
|
if req.Method == http.MethodPut && len(parts) == 1 {
|
|
var body notices.SaveRequest
|
|
if err := json.NewDecoder(req.Body).Decode(&body); err != nil {
|
|
writeError(w, http.StatusBadRequest, "INVALID_PAYLOAD", err)
|
|
return
|
|
}
|
|
doc, err := r.notices.Save(req.Context(), version, body, "admin")
|
|
if err != nil {
|
|
writeError(w, http.StatusBadRequest, "NOTICE_SAVE_FAILED", err)
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "document": doc})
|
|
return
|
|
}
|
|
if req.Method == http.MethodPost && len(parts) == 2 && parts[1] == "validate" {
|
|
var body notices.SaveRequest
|
|
if err := json.NewDecoder(req.Body).Decode(&body); err != nil {
|
|
writeError(w, http.StatusBadRequest, "INVALID_PAYLOAD", err)
|
|
return
|
|
}
|
|
doc, err := r.notices.Validate(req.Context(), version, body)
|
|
if err != nil {
|
|
writeError(w, http.StatusBadRequest, "NOTICE_VALIDATE_FAILED", err)
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "document": doc})
|
|
return
|
|
}
|
|
if req.Method == http.MethodPost && len(parts) == 2 && parts[1] == "restore" {
|
|
var body struct {
|
|
RevisionID int64 `json:"revisionId"`
|
|
}
|
|
if err := json.NewDecoder(req.Body).Decode(&body); err != nil || body.RevisionID <= 0 {
|
|
writeError(w, http.StatusBadRequest, "INVALID_PAYLOAD", errors.New("revisionId is required"))
|
|
return
|
|
}
|
|
doc, err := r.notices.Restore(req.Context(), version, body.RevisionID, "admin")
|
|
if err != nil {
|
|
writeError(w, http.StatusBadRequest, "NOTICE_RESTORE_FAILED", err)
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "document": doc})
|
|
return
|
|
}
|
|
http.NotFound(w, req)
|
|
}
|