package feedback import ( "archive/zip" "bytes" "context" "crypto/aes" "crypto/cipher" "crypto/hmac" "crypto/sha256" "encoding/hex" "encoding/json" "errors" "io" "mime/multipart" "net/http" "os" "path" "path/filepath" "regexp" "strings" "time" "ymhut-box/server/unified-management/internal/config" "ymhut-box/server/unified-management/internal/db" ) const PackageMagic = "YMHUTFB1" const ( ErrorTooLarge = "TOO_LARGE" ErrorMissingField = "MISSING_FIELD" ErrorInvalidPayload = "INVALID_PAYLOAD" ErrorInvalidTimestamp = "INVALID_TIMESTAMP" ErrorInvalidSignature = "INVALID_SIGNATURE" ErrorInvalidPackage = "INVALID_PACKAGE" ErrorInvalidEncryptedPackage = "INVALID_ENCRYPTED_PACKAGE" ErrorDecryptFailed = "DECRYPT_FAILED" ErrorHashMismatch = "HASH_MISMATCH" ErrorServerConfig = "SERVER_CONFIG" ) var feedbackCodePattern = regexp.MustCompile(`^FB-[0-9]{8}-[A-F0-9]{6}$`) type requestContextKey string const duplicateContextKey requestContextKey = "ymhut.feedback.duplicate" type Service struct { cfg *config.Config store *db.Store } type submissionPayload struct { FeedbackCode string `json:"feedbackCode"` Title string `json:"title"` Type string `json:"type"` Severity string `json:"severity"` Contact string `json:"contact"` BodyLength int `json:"bodyLength"` PackageEncrypted bool `json:"packageEncrypted"` Encryption string `json:"encryption"` PackageBytes int64 `json:"packageBytes"` PackageSha256 string `json:"packageSha256"` PlainPackageBytes int64 `json:"plainPackageBytes"` PlainPackageSha256 string `json:"plainPackageSha256"` CreatedAt json.RawMessage `json:"createdAt"` } type packageInfo struct { Request map[string]any Summary string Files []string } func NewService(cfg *config.Config, store *db.Store) *Service { return &Service{cfg: cfg, store: store} } func (s *Service) Submit(r *http.Request) (db.Feedback, error) { contentType := r.Header.Get("Content-Type") if strings.Contains(contentType, "multipart/form-data") { if item, err := s.submitMultipart(r); err == nil { return item, nil } else if hasSignedFields(r) || !strings.Contains(strings.ToLower(err.Error()), "signed multipart fields are required") { return db.Feedback{}, err } } return s.submitSimple(r) } func (s *Service) submitSimple(r *http.Request) (db.Feedback, error) { if strings.Contains(r.Header.Get("Content-Type"), "application/json") { var payload map[string]any if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { return db.Feedback{}, err } item := db.Feedback{ Code: db.NewFeedbackCode(), Title: value(payload, "title", "客户端反馈"), Type: value(payload, "type", "issue"), Severity: value(payload, "severity", "normal"), Contact: value(payload, "contact", ""), Body: value(payload, "body", value(payload, "message", "")), Status: "new", RemoteAddr: r.RemoteAddr, } if strings.TrimSpace(item.Body) == "" { item.Body = "No feedback body provided." } return item, s.store.InsertFeedback(item) } if err := r.ParseMultipartForm(32 << 20); err != nil { _ = r.ParseForm() } item := db.Feedback{ Code: db.NewFeedbackCode(), Title: firstNonEmpty(r.FormValue("title"), r.FormValue("subject"), "客户端反馈"), Type: firstNonEmpty(r.FormValue("type"), r.FormValue("category"), "issue"), Severity: firstNonEmpty(r.FormValue("severity"), r.FormValue("priority"), "normal"), Contact: firstNonEmpty(r.FormValue("contact"), r.FormValue("email")), Body: firstNonEmpty(r.FormValue("body"), r.FormValue("message"), r.FormValue("description")), Status: "new", RemoteAddr: r.RemoteAddr, } if strings.TrimSpace(item.Body) == "" { item.Body = "No feedback body provided." } return item, s.store.InsertFeedback(item) } func (s *Service) submitMultipart(r *http.Request) (db.Feedback, error) { if s.cfg.MaxRequestBytes > 0 { if r.ContentLength > s.cfg.MaxRequestBytes { return db.Feedback{}, errors.New("request is too large") } r.Body = http.MaxBytesReader(nilResponseWriter{}, r.Body, s.cfg.MaxRequestBytes) } if err := r.ParseMultipartForm(s.cfg.MaxRequestBytes); err != nil { return db.Feedback{}, err } payloadText := strings.TrimSpace(r.FormValue("payload")) timestamp := strings.TrimSpace(r.FormValue("timestamp")) nonce := strings.TrimSpace(r.FormValue("nonce")) packageSha256 := strings.ToLower(strings.TrimSpace(r.FormValue("packageSha256"))) signature := strings.ToLower(strings.TrimSpace(r.FormValue("signature"))) if payloadText == "" || timestamp == "" || nonce == "" || packageSha256 == "" || signature == "" { return db.Feedback{}, errors.New("signed multipart fields are required") } if !validTimestamp(timestamp, s.cfg.TimestampWindowSeconds) { return db.Feedback{}, errors.New("timestamp outside accepted window") } if !isHexSHA256(packageSha256) { return db.Feedback{}, errors.New("invalid package hash") } expected := SignWithKey(s.cfg.ClientSignatureKey, timestamp, nonce, packageSha256, payloadText) if !hmac.Equal([]byte(expected), []byte(signature)) { return db.Feedback{}, errors.New("invalid request signature") } var payload submissionPayload if err := json.Unmarshal([]byte(payloadText), &payload); err != nil { return db.Feedback{}, err } if err := validatePayload(payload); err != nil { return db.Feedback{}, err } code := NormalizeCode(payload.FeedbackCode) if code == "" { code = db.NewFeedbackCode() } if existing, err := s.store.GetFeedback(code); err == nil { setDuplicateSubmission(r, true) return existing, nil } file, _, err := r.FormFile("package") if err != nil { return db.Feedback{}, errors.New("missing package file") } defer file.Close() data, err := readUploadedPackage(file, s.cfg.MaxPackageBytes) if err != nil { return db.Feedback{}, err } if !bytes.HasPrefix(data, []byte(PackageMagic)) { return db.Feedback{}, errors.New("encrypted package format is invalid") } if !hmac.Equal([]byte(sha256Hex(data)), []byte(packageSha256)) { return db.Feedback{}, errors.New("package hash mismatch") } plain, err := DecryptPackage(data, s.cfg.PackageEncryptionKey) if err != nil { return db.Feedback{}, err } if !isZipBytes(plain) { return db.Feedback{}, errors.New("decrypted package is not a zip") } if payload.PlainPackageSha256 != "" && isHexSHA256(payload.PlainPackageSha256) { if !hmac.Equal([]byte(sha256Hex(plain)), []byte(strings.ToLower(payload.PlainPackageSha256))) { return db.Feedback{}, errors.New("decrypted package hash mismatch") } } info, err := ReadFeedbackPackageWithGuard(plain, s.cfg.UploadGuard) if err != nil { return db.Feedback{}, err } dir := filepath.Join(s.cfg.StorageDir, "feedback") if err := os.MkdirAll(dir, 0o750); err != nil { return db.Feedback{}, err } encryptedPath := filepath.Join(dir, code+".ymfb") packagePath := filepath.Join(dir, code+".zip") if err := os.WriteFile(encryptedPath, data, 0o640); err != nil { return db.Feedback{}, err } if err := os.WriteFile(packagePath, plain, 0o640); err != nil { return db.Feedback{}, err } item := buildRecord(code, payload, info, encryptedPath, packagePath, packageSha256, strings.ToLower(payload.PlainPackageSha256), r.RemoteAddr) setDuplicateSubmission(r, false) return item, s.store.InsertFeedback(item) } func DuplicateSubmission(r *http.Request) bool { duplicate, _ := r.Context().Value(duplicateContextKey).(bool) return duplicate } func setDuplicateSubmission(r *http.Request, duplicate bool) { *r = *r.WithContext(context.WithValue(r.Context(), duplicateContextKey, duplicate)) } func LegacyError(err error) (string, int) { if err == nil { return "", http.StatusOK } lower := strings.ToLower(err.Error()) switch { case strings.Contains(lower, "too large"): return ErrorTooLarge, http.StatusRequestEntityTooLarge case strings.Contains(lower, "signed multipart fields") || strings.Contains(lower, "missing package"): return ErrorMissingField, http.StatusBadRequest case strings.Contains(lower, "timestamp outside"): return ErrorInvalidTimestamp, http.StatusBadRequest case strings.Contains(lower, "invalid request signature"): return ErrorInvalidSignature, http.StatusUnauthorized case strings.Contains(lower, "hash mismatch") || strings.Contains(lower, "invalid package hash"): return ErrorHashMismatch, http.StatusBadRequest case strings.Contains(lower, "encrypted package format") || strings.Contains(lower, "encrypted package is required"): return ErrorInvalidEncryptedPackage, http.StatusBadRequest case strings.Contains(lower, "message authentication failed") || strings.Contains(lower, "decrypt"): return ErrorDecryptFailed, http.StatusBadRequest case strings.Contains(lower, "payload") || strings.Contains(lower, "json"): return ErrorInvalidPayload, http.StatusBadRequest case strings.Contains(lower, "zip") || strings.Contains(lower, "package"): return ErrorInvalidPackage, http.StatusBadRequest default: return ErrorServerConfig, http.StatusBadRequest } } func hasSignedFields(r *http.Request) bool { if r.MultipartForm == nil { return false } for _, key := range []string{"payload", "timestamp", "nonce", "packageSha256", "signature"} { if strings.TrimSpace(r.FormValue(key)) == "" { return false } } return true } func NormalizeCode(code string) string { code = strings.ToUpper(strings.TrimSpace(code)) if feedbackCodePattern.MatchString(code) { return code } return "" } func SignWithKey(key, timestamp, nonce, packageSha256, payload string) string { material := timestamp + "\n" + nonce + "\n" + packageSha256 + "\n" + payload mac := hmac.New(sha256.New, []byte(key)) _, _ = mac.Write([]byte(material)) return hex.EncodeToString(mac.Sum(nil)) } func validTimestamp(value string, windowSeconds int64) bool { if !regexp.MustCompile(`^[0-9]{10,}$`).MatchString(value) { return false } seconds, err := time.ParseDuration(value + "s") if err != nil { return false } delta := time.Now().Unix() - int64(seconds.Seconds()) if delta < 0 { delta = -delta } return delta <= windowSeconds } func validatePayload(payload submissionPayload) error { if payload.Title == "" || payload.Type == "" || payload.Severity == "" { return errors.New("payload title, type and severity are required") } if payload.PackageBytes <= 0 || payload.PackageSha256 == "" || payload.PlainPackageSha256 == "" { return errors.New("payload package hashes are required") } if !payload.PackageEncrypted || payload.Encryption != PackageMagic { return errors.New("encrypted package is required") } return nil } func readUploadedPackage(file multipart.File, maxBytes int64) ([]byte, error) { limit := maxBytes + 1 if limit <= 1 { limit = 10*1024*1024 + 1 } data, err := io.ReadAll(io.LimitReader(file, limit)) if err != nil { return nil, err } if maxBytes > 0 && int64(len(data)) > maxBytes { return nil, errors.New("feedback package is too large") } return data, nil } func DecryptPackage(data []byte, keyMaterial string) ([]byte, error) { if len(data) < len(PackageMagic)+12+16 || !bytes.HasPrefix(data, []byte(PackageMagic)) { return nil, errors.New("encrypted package format is invalid") } if keyMaterial == "" { keyMaterial = "ymhut-box-feedback-package-v1" } key := sha256.Sum256([]byte(keyMaterial)) block, err := aes.NewCipher(key[:]) if err != nil { return nil, err } gcm, err := cipher.NewGCM(block) if err != nil { return nil, err } offset := len(PackageMagic) nonce := data[offset : offset+12] offset += 12 tag := data[offset : offset+16] offset += 16 ciphertext := data[offset:] combined := append(append([]byte{}, ciphertext...), tag...) return gcm.Open(nil, nonce, combined, []byte(PackageMagic)) } func ReadFeedbackPackageWithGuard(plain []byte, guard config.UploadGuardConfig) (packageInfo, error) { reader, err := zip.NewReader(bytes.NewReader(plain), int64(len(plain))) if err != nil { return packageInfo{}, err } files := []string{} texts := map[string]string{} var total uint64 for _, entry := range reader.File { if entry.FileInfo().IsDir() { continue } cleanName, err := safeZipName(entry.Name) if err != nil { return packageInfo{}, err } if len(files)+1 > guard.MaxZipFiles { return packageInfo{}, errors.New("zip contains too many files") } if entry.UncompressedSize64 > uint64(guard.MaxSingleFileBytes) { return packageInfo{}, errors.New("zip entry is too large") } total += entry.UncompressedSize64 if total > uint64(guard.MaxDecompressedBytes) { return packageInfo{}, errors.New("zip decompressed size is too large") } if entry.CompressedSize64 == 0 && entry.UncompressedSize64 > 0 { return packageInfo{}, errors.New("zip entry has invalid compression metadata") } if entry.CompressedSize64 > 0 && float64(entry.UncompressedSize64)/float64(entry.CompressedSize64) > guard.MaxCompressionRatio { return packageInfo{}, errors.New("zip compression ratio is suspicious") } files = append(files, cleanName) if cleanName != "feedback.json" && cleanName != "summary.txt" { if !guard.AllowUnexpectedZipFiles && !strings.HasPrefix(cleanName, "attachments/") { return packageInfo{}, errors.New("zip contains unexpected file") } continue } text, err := readZipText(entry, guard.MaxReadableTextBytes) if err != nil { return packageInfo{}, err } texts[cleanName] = text } request := map[string]any{} if raw := texts["feedback.json"]; raw != "" { var parsed map[string]any if err := json.Unmarshal([]byte(raw), &parsed); err == nil { if nested, ok := parsed["request"].(map[string]any); ok { request = nested } else { request = parsed } } } if len(request) == 0 && texts["feedback.json"] == "" { return packageInfo{}, errors.New("feedback.json is missing") } return packageInfo{Request: request, Summary: texts["summary.txt"], Files: files}, nil } func safeZipName(name string) (string, error) { name = strings.ReplaceAll(name, "\\", "/") name = strings.TrimSpace(name) if name == "" || strings.Contains(name, "\x00") || strings.HasPrefix(name, "/") { return "", errors.New("unsafe zip entry name") } clean := path.Clean(name) if clean == "." || clean == ".." || strings.HasPrefix(clean, "../") { return "", errors.New("unsafe zip entry path") } return clean, nil } func readZipText(entry *zip.File, maxBytes int64) (string, error) { if int64(entry.UncompressedSize64) > maxBytes { return "", nil } reader, err := entry.Open() if err != nil { return "", err } defer reader.Close() data, err := io.ReadAll(io.LimitReader(reader, maxBytes+1)) if err != nil { return "", err } if int64(len(data)) > maxBytes { return "", nil } return string(data), nil } func buildRecord(code string, payload submissionPayload, info packageInfo, encryptedPath, packagePath, packageSha256, plainPackageSha256, remoteAddr string) db.Feedback { title := firstNonEmpty(textFromMap(info.Request, "title"), payload.Title, "未命名反馈") typ := firstNonEmpty(textFromMap(info.Request, "type"), payload.Type, "issue") severity := firstNonEmpty(textFromMap(info.Request, "severity"), payload.Severity, "normal") contact := firstNonEmpty(textFromMap(info.Request, "contact"), payload.Contact) body := firstNonEmpty(textFromMap(info.Request, "body"), info.Summary) return db.Feedback{ Code: code, Title: title, Type: typ, Severity: severity, Contact: contact, Body: body, Status: "new", StatusDetail: "反馈已接收,等待后台处理。", SourceChannel: "winui", PackagePath: packagePath, EncryptedPackagePath: encryptedPath, PackageSha256: packageSha256, PlainPackageSha256: plainPackageSha256, SummaryText: info.Summary, IncludedFiles: strings.Join(info.Files, ", "), RemoteAddr: remoteAddr, } } func firstNonEmpty(values ...string) string { for _, value := range values { if strings.TrimSpace(value) != "" { return strings.TrimSpace(value) } } return "" } func value(payload map[string]any, key, fallback string) string { if raw, ok := payload[key].(string); ok && strings.TrimSpace(raw) != "" { return strings.TrimSpace(raw) } return fallback } func textFromMap(values map[string]any, key string) string { if value, ok := values[key].(string); ok { return value } return "" } func isHexSHA256(value string) bool { value = strings.ToLower(strings.TrimSpace(value)) if len(value) != 64 { return false } _, err := hex.DecodeString(value) return err == nil } func sha256Hex(data []byte) string { sum := sha256.Sum256(data) return hex.EncodeToString(sum[:]) } func isZipBytes(data []byte) bool { return bytes.HasPrefix(data, []byte("PK\x03\x04")) || bytes.HasPrefix(data, []byte("PK\x05\x06")) || bytes.HasPrefix(data, []byte("PK\x07\x08")) } type nilResponseWriter struct{} func (nilResponseWriter) Header() http.Header { return http.Header{} } func (nilResponseWriter) Write([]byte) (int, error) { return 0, nil } func (nilResponseWriter) WriteHeader(int) {}