feat: add desktop bug report collection
This commit is contained in:
@@ -0,0 +1,685 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"github.com/geo-platform/tenant-api/internal/ops/domain"
|
||||
"github.com/geo-platform/tenant-api/internal/ops/repository"
|
||||
"github.com/geo-platform/tenant-api/internal/shared/objectstorage"
|
||||
"github.com/geo-platform/tenant-api/internal/shared/response"
|
||||
tenantrepo "github.com/geo-platform/tenant-api/internal/tenant/repository"
|
||||
)
|
||||
|
||||
const (
|
||||
ActionDesktopBugReportCreate = "desktop_bug_report.create"
|
||||
ActionDesktopBugReportUpdate = "desktop_bug_report.update"
|
||||
)
|
||||
|
||||
const (
|
||||
DesktopBugReportTypeCrash = "crash"
|
||||
DesktopBugReportTypeBug = "bug"
|
||||
)
|
||||
|
||||
const (
|
||||
DesktopBugReportStatusNew = "new"
|
||||
DesktopBugReportStatusTriaged = "triaged"
|
||||
DesktopBugReportStatusInProgress = "in_progress"
|
||||
DesktopBugReportStatusResolved = "resolved"
|
||||
DesktopBugReportStatusIgnored = "ignored"
|
||||
)
|
||||
|
||||
const (
|
||||
DesktopBugReportSeverityLow = "low"
|
||||
DesktopBugReportSeverityMedium = "medium"
|
||||
DesktopBugReportSeverityHigh = "high"
|
||||
DesktopBugReportSeverityCritical = "critical"
|
||||
)
|
||||
|
||||
const maxDesktopBugDumpBytes = 64 * 1024 * 1024
|
||||
const maxDesktopBugMultipartBytes = maxDesktopBugDumpBytes + 4*1024*1024
|
||||
|
||||
type DesktopBugReportService struct {
|
||||
reports *repository.DesktopBugReportRepository
|
||||
storage objectstorage.Client
|
||||
audits *AuditService
|
||||
}
|
||||
|
||||
func NewDesktopBugReportService(
|
||||
reports *repository.DesktopBugReportRepository,
|
||||
storage objectstorage.Client,
|
||||
audits *AuditService,
|
||||
) *DesktopBugReportService {
|
||||
return &DesktopBugReportService{reports: reports, storage: storage, audits: audits}
|
||||
}
|
||||
|
||||
type DesktopBugReportUploadInput struct {
|
||||
ReportType string
|
||||
Severity string
|
||||
Title string
|
||||
Description string
|
||||
TenantID *int64
|
||||
WorkspaceID *int64
|
||||
UserID *int64
|
||||
DesktopClientID string
|
||||
CrashGUID string
|
||||
CrashReportID string
|
||||
ProcessType string
|
||||
ElectronVersion string
|
||||
AppVersion string
|
||||
ProductName string
|
||||
Platform string
|
||||
OSArch string
|
||||
DeviceName string
|
||||
FormFields map[string]string
|
||||
RuntimeSnapshot []byte
|
||||
AppLogTail string
|
||||
DumpFileName string
|
||||
DumpContent []byte
|
||||
UploaderIP string
|
||||
UserAgent string
|
||||
OccurredAt time.Time
|
||||
}
|
||||
|
||||
type DesktopBugReportListInput struct {
|
||||
Keyword string
|
||||
Status string
|
||||
Severity string
|
||||
ReportType string
|
||||
Platform string
|
||||
Process string
|
||||
TenantID *int64
|
||||
ClientID string
|
||||
Page int
|
||||
Size int
|
||||
}
|
||||
|
||||
type DesktopBugReportUpdateInput struct {
|
||||
Status string
|
||||
Severity string
|
||||
ResolutionNote *string
|
||||
}
|
||||
|
||||
type DesktopBugReportListResult struct {
|
||||
Items []DesktopBugReportView `json:"items"`
|
||||
Total int64 `json:"total"`
|
||||
Page int `json:"page"`
|
||||
Size int `json:"size"`
|
||||
}
|
||||
|
||||
type DesktopBugReportUploadResult struct {
|
||||
ID string `json:"id"`
|
||||
DumpObjectKey *string `json:"dump_object_key,omitempty"`
|
||||
}
|
||||
|
||||
type DesktopBugReportDownloadURLResult struct {
|
||||
DownloadURL string `json:"download_url"`
|
||||
}
|
||||
|
||||
type DesktopBugReportView struct {
|
||||
ID string `json:"id"`
|
||||
ReportType string `json:"report_type"`
|
||||
Status string `json:"status"`
|
||||
Severity string `json:"severity"`
|
||||
TenantID *int64 `json:"tenant_id,omitempty"`
|
||||
TenantName *string `json:"tenant_name,omitempty"`
|
||||
WorkspaceID *int64 `json:"workspace_id,omitempty"`
|
||||
WorkspaceName *string `json:"workspace_name,omitempty"`
|
||||
UserID *int64 `json:"user_id,omitempty"`
|
||||
UserName *string `json:"user_name,omitempty"`
|
||||
UserPhone *string `json:"user_phone,omitempty"`
|
||||
UserEmail *string `json:"user_email,omitempty"`
|
||||
DesktopClientID *string `json:"desktop_client_id,omitempty"`
|
||||
CrashGUID *string `json:"crash_guid,omitempty"`
|
||||
CrashReportID *string `json:"crash_report_id,omitempty"`
|
||||
ProcessType *string `json:"process_type,omitempty"`
|
||||
ElectronVersion *string `json:"electron_version,omitempty"`
|
||||
AppVersion *string `json:"app_version,omitempty"`
|
||||
ProductName *string `json:"product_name,omitempty"`
|
||||
Platform *string `json:"platform,omitempty"`
|
||||
OSArch *string `json:"os_arch,omitempty"`
|
||||
DeviceName *string `json:"device_name,omitempty"`
|
||||
Title string `json:"title"`
|
||||
Description *string `json:"description,omitempty"`
|
||||
DumpObjectKey *string `json:"dump_object_key,omitempty"`
|
||||
DumpFileName *string `json:"dump_file_name,omitempty"`
|
||||
DumpSizeBytes *int64 `json:"dump_size_bytes,omitempty"`
|
||||
DumpSHA256 *string `json:"dump_sha256,omitempty"`
|
||||
FormFields json.RawMessage `json:"form_fields"`
|
||||
RuntimeSnapshot json.RawMessage `json:"runtime_snapshot"`
|
||||
AppLogTail *string `json:"app_log_tail,omitempty"`
|
||||
ResolutionNote *string `json:"resolution_note,omitempty"`
|
||||
UploaderIP *string `json:"uploader_ip,omitempty"`
|
||||
UserAgent *string `json:"user_agent,omitempty"`
|
||||
OccurredAt time.Time `json:"occurred_at"`
|
||||
ResolvedAt *time.Time `json:"resolved_at,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
func (s *DesktopBugReportService) UploadPublic(ctx context.Context, in DesktopBugReportUploadInput) (*DesktopBugReportUploadResult, error) {
|
||||
in.TenantID = nil
|
||||
in.WorkspaceID = nil
|
||||
in.UserID = nil
|
||||
in.DesktopClientID = ""
|
||||
in.FormFields = dropPublicDesktopBugIdentityFields(in.FormFields)
|
||||
return s.Upload(ctx, in)
|
||||
}
|
||||
|
||||
func (s *DesktopBugReportService) Upload(ctx context.Context, in DesktopBugReportUploadInput) (*DesktopBugReportUploadResult, error) {
|
||||
if s == nil || s.reports == nil {
|
||||
return nil, response.ErrServiceUnavailable(50361, "desktop_bug_report_unavailable", "desktop bug report service is unavailable")
|
||||
}
|
||||
normalized, err := normalizeDesktopBugReportUploadInput(in)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var dumpObjectKey *string
|
||||
var dumpSHA256 *string
|
||||
var dumpSize *int64
|
||||
if len(normalized.DumpContent) > 0 {
|
||||
if err := s.ensureStorageReady(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sum := sha256.Sum256(normalized.DumpContent)
|
||||
sha256Hex := hex.EncodeToString(sum[:])
|
||||
size := int64(len(normalized.DumpContent))
|
||||
objectKey := buildDesktopBugDumpObjectKey(normalized, sha256Hex)
|
||||
contentType := detectDesktopBugDumpContentType(normalized.DumpFileName, normalized.DumpContent)
|
||||
if err := s.storage.PutBytes(ctx, objectKey, normalized.DumpContent, contentType); err != nil {
|
||||
appErr := response.ErrInternal(50061, "desktop_bug_dump_upload_failed", "failed to upload dump to object storage")
|
||||
appErr.Cause = err
|
||||
return nil, appErr
|
||||
}
|
||||
dumpObjectKey = &objectKey
|
||||
dumpSHA256 = &sha256Hex
|
||||
dumpSize = &size
|
||||
}
|
||||
|
||||
formFields, _ := json.Marshal(normalized.FormFields)
|
||||
report, err := s.reports.Create(ctx, repository.CreateDesktopBugReportInput{
|
||||
ID: uuid.NewString(),
|
||||
ReportType: normalized.ReportType,
|
||||
Status: DesktopBugReportStatusNew,
|
||||
Severity: normalized.Severity,
|
||||
TenantID: normalized.TenantID,
|
||||
WorkspaceID: normalized.WorkspaceID,
|
||||
UserID: normalized.UserID,
|
||||
DesktopClientID: optionalUUIDString(normalized.DesktopClientID),
|
||||
CrashGUID: optionalText(normalized.CrashGUID),
|
||||
CrashReportID: optionalText(normalized.CrashReportID),
|
||||
ProcessType: optionalText(normalized.ProcessType),
|
||||
ElectronVersion: optionalText(normalized.ElectronVersion),
|
||||
AppVersion: optionalText(normalized.AppVersion),
|
||||
ProductName: optionalText(normalized.ProductName),
|
||||
Platform: optionalText(normalized.Platform),
|
||||
OSArch: optionalText(normalized.OSArch),
|
||||
DeviceName: optionalText(normalized.DeviceName),
|
||||
Title: normalized.Title,
|
||||
Description: optionalText(normalized.Description),
|
||||
DumpObjectKey: dumpObjectKey,
|
||||
DumpFileName: optionalText(normalized.DumpFileName),
|
||||
DumpSizeBytes: dumpSize,
|
||||
DumpSHA256: dumpSHA256,
|
||||
FormFields: formFields,
|
||||
RuntimeSnapshot: normalized.RuntimeSnapshot,
|
||||
AppLogTail: optionalText(normalized.AppLogTail),
|
||||
UploaderIP: optionalText(normalized.UploaderIP),
|
||||
UserAgent: optionalText(normalized.UserAgent),
|
||||
OccurredAt: normalized.OccurredAt,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &DesktopBugReportUploadResult{ID: report.ID, DumpObjectKey: report.DumpObjectKey}, nil
|
||||
}
|
||||
|
||||
func (s *DesktopBugReportService) UploadForClient(
|
||||
ctx context.Context,
|
||||
client *tenantrepo.DesktopClient,
|
||||
in DesktopBugReportUploadInput,
|
||||
) (*DesktopBugReportUploadResult, error) {
|
||||
if client == nil {
|
||||
return nil, response.ErrUnauthorized(40108, "desktop_client_missing", "desktop client context is required")
|
||||
}
|
||||
tenantID := client.TenantID
|
||||
workspaceID := client.WorkspaceID
|
||||
userID := client.UserID
|
||||
in.TenantID = &tenantID
|
||||
in.WorkspaceID = &workspaceID
|
||||
in.UserID = &userID
|
||||
in.DesktopClientID = client.ID.String()
|
||||
if strings.TrimSpace(in.DeviceName) == "" && client.DeviceName != nil {
|
||||
in.DeviceName = *client.DeviceName
|
||||
}
|
||||
if strings.TrimSpace(in.Platform) == "" && client.OS != nil {
|
||||
in.Platform = *client.OS
|
||||
}
|
||||
if strings.TrimSpace(in.OSArch) == "" && client.CPUArch != nil {
|
||||
in.OSArch = *client.CPUArch
|
||||
}
|
||||
if strings.TrimSpace(in.AppVersion) == "" && client.ClientVersion != nil {
|
||||
in.AppVersion = *client.ClientVersion
|
||||
}
|
||||
return s.Upload(ctx, in)
|
||||
}
|
||||
|
||||
func (s *DesktopBugReportService) List(ctx context.Context, in DesktopBugReportListInput) (*DesktopBugReportListResult, error) {
|
||||
page := in.Page
|
||||
if page <= 0 {
|
||||
page = 1
|
||||
}
|
||||
size := in.Size
|
||||
if size <= 0 || size > 200 {
|
||||
size = 20
|
||||
}
|
||||
items, total, err := s.reports.List(ctx, repository.DesktopBugReportFilter{
|
||||
Keyword: strings.TrimSpace(in.Keyword),
|
||||
Status: normalizeDesktopBugStatus(in.Status),
|
||||
Severity: normalizeDesktopBugSeverity(in.Severity),
|
||||
ReportType: normalizeDesktopBugReportType(in.ReportType),
|
||||
Platform: normalizeDesktopBugToken(in.Platform),
|
||||
Process: normalizeDesktopBugToken(in.Process),
|
||||
TenantID: in.TenantID,
|
||||
ClientID: normalizeUUIDString(in.ClientID),
|
||||
Limit: size,
|
||||
Offset: (page - 1) * size,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
views := make([]DesktopBugReportView, 0, len(items))
|
||||
for i := range items {
|
||||
views = append(views, desktopBugReportView(&items[i]))
|
||||
}
|
||||
return &DesktopBugReportListResult{Items: views, Total: total, Page: page, Size: size}, nil
|
||||
}
|
||||
|
||||
func (s *DesktopBugReportService) Get(ctx context.Context, id string) (*DesktopBugReportView, error) {
|
||||
report, err := s.reports.GetByID(ctx, id)
|
||||
if err != nil {
|
||||
if errors.Is(err, repository.ErrDesktopBugReportNotFound) {
|
||||
return nil, response.ErrNotFound(40461, "desktop_bug_report_not_found", "客户端 Bug 记录不存在")
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
view := desktopBugReportView(report)
|
||||
return &view, nil
|
||||
}
|
||||
|
||||
func (s *DesktopBugReportService) Update(ctx context.Context, actor *Actor, id string, in DesktopBugReportUpdateInput) (*DesktopBugReportView, error) {
|
||||
status := normalizeDesktopBugStatus(in.Status)
|
||||
if !isAllowedDesktopBugStatus(status) {
|
||||
return nil, response.ErrBadRequest(40061, "invalid_desktop_bug_status", "状态必须是 new、triaged、in_progress、resolved 或 ignored")
|
||||
}
|
||||
severity := normalizeDesktopBugSeverity(in.Severity)
|
||||
if !isAllowedDesktopBugSeverity(severity) {
|
||||
return nil, response.ErrBadRequest(40062, "invalid_desktop_bug_severity", "严重级别必须是 low、medium、high 或 critical")
|
||||
}
|
||||
report, err := s.reports.UpdateState(ctx, id, repository.UpdateDesktopBugReportStateInput{
|
||||
Status: status,
|
||||
Severity: severity,
|
||||
ResolutionNote: trimOptionalDesktopBugString(in.ResolutionNote, 4000),
|
||||
})
|
||||
if err != nil {
|
||||
if errors.Is(err, repository.ErrDesktopBugReportNotFound) {
|
||||
return nil, response.ErrNotFound(40461, "desktop_bug_report_not_found", "客户端 Bug 记录不存在")
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
s.audit(ctx, actor, ActionDesktopBugReportUpdate, report)
|
||||
view := desktopBugReportView(report)
|
||||
return &view, nil
|
||||
}
|
||||
|
||||
func (s *DesktopBugReportService) ResolveDumpDownloadURL(ctx context.Context, id string) (*DesktopBugReportDownloadURLResult, error) {
|
||||
report, err := s.reports.GetByID(ctx, id)
|
||||
if err != nil {
|
||||
if errors.Is(err, repository.ErrDesktopBugReportNotFound) {
|
||||
return nil, response.ErrNotFound(40461, "desktop_bug_report_not_found", "客户端 Bug 记录不存在")
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
if report.DumpObjectKey == nil || strings.TrimSpace(*report.DumpObjectKey) == "" {
|
||||
return nil, response.ErrNotFound(40462, "desktop_bug_dump_not_found", "该记录没有 dump 文件")
|
||||
}
|
||||
if err := s.ensureStorageReady(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
downloadURL, err := s.storage.DownloadURL(*report.DumpObjectKey)
|
||||
if err != nil {
|
||||
appErr := response.ErrInternal(50062, "desktop_bug_dump_url_failed", "failed to generate dump download URL")
|
||||
appErr.Cause = err
|
||||
return nil, appErr
|
||||
}
|
||||
return &DesktopBugReportDownloadURLResult{DownloadURL: downloadURL}, nil
|
||||
}
|
||||
|
||||
func (s *DesktopBugReportService) ensureStorageReady() error {
|
||||
if s == nil || s.storage == nil {
|
||||
return response.ErrServiceUnavailable(50303, "object_storage_unavailable", "object storage is not configured")
|
||||
}
|
||||
if err := s.storage.Validate(); err != nil {
|
||||
return response.ErrServiceUnavailable(50303, "object_storage_unavailable", "object storage is not configured")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func normalizeDesktopBugReportUploadInput(in DesktopBugReportUploadInput) (DesktopBugReportUploadInput, error) {
|
||||
in.ReportType = normalizeDesktopBugReportType(in.ReportType)
|
||||
if in.ReportType == "" {
|
||||
in.ReportType = DesktopBugReportTypeCrash
|
||||
}
|
||||
if !isAllowedDesktopBugReportType(in.ReportType) {
|
||||
return DesktopBugReportUploadInput{}, response.ErrBadRequest(40063, "invalid_desktop_bug_report_type", "上报类型必须是 crash 或 bug")
|
||||
}
|
||||
in.Severity = normalizeDesktopBugSeverity(in.Severity)
|
||||
if in.Severity == "" {
|
||||
if in.ReportType == DesktopBugReportTypeCrash {
|
||||
in.Severity = DesktopBugReportSeverityHigh
|
||||
} else {
|
||||
in.Severity = DesktopBugReportSeverityMedium
|
||||
}
|
||||
}
|
||||
if !isAllowedDesktopBugSeverity(in.Severity) {
|
||||
return DesktopBugReportUploadInput{}, response.ErrBadRequest(40062, "invalid_desktop_bug_severity", "严重级别必须是 low、medium、high 或 critical")
|
||||
}
|
||||
in.Title = limitDesktopBugText(firstNonEmptyString(
|
||||
in.Title,
|
||||
in.FormFields["_title"],
|
||||
in.FormFields["title"],
|
||||
defaultDesktopBugTitle(in),
|
||||
), 200)
|
||||
in.Description = limitDesktopBugText(firstNonEmptyString(in.Description, in.FormFields["_description"], in.FormFields["description"]), 4000)
|
||||
in.CrashGUID = limitDesktopBugText(firstNonEmptyString(in.CrashGUID, in.FormFields["guid"]), 200)
|
||||
in.CrashReportID = limitDesktopBugText(firstNonEmptyString(in.CrashReportID, in.FormFields["crash_report_id"], in.FormFields["id"]), 200)
|
||||
in.ProcessType = limitDesktopBugText(firstNonEmptyString(in.ProcessType, in.FormFields["process_type"], in.FormFields["ptype"]), 80)
|
||||
in.ElectronVersion = limitDesktopBugText(firstNonEmptyString(in.ElectronVersion, in.FormFields["ver"]), 80)
|
||||
in.AppVersion = limitDesktopBugText(firstNonEmptyString(in.AppVersion, in.FormFields["app_version"], in.FormFields["_version"]), 80)
|
||||
in.ProductName = limitDesktopBugText(firstNonEmptyString(in.ProductName, in.FormFields["_productName"], in.FormFields["prod"]), 120)
|
||||
in.Platform = limitDesktopBugText(firstNonEmptyString(in.Platform, in.FormFields["platform"]), 80)
|
||||
in.OSArch = limitDesktopBugText(firstNonEmptyString(in.OSArch, in.FormFields["osarch"], in.FormFields["cpu_arch"]), 80)
|
||||
in.DeviceName = limitDesktopBugText(firstNonEmptyString(in.DeviceName, in.FormFields["device_name"]), 200)
|
||||
in.DesktopClientID = normalizeUUIDString(firstNonEmptyString(in.DesktopClientID, in.FormFields["client_id"]))
|
||||
if len(in.DumpContent) > maxDesktopBugDumpBytes {
|
||||
return DesktopBugReportUploadInput{}, response.ErrBadRequest(40064, "desktop_bug_dump_too_large", "dump 文件不能超过 64MB")
|
||||
}
|
||||
in.DumpFileName = sanitizeDesktopBugFileName(in.DumpFileName)
|
||||
if len(in.DumpContent) > 0 && in.DumpFileName == "" {
|
||||
in.DumpFileName = "upload_file_minidump.dmp"
|
||||
}
|
||||
in.AppLogTail = limitDesktopBugText(in.AppLogTail, 20000)
|
||||
if len(in.RuntimeSnapshot) == 0 || !json.Valid(in.RuntimeSnapshot) {
|
||||
in.RuntimeSnapshot = []byte("{}")
|
||||
}
|
||||
in.FormFields = sanitizeDesktopBugFormFields(in.FormFields)
|
||||
if in.OccurredAt.IsZero() {
|
||||
in.OccurredAt = time.Now().UTC()
|
||||
}
|
||||
return in, nil
|
||||
}
|
||||
|
||||
func defaultDesktopBugTitle(in DesktopBugReportUploadInput) string {
|
||||
if in.ReportType == DesktopBugReportTypeBug {
|
||||
return "用户反馈"
|
||||
}
|
||||
process := firstNonEmptyString(in.ProcessType, in.FormFields["process_type"], in.FormFields["ptype"], "unknown")
|
||||
platform := firstNonEmptyString(in.Platform, in.FormFields["platform"], "unknown")
|
||||
return fmt.Sprintf("客户端崩溃:%s / %s", process, platform)
|
||||
}
|
||||
|
||||
func sanitizeDesktopBugFormFields(fields map[string]string) map[string]string {
|
||||
out := make(map[string]string)
|
||||
for key, value := range fields {
|
||||
normalizedKey := strings.TrimSpace(key)
|
||||
if normalizedKey == "" || len(normalizedKey) > 120 {
|
||||
continue
|
||||
}
|
||||
out[normalizedKey] = limitDesktopBugText(value, 2000)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func dropPublicDesktopBugIdentityFields(fields map[string]string) map[string]string {
|
||||
if len(fields) == 0 {
|
||||
return fields
|
||||
}
|
||||
out := make(map[string]string, len(fields))
|
||||
for key, value := range fields {
|
||||
switch strings.ToLower(strings.TrimSpace(key)) {
|
||||
case "tenant_id", "_tenant_id", "workspace_id", "_workspace_id", "user_id", "_user_id",
|
||||
"client_id", "_client_id", "desktop_client_id", "_desktop_client_id":
|
||||
continue
|
||||
default:
|
||||
out[key] = value
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func buildDesktopBugDumpObjectKey(in DesktopBugReportUploadInput, sha256Hex string) string {
|
||||
datePrefix := time.Now().UTC().Format("2006/01/02")
|
||||
reportKind := in.ReportType
|
||||
if reportKind == "" {
|
||||
reportKind = DesktopBugReportTypeCrash
|
||||
}
|
||||
clientID := normalizeUUIDString(in.DesktopClientID)
|
||||
if clientID == "" {
|
||||
clientID = "anonymous"
|
||||
}
|
||||
ext := strings.ToLower(filepath.Ext(in.DumpFileName))
|
||||
if ext == "" || len(ext) > 10 {
|
||||
ext = ".dmp"
|
||||
}
|
||||
return fmt.Sprintf("desktop-bug-reports/%s/%s/%s/%s%s", reportKind, datePrefix, clientID, sha256Hex, ext)
|
||||
}
|
||||
|
||||
func detectDesktopBugDumpContentType(fileName string, content []byte) string {
|
||||
lower := strings.ToLower(fileName)
|
||||
switch {
|
||||
case strings.HasSuffix(lower, ".dmp"):
|
||||
return "application/vnd.microsoft.portable-executable"
|
||||
case strings.HasSuffix(lower, ".gz"):
|
||||
return "application/gzip"
|
||||
case strings.HasSuffix(lower, ".zip"):
|
||||
return "application/zip"
|
||||
default:
|
||||
if len(content) > 0 {
|
||||
return http.DetectContentType(content)
|
||||
}
|
||||
return "application/octet-stream"
|
||||
}
|
||||
}
|
||||
|
||||
func desktopBugReportView(item *domain.DesktopBugReport) DesktopBugReportView {
|
||||
return DesktopBugReportView{
|
||||
ID: item.ID,
|
||||
ReportType: item.ReportType,
|
||||
Status: item.Status,
|
||||
Severity: item.Severity,
|
||||
TenantID: item.TenantID,
|
||||
TenantName: item.TenantName,
|
||||
WorkspaceID: item.WorkspaceID,
|
||||
WorkspaceName: item.WorkspaceName,
|
||||
UserID: item.UserID,
|
||||
UserName: item.UserName,
|
||||
UserPhone: item.UserPhone,
|
||||
UserEmail: item.UserEmail,
|
||||
DesktopClientID: item.DesktopClientID,
|
||||
CrashGUID: item.CrashGUID,
|
||||
CrashReportID: item.CrashReportID,
|
||||
ProcessType: item.ProcessType,
|
||||
ElectronVersion: item.ElectronVersion,
|
||||
AppVersion: item.AppVersion,
|
||||
ProductName: item.ProductName,
|
||||
Platform: item.Platform,
|
||||
OSArch: item.OSArch,
|
||||
DeviceName: item.DeviceName,
|
||||
Title: item.Title,
|
||||
Description: item.Description,
|
||||
DumpObjectKey: item.DumpObjectKey,
|
||||
DumpFileName: item.DumpFileName,
|
||||
DumpSizeBytes: item.DumpSizeBytes,
|
||||
DumpSHA256: item.DumpSHA256,
|
||||
FormFields: item.FormFields,
|
||||
RuntimeSnapshot: item.RuntimeSnapshot,
|
||||
AppLogTail: item.AppLogTail,
|
||||
ResolutionNote: item.ResolutionNote,
|
||||
UploaderIP: item.UploaderIP,
|
||||
UserAgent: item.UserAgent,
|
||||
OccurredAt: item.OccurredAt,
|
||||
ResolvedAt: item.ResolvedAt,
|
||||
CreatedAt: item.CreatedAt,
|
||||
UpdatedAt: item.UpdatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *DesktopBugReportService) audit(ctx context.Context, actor *Actor, action string, item *domain.DesktopBugReport) {
|
||||
if actor == nil || s.audits == nil || item == nil {
|
||||
return
|
||||
}
|
||||
event := actor.audit(action, "desktop_bug_report", 0, map[string]any{
|
||||
"id": item.ID,
|
||||
"report_type": item.ReportType,
|
||||
"status": item.Status,
|
||||
"severity": item.Severity,
|
||||
"dump_object_key": item.DumpObjectKey,
|
||||
})
|
||||
event.TargetID = item.ID
|
||||
_ = s.audits.Append(ctx, event)
|
||||
}
|
||||
|
||||
func normalizeDesktopBugReportType(value string) string {
|
||||
return strings.ToLower(strings.TrimSpace(value))
|
||||
}
|
||||
|
||||
func normalizeDesktopBugStatus(value string) string {
|
||||
return strings.ToLower(strings.TrimSpace(value))
|
||||
}
|
||||
|
||||
func normalizeDesktopBugSeverity(value string) string {
|
||||
return strings.ToLower(strings.TrimSpace(value))
|
||||
}
|
||||
|
||||
func normalizeDesktopBugToken(value string) string {
|
||||
return strings.ToLower(strings.TrimSpace(value))
|
||||
}
|
||||
|
||||
func isAllowedDesktopBugReportType(value string) bool {
|
||||
return value == DesktopBugReportTypeCrash || value == DesktopBugReportTypeBug
|
||||
}
|
||||
|
||||
func isAllowedDesktopBugStatus(value string) bool {
|
||||
switch value {
|
||||
case DesktopBugReportStatusNew, DesktopBugReportStatusTriaged, DesktopBugReportStatusInProgress, DesktopBugReportStatusResolved, DesktopBugReportStatusIgnored:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func isAllowedDesktopBugSeverity(value string) bool {
|
||||
switch value {
|
||||
case DesktopBugReportSeverityLow, DesktopBugReportSeverityMedium, DesktopBugReportSeverityHigh, DesktopBugReportSeverityCritical:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func optionalText(value string) *string {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" {
|
||||
return nil
|
||||
}
|
||||
return &value
|
||||
}
|
||||
|
||||
func optionalUUIDString(value string) *string {
|
||||
value = normalizeUUIDString(value)
|
||||
if value == "" {
|
||||
return nil
|
||||
}
|
||||
return &value
|
||||
}
|
||||
|
||||
func normalizeUUIDString(value string) string {
|
||||
value = strings.TrimSpace(value)
|
||||
if _, err := uuid.Parse(value); err != nil {
|
||||
return ""
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func limitDesktopBugText(value string, limit int) string {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" || limit <= 0 {
|
||||
return ""
|
||||
}
|
||||
runes := []rune(value)
|
||||
if len(runes) <= limit {
|
||||
return value
|
||||
}
|
||||
return string(runes[:limit])
|
||||
}
|
||||
|
||||
func trimOptionalDesktopBugString(value *string, limit int) *string {
|
||||
if value == nil {
|
||||
return nil
|
||||
}
|
||||
trimmed := limitDesktopBugText(*value, limit)
|
||||
if trimmed == "" {
|
||||
return nil
|
||||
}
|
||||
return &trimmed
|
||||
}
|
||||
|
||||
func sanitizeDesktopBugFileName(value string) string {
|
||||
value = strings.TrimSpace(filepath.Base(value))
|
||||
if value == "" || value == "." || value == "/" || value == "\\" {
|
||||
return ""
|
||||
}
|
||||
value = strings.Map(func(r rune) rune {
|
||||
if r == '\\' || r == '/' || r == '\r' || r == '\n' {
|
||||
return -1
|
||||
}
|
||||
return r
|
||||
}, value)
|
||||
return limitDesktopBugText(value, 200)
|
||||
}
|
||||
|
||||
var desktopBugISODatePattern = regexp.MustCompile(`^\d{4}-\d{2}-\d{2}T`)
|
||||
|
||||
func ParseDesktopBugOccurredAt(value string) time.Time {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" || !desktopBugISODatePattern.MatchString(value) {
|
||||
return time.Time{}
|
||||
}
|
||||
parsed, err := time.Parse(time.RFC3339Nano, value)
|
||||
if err != nil {
|
||||
return time.Time{}
|
||||
}
|
||||
return parsed.UTC()
|
||||
}
|
||||
|
||||
func MaxDesktopBugMultipartBytes() int64 {
|
||||
return maxDesktopBugMultipartBytes
|
||||
}
|
||||
|
||||
func MaxDesktopBugDumpBytes() int64 {
|
||||
return maxDesktopBugDumpBytes
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
package app
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestDropPublicDesktopBugIdentityFields(t *testing.T) {
|
||||
fields := map[string]string{
|
||||
"tenant_id": "1",
|
||||
"_tenant_id": "2",
|
||||
"workspace_id": "3",
|
||||
"_workspace_id": "4",
|
||||
"user_id": "5",
|
||||
"_user_id": "6",
|
||||
"client_id": "spoofed-client",
|
||||
"_client_id": "spoofed-electron-client",
|
||||
"desktop_client_id": "spoofed-desktop-client",
|
||||
"_desktop_client_id": "spoofed-electron-desktop-client",
|
||||
"title": "renderer crash",
|
||||
"guid": "crash-guid",
|
||||
}
|
||||
|
||||
sanitized := dropPublicDesktopBugIdentityFields(fields)
|
||||
|
||||
for _, key := range []string{
|
||||
"tenant_id",
|
||||
"_tenant_id",
|
||||
"workspace_id",
|
||||
"_workspace_id",
|
||||
"user_id",
|
||||
"_user_id",
|
||||
"client_id",
|
||||
"_client_id",
|
||||
"desktop_client_id",
|
||||
"_desktop_client_id",
|
||||
} {
|
||||
if _, ok := sanitized[key]; ok {
|
||||
t.Fatalf("sanitized fields retained spoofable identity key %q", key)
|
||||
}
|
||||
}
|
||||
if sanitized["title"] != "renderer crash" || sanitized["guid"] != "crash-guid" {
|
||||
t.Fatalf("sanitized fields dropped non-identity metadata: %#v", sanitized)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user