package feedback import ( "archive/zip" "bytes" "crypto/aes" "crypto/cipher" "crypto/hmac" "crypto/rand" "crypto/sha256" "encoding/hex" "encoding/json" "errors" "fmt" "io" "mime/multipart" "net/http" "os" "path" "path/filepath" "regexp" "strings" "time" "github.com/gin-gonic/gin" "ymhut-box/server/feedback-mailer/internal/config" "ymhut-box/server/feedback-mailer/internal/db" feedbackmail "ymhut-box/server/feedback-mailer/internal/mail" "ymhut-box/server/feedback-mailer/internal/webhook" ) const ( ErrorMethodNotAllowed = "METHOD_NOT_ALLOWED" 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" ErrorMailFailed = "MAIL_FAILED" ErrorServerConfig = "SERVER_CONFIG" ErrorNotFound = "NOT_FOUND" PackageMagic = "YMHUTFB1" ) var feedbackCodePattern = regexp.MustCompile(`^FB-[0-9]{8}-[A-F0-9]{6}$`) type Service struct { cfg *config.Config store *db.Store hooks *webhook.Dispatcher } type StatusPayload struct { OK bool `json:"ok"` Code string `json:"code"` Status string `json:"status"` StatusLabel string `json:"statusLabel"` StatusDetail string `json:"statusDetail"` Category string `json:"category"` Priority string `json:"priority"` HasReply bool `json:"hasReply"` Reply string `json:"reply"` ReceivedAt string `json:"receivedAt"` UpdatedAt string `json:"updatedAt"` MailSent bool `json:"mailSent"` Duplicate bool `json:"duplicate,omitempty"` } 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, hooks *webhook.Dispatcher) *Service { return &Service{cfg: cfg, store: store, hooks: hooks} } func (s *Service) HandleSubmission(c *gin.Context) { if c.Request.Method != http.MethodPost { Fail(c, ErrorMethodNotAllowed, http.StatusMethodNotAllowed, "POST required") return } if s.cfg.MaxRequestBytes > 0 { if c.Request.ContentLength > s.cfg.MaxRequestBytes { Fail(c, ErrorTooLarge, http.StatusRequestEntityTooLarge, "Request is too large") return } c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, s.cfg.MaxRequestBytes) } payloadText, ok := requireForm(c, "payload") if !ok { return } timestamp, ok := requireForm(c, "timestamp") if !ok { return } nonce, ok := requireForm(c, "nonce") if !ok { return } packageSha256, ok := requireForm(c, "packageSha256") if !ok { return } signature, ok := requireForm(c, "signature") if !ok { return } var payload submissionPayload if err := json.Unmarshal([]byte(payloadText), &payload); err != nil { Fail(c, ErrorInvalidPayload, http.StatusBadRequest, "Invalid payload JSON") return } if err := validatePayload(payloadText, payload); err != nil { Fail(c, errCode(err), http.StatusBadRequest, err.Error()) return } if !validTimestamp(timestamp, s.cfg.TimestampWindowSeconds) { Fail(c, ErrorInvalidTimestamp, http.StatusBadRequest, "Timestamp outside accepted window") return } packageSha256 = strings.ToLower(strings.TrimSpace(packageSha256)) signature = strings.ToLower(strings.TrimSpace(signature)) if !isHexSHA256(packageSha256) { Fail(c, ErrorHashMismatch, http.StatusBadRequest, "Invalid package hash") return } if s.cfg.ClientSignatureKey == "" { Fail(c, ErrorServerConfig, http.StatusInternalServerError, "Missing client signature key") return } expected := SignWithKey(s.cfg.ClientSignatureKey, timestamp, nonce, packageSha256, payloadText) if !hmac.Equal([]byte(expected), []byte(signature)) { Fail(c, ErrorInvalidSignature, http.StatusUnauthorized, "Invalid request signature") return } code := NormalizeCode(payload.FeedbackCode) if code == "" { code = s.generateCode() } existing, err := s.store.FetchStatus(code) if err != nil { Fail(c, ErrorServerConfig, http.StatusInternalServerError, "Server error") return } if existing != nil { response := StatusFromRow(*existing) response.Duplicate = true c.JSON(http.StatusOK, response) return } file, err := c.FormFile("package") if err != nil { Fail(c, ErrorMissingField, http.StatusBadRequest, "Missing package file") return } data, err := readUploadedPackage(file, s.cfg.MaxPackageBytes) if err != nil { if errors.Is(err, errUploadTooLarge) { Fail(c, ErrorTooLarge, http.StatusRequestEntityTooLarge, "Feedback package is too large") } else { Fail(c, ErrorInvalidPackage, http.StatusBadRequest, "Package upload failed") } return } if !bytes.HasPrefix(data, []byte(PackageMagic)) { Fail(c, ErrorInvalidEncryptedPackage, http.StatusBadRequest, "Encrypted package format is invalid") return } actual := sha256Hex(data) if !hmac.Equal([]byte(actual), []byte(packageSha256)) { Fail(c, ErrorHashMismatch, http.StatusBadRequest, "Package hash mismatch") return } encryptedPath := filepath.Join(s.cfg.StorageDir, code+".ymfb") packagePath := filepath.Join(s.cfg.StorageDir, code+".zip") if err := os.WriteFile(encryptedPath, data, 0o640); err != nil { Fail(c, ErrorInvalidPackage, http.StatusInternalServerError, "Unable to save package") return } plain, err := DecryptPackage(data, s.cfg.PackageEncryptionKey) if err != nil { Fail(c, ErrorDecryptFailed, http.StatusBadRequest, "Unable to decrypt package") return } if !isZipBytes(plain) { Fail(c, ErrorInvalidPackage, http.StatusBadRequest, "Decrypted package is not a zip file") return } if payload.PlainPackageSha256 != "" && isHexSHA256(payload.PlainPackageSha256) { if !hmac.Equal([]byte(sha256Hex(plain)), []byte(strings.ToLower(payload.PlainPackageSha256))) { Fail(c, ErrorHashMismatch, http.StatusBadRequest, "Decrypted package hash mismatch") return } } info, err := ReadFeedbackPackageWithGuard(plain, s.cfg.UploadGuard) if err != nil { Fail(c, ErrorInvalidPackage, http.StatusBadRequest, "Unable to read feedback package") return } if err := os.WriteFile(packagePath, plain, 0o640); err != nil { Fail(c, ErrorServerConfig, http.StatusInternalServerError, "Unable to write decrypted package") return } record := buildRecord(code, payload, info, encryptedPath, packagePath, packageSha256, strings.ToLower(payload.PlainPackageSha256), c.ClientIP()) if err := s.store.InsertFeedback(record); err != nil { Fail(c, ErrorServerConfig, http.StatusInternalServerError, "Unable to retain feedback") return } s.hooks.Dispatch("feedback.created", record) message, err := feedbackmail.BuildFeedbackMessage(s.cfg, record, packagePath) if err != nil { s.hooks.Dispatch("mail.failed", gin.H{"feedbackCode": record.Code, "error": err.Error()}) Fail(c, ErrorMailFailed, http.StatusBadGateway, "Mail delivery failed; feedback record was retained") return } mailID, err := s.store.InsertMail(db.MailRecord{ FeedbackCode: record.Code, Kind: "feedback", Status: "pending", ToAddress: message.To, Subject: message.Subject, PlainBody: message.PlainBody, HTMLBody: message.HTMLBody, AttachmentPath: message.AttachmentPath, AttachmentName: message.AttachmentName, CreatedAt: db.Now(), }) if err != nil { Fail(c, ErrorServerConfig, http.StatusInternalServerError, "Unable to retain mail record") return } if err := feedbackmail.Send(s.cfg, message); err != nil { _ = s.store.UpdateMailState(mailID, "failed", shortenPlain(err.Error(), 1000)) _ = s.store.UpdateFeedbackMailState(code, false) s.hooks.Dispatch("mail.failed", gin.H{"feedbackCode": record.Code, "mailId": mailID, "error": err.Error()}) Fail(c, ErrorMailFailed, http.StatusBadGateway, "Mail delivery failed; feedback record was retained") return } _ = s.store.UpdateMailState(mailID, "sent", "") _ = s.store.UpdateFeedbackMailState(code, true) c.JSON(http.StatusOK, gin.H{"ok": true, "code": code}) } func (s *Service) HandleStatus(c *gin.Context) { code := NormalizeCode(c.Query("code")) if code == "" { Fail(c, ErrorInvalidPayload, http.StatusBadRequest, "Invalid feedback code") return } row, err := s.store.FetchStatus(code) if err != nil { Fail(c, ErrorServerConfig, http.StatusInternalServerError, "Server error") return } if row == nil { Fail(c, ErrorNotFound, http.StatusNotFound, "Feedback not found") return } c.JSON(http.StatusOK, StatusFromRow(*row)) } func StatusFromRow(row db.StatusRow) StatusPayload { reply := strings.TrimSpace(row.PublicReply) return StatusPayload{ OK: true, Code: row.Code, Status: row.Status, StatusLabel: StatusLabel(row.Status), StatusDetail: row.StatusDetail, Category: row.Category, Priority: row.Priority, HasReply: reply != "", Reply: reply, ReceivedAt: row.ReceivedAt, UpdatedAt: row.UpdatedAt, MailSent: row.MailSent, } } 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(payloadText string, payload submissionPayload) error { var raw map[string]json.RawMessage if err := json.Unmarshal([]byte(payloadText), &raw); err != nil { return codeError{code: ErrorInvalidPayload, message: "Invalid payload JSON"} } for _, field := range []string{"title", "type", "severity", "bodyLength", "packageBytes", "packageSha256", "plainPackageSha256", "createdAt"} { if _, ok := raw[field]; !ok { return fieldError("Payload missing field: " + field) } } if payload.Title == "" { return fieldError("Payload missing field: title") } if payload.Type == "" { return fieldError("Payload missing field: type") } if payload.Severity == "" { return fieldError("Payload missing field: severity") } if payload.BodyLength < 0 { return fieldError("Payload missing field: bodyLength") } if payload.PackageBytes <= 0 { return fieldError("Payload missing field: packageBytes") } if payload.PackageSha256 == "" { return fieldError("Payload missing field: packageSha256") } if payload.PlainPackageSha256 == "" { return fieldError("Payload missing field: plainPackageSha256") } if len(payload.CreatedAt) == 0 { return fieldError("Payload missing field: createdAt") } if !payload.PackageEncrypted || payload.Encryption != PackageMagic { return codeError{code: ErrorInvalidEncryptedPackage, message: "Encrypted package is required"} } return nil } type fieldError string func (e fieldError) Error() string { return string(e) } type codeError struct { code string message string } func (e codeError) Error() string { return e.message } func errCode(err error) string { var coded codeError if errors.As(err, &coded) { return coded.code } return ErrorMissingField } func requireForm(c *gin.Context, key string) (string, bool) { value := strings.TrimSpace(c.PostForm(key)) if value == "" { Fail(c, ErrorMissingField, http.StatusBadRequest, "Missing field: "+key) return "", false } return value, true } var errUploadTooLarge = errors.New("upload too large") func readUploadedPackage(file *multipart.FileHeader, maxBytes int64) ([]byte, error) { if maxBytes > 0 && file.Size > maxBytes { return nil, errUploadTooLarge } stream, err := file.Open() if err != nil { return nil, err } defer stream.Close() limit := maxBytes + 1 if limit <= 1 { limit = 10*1024*1024 + 1 } data, err := io.ReadAll(io.LimitReader(stream, limit)) if err != nil { return nil, err } if maxBytes > 0 && int64(len(data)) > maxBytes { return nil, errUploadTooLarge } 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 := make([]byte, 0, len(ciphertext)+len(tag)) combined = append(combined, ciphertext...) combined = append(combined, tag...) return gcm.Open(nil, nonce, combined, []byte(PackageMagic)) } func ReadFeedbackPackage(plain []byte) (packageInfo, error) { return ReadFeedbackPackageWithGuard(plain, config.Default(".").UploadGuard) } 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 } if guard.MaxZipFiles <= 0 { guard.MaxZipFiles = 80 } if guard.MaxDecompressedBytes <= 0 { guard.MaxDecompressedBytes = 30 * 1024 * 1024 } if guard.MaxSingleFileBytes <= 0 { guard.MaxSingleFileBytes = 8 * 1024 * 1024 } if guard.MaxCompressionRatio <= 0 { guard.MaxCompressionRatio = 120 } if guard.MaxReadableTextBytes <= 0 { guard.MaxReadableTextBytes = 256 * 1024 } 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 { ratio := float64(entry.UncompressedSize64) / float64(entry.CompressedSize64) if ratio > 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 } } } 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.FeedbackRecord { now := db.Now() title := firstText(textFromMap(info.Request, "title"), payload.Title, "未命名反馈") typ := firstText(textFromMap(info.Request, "type"), payload.Type, "issue") severity := firstText(textFromMap(info.Request, "severity"), payload.Severity, "normal") contact := firstText(textFromMap(info.Request, "contact"), payload.Contact, "") body := firstText(textFromMap(info.Request, "body"), "", "") priority := normalizePriority(severity) return db.FeedbackRecord{ Code: code, ReceivedAt: now, Title: shortenPlain(title, 240), Type: shortenPlain(typ, 80), Severity: shortenPlain(severity, 80), Category: normalizeCategory(typ), Priority: priority, Contact: shortenPlain(contact, 240), Body: shortenPlain(body, 5000), Status: "new", StatusDetail: "反馈已接收,等待后台处理。", Note: "", PublicReply: "", HandledBy: "", Assignee: "", DueAt: "", ResolvedAt: "", ArchivedAt: "", SLALevel: defaultSLA(priority), SourceChannel: "winui", RiskScore: defaultRisk(priority), Resolution: "", PackagePath: packagePath, EncryptedPackagePath: encryptedPath, PackageSha256: packageSha256, PlainPackageSha256: plainPackageSha256, RemoteAddr: shortenPlain(remoteAddr, 80), SummaryText: shortenPlain(info.Summary, 6000), IncludedFiles: strings.Join(info.Files, ", "), MailSent: false, UpdatedAt: now, LastActivityAt: now, } } func (s *Service) generateCode() string { for { random := make([]byte, 3) if _, err := rand.Read(random); err != nil { panic(err) } code := "FB-" + time.Now().UTC().Format("20060102") + "-" + strings.ToUpper(hex.EncodeToString(random)) existing, _ := s.store.FetchStatus(code) if existing == nil { return code } } } func StatusLabel(status string) string { switch status { case "triaged": return "已归类" case "investigating": return "处理中" case "resolved": return "已解决" case "archived": return "已归档" default: return "新反馈" } } func TypeLabel(value string) string { switch strings.ToLower(value) { case "suggestion": return "建议" case "ui": return "界面反馈" case "other": return "其他" default: return "问题" } } func SeverityLabel(value string) string { switch strings.ToLower(value) { case "major": return "影响使用" case "blocking": return "阻塞" default: return "普通" } } func Fail(c *gin.Context, code string, status int, message string) { c.JSON(status, gin.H{ "ok": false, "error": code, "message": message, }) } func firstText(values ...string) string { for _, value := range values { if trimmed := strings.TrimSpace(value); trimmed != "" { return trimmed } } return "" } func textFromMap(values map[string]any, key string) string { if value, ok := values[key].(string); ok { return value } return "" } func shortenPlain(value string, max int) string { value = strings.TrimSpace(strings.ReplaceAll(value, "\x00", "")) if max <= 0 || len([]rune(value)) <= max { return value } runes := []rune(value) return string(runes[:max]) } 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")) } func normalizeCategory(value string) string { switch strings.ToLower(strings.TrimSpace(value)) { case "suggestion", "ui", "other": return strings.ToLower(strings.TrimSpace(value)) default: return "issue" } } func normalizePriority(value string) string { switch strings.ToLower(strings.TrimSpace(value)) { case "major", "blocking": return strings.ToLower(strings.TrimSpace(value)) default: return "normal" } } func defaultSLA(priority string) string { switch normalizePriority(priority) { case "blocking": return "urgent" case "major": return "elevated" default: return "standard" } } func defaultRisk(priority string) int { switch normalizePriority(priority) { case "blocking": return 90 case "major": return 65 default: return 30 } } func DebugEncryptPackageForTest(plain []byte, keyMaterial string, nonce []byte) ([]byte, error) { if len(nonce) != 12 { return nil, fmt.Errorf("nonce must be 12 bytes") } 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 } sealed := gcm.Seal(nil, nonce, plain, []byte(PackageMagic)) ciphertext := sealed[:len(sealed)-gcm.Overhead()] tag := sealed[len(sealed)-gcm.Overhead():] out := []byte(PackageMagic) out = append(out, nonce...) out = append(out, tag...) out = append(out, ciphertext...) return out, nil }