## Summary - **Remove hashed_ip entirely**: dropped `HashedIP` field, `hashIP` function, and all IP extraction logic from the server. No IP address is collected or stored in any form. - **Move contact email out of report.json**: if the user opts to include their email for follow-up, it is now written to `mail.eml` in the report directory instead of being embedded in `report.json`. This keeps PII separate from the structured report data. - Remove now-unused imports (`crypto/sha256`, `encoding/hex`, `strings`). - Flutter client (`bug_report_screen.dart`) was already not sending a `hashed_ip` field — no client changes needed. ## Test plan - [x] `go build ./...` in `server/bugreport/` passes with no errors - [x] `go vet ./...` passes with no warnings - Reports without a contact email produce only `report.json` (no `mail.eml`) - Reports with a contact email produce `report.json` (no `email` key) and `mail.eml` containing the address Closes #441 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Thomas SharedInbox <sharedinbox@thomas-guettler.de> Reviewed-on: https://codeberg.org/guettli/sharedinbox/pulls/442
267 lines
7.4 KiB
Go
267 lines
7.4 KiB
Go
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)
|
|
}
|
|
}
|