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) }