package main import ( "crypto/rand" "encoding/json" "fmt" "io" "log" "net" "net/http" "os" "path/filepath" "strconv" "sync" "time" ) // BugReport represents the data stored in report.json type BugReport struct { Description string `json:"description"` AboutInfo string `json:"about_info"` EmailData string `json:"email_data,omitempty"` SyncLog string `json:"sync_log,omitempty"` Timestamp time.Time `json:"timestamp"` } var ( rateLimitMu sync.Mutex requestTimes []time.Time ) // checkRateLimit implements a sliding window rate limiter: max 10 requests per minute globally. func checkRateLimit() (bool, time.Duration) { rateLimitMu.Lock() defer rateLimitMu.Unlock() now := time.Now() // Clean up timestamps older than 1 minute var valid []time.Time for _, t := range requestTimes { if now.Sub(t) < time.Minute { valid = append(valid, t) } } requestTimes = valid if len(requestTimes) >= 10 { // Calculate time until the oldest request in the window falls out of it oldest := requestTimes[0] remaining := time.Minute - now.Sub(oldest) if remaining < 0 { remaining = 0 } return false, remaining } requestTimes = append(requestTimes, now) return true, 0 } func generateUUID() (string, error) { b := make([]byte, 16) _, err := rand.Read(b) if err != nil { return "", err } // Format as UUID v4 structure b[6] = (b[6] & 0x0f) | 0x40 // Version 4 b[8] = (b[8] & 0x3f) | 0x80 // Variant is 10 return fmt.Sprintf("%x-%x-%x-%x-%x", b[0:4], b[4:6], b[6:8], b[8:10], b[10:]), nil } func bugReportHandler(storageDir string) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { // Enable CORS so the web app (if applicable) can upload w.Header().Set("Access-Control-Allow-Origin", "*") w.Header().Set("Access-Control-Allow-Methods", "POST, OPTIONS") w.Header().Set("Access-Control-Allow-Headers", "Content-Type") if r.Method == http.MethodOptions { w.WriteHeader(http.StatusOK) return } if r.Method != http.MethodPost { http.Error(w, "Method Not Allowed", http.StatusMethodNotAllowed) return } // Rate limiting check allowed, waitTime := checkRateLimit() if !allowed { retryAfter := int(waitTime.Seconds()) if retryAfter < 1 { retryAfter = 1 } w.Header().Set("Retry-After", strconv.Itoa(retryAfter)) w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusTooManyRequests) _ = json.NewEncoder(w).Encode(map[string]string{"error": "Too many requests. Please try again later."}) return } // Limit body size to 20 MB (20 * 1024 * 1024 bytes) const maxBodySize = 20 * 1024 * 1024 r.Body = http.MaxBytesReader(w, r.Body, maxBodySize) // Parse the multipart form err := r.ParseMultipartForm(maxBodySize) if err != nil { log.Printf("Failed to parse multipart form: %v", err) w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusRequestEntityTooLarge) _ = json.NewEncoder(w).Encode(map[string]string{"error": "Request body too large or invalid multipart form."}) return } defer func() { _ = r.MultipartForm.RemoveAll() }() description := r.FormValue("description") aboutInfo := r.FormValue("about_info") if description == "" || aboutInfo == "" { w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusBadRequest) _ = json.NewEncoder(w).Encode(map[string]string{"error": "description and about_info are required fields."}) return } email := r.FormValue("email") emailData := r.FormValue("email_data") syncLog := r.FormValue("sync_log") uuidVal, err := generateUUID() if err != nil { log.Printf("Failed to generate UUID: %v", err) http.Error(w, "Internal Server Error", http.StatusInternalServerError) return } now := time.Now() timestampStr := now.Format("20060102_150405") dirName := fmt.Sprintf("%s_%s", timestampStr, uuidVal) reportDir := filepath.Join(storageDir, dirName) err = os.MkdirAll(reportDir, 0750) if err != nil { log.Printf("Failed to create report directory: %v", err) http.Error(w, "Internal Server Error", http.StatusInternalServerError) return } // Write report.json report := BugReport{ Description: description, AboutInfo: aboutInfo, EmailData: emailData, SyncLog: syncLog, Timestamp: now, } reportJSONPath := filepath.Join(reportDir, "report.json") reportJSONFile, err := os.OpenFile(reportJSONPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0600) if err != nil { log.Printf("Failed to create report.json: %v", err) http.Error(w, "Internal Server Error", http.StatusInternalServerError) return } defer reportJSONFile.Close() enc := json.NewEncoder(reportJSONFile) enc.SetIndent("", " ") err = enc.Encode(report) if err != nil { log.Printf("Failed to write report.json: %v", err) http.Error(w, "Internal Server Error", http.StatusInternalServerError) return } // Write contact email to mail.eml (kept separate from report.json to isolate PII) if email != "" { mailEmlPath := filepath.Join(reportDir, "mail.eml") err = os.WriteFile(mailEmlPath, []byte(email), 0600) if err != nil { log.Printf("Failed to write mail.eml: %v", err) http.Error(w, "Internal Server Error", http.StatusInternalServerError) return } } // Save attachments form := r.MultipartForm files := form.File["attachments[]"] for i, fileHeader := range files { file, err := fileHeader.Open() if err != nil { log.Printf("Failed to open attachment %d: %v", i, err) http.Error(w, "Internal Server Error", http.StatusInternalServerError) return } defer file.Close() // Sanitize filename to avoid directory traversal baseName := filepath.Base(fileHeader.Filename) attachmentName := fmt.Sprintf("attachment_%d_%s", i, baseName) attachmentPath := filepath.Join(reportDir, attachmentName) destFile, err := os.OpenFile(attachmentPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0600) if err != nil { log.Printf("Failed to create attachment file %s: %v", attachmentName, err) http.Error(w, "Internal Server Error", http.StatusInternalServerError) return } defer destFile.Close() _, err = io.Copy(destFile, file) if err != nil { log.Printf("Failed to copy attachment content to %s: %v", attachmentName, err) http.Error(w, "Internal Server Error", http.StatusInternalServerError) return } } w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusCreated) _ = json.NewEncoder(w).Encode(map[string]string{"id": uuidVal}) } } func main() { port := os.Getenv("BUGREPORT_PORT") if port == "" { port = "8090" } storageDir := os.Getenv("BUGREPORT_STORAGE_DIR") if storageDir == "" { storageDir = "./reports" } // Create storage directory if it doesn't exist err := os.MkdirAll(storageDir, 0750) if err != nil { log.Fatalf("Failed to create storage directory %s: %v", storageDir, err) } mux := http.NewServeMux() mux.HandleFunc("/api/v1/bug-reports", bugReportHandler(storageDir)) addr := net.JoinHostPort("127.0.0.1", port) log.Printf("Bug report server starting on %s...", addr) log.Printf("Reports storage directory: %s", storageDir) server := &http.Server{ Addr: addr, Handler: mux, ReadTimeout: 15 * time.Second, WriteTimeout: 15 * time.Second, IdleTimeout: 60 * time.Second, } if err := server.ListenAndServe(); err != nil { log.Fatalf("Server failed to start: %v", err) } }