@@ -0,0 +1,800 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user