From 45dbe480a5bfc19c5bde9d959f1178925a714442 Mon Sep 17 00:00:00 2001 From: Flavio Fois Date: Tue, 17 Mar 2026 18:20:14 +0100 Subject: [PATCH] add bug report creation handler and update routing structure --- .gitignore | 4 +- internal/handlers/bug_report.route.go | 122 ++++++++++++++++++++++++++ main.go | 45 +++++----- 3 files changed, 149 insertions(+), 22 deletions(-) diff --git a/.gitignore b/.gitignore index 28cbcf5..172fdcc 100644 --- a/.gitignore +++ b/.gitignore @@ -33,4 +33,6 @@ go.work.sum # Claude -.claude/ \ No newline at end of file +.claude/ + +tmp/ \ No newline at end of file diff --git a/internal/handlers/bug_report.route.go b/internal/handlers/bug_report.route.go index 544cbd7..89e975d 100644 --- a/internal/handlers/bug_report.route.go +++ b/internal/handlers/bug_report.route.go @@ -7,7 +7,10 @@ import ( "encoding/json" "errors" "fmt" + "io" + "log" "net/http" + "strings" "github.com/go-chi/chi/v5" "github.com/jmoiron/sqlx" @@ -15,6 +18,125 @@ import ( "emly-api-go/internal/models" ) +var fileRoles = []struct { + field string + role models.FileRole + defaultMime string +}{ + {"attachment", models.FileRoleAttachment, "application/octet-stream"}, + {"screenshot", models.FileRoleScreenshot, "image/png"}, + {"log", models.FileRoleLog, "text/plain"}, +} + +func CreateBugReport(db *sqlx.DB) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if err := r.ParseMultipartForm(32 << 20); err != nil { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusBadRequest) + json.NewEncoder(w).Encode(map[string]string{"error": "invalid multipart form: " + err.Error()}) + return + } + + name := r.FormValue("name") + email := r.FormValue("email") + description := r.FormValue("description") + hwid := r.FormValue("hwid") + hostname := r.FormValue("hostname") + osUser := r.FormValue("os_user") + systemInfoStr := r.FormValue("system_info") + + if name == "" || email == "" || description == "" { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusBadRequest) + json.NewEncoder(w).Encode(map[string]string{"error": "name, email and description are required"}) + return + } + + submitterIP := strings.TrimSpace(strings.SplitN(r.Header.Get("X-Forwarded-For"), ",", 2)[0]) + if submitterIP == "" { + submitterIP = r.Header.Get("X-Real-IP") + } + if submitterIP == "" { + submitterIP = "unknown" + } + + var systemInfo json.RawMessage + if systemInfoStr != "" && json.Valid([]byte(systemInfoStr)) { + systemInfo = json.RawMessage(systemInfoStr) + } + + log.Printf("[BUGREPORT] Received from name=%s hwid=%s ip=%s", name, hwid, submitterIP) + + result, err := db.ExecContext(r.Context(), + "INSERT INTO emly_bugreports.bug_reports (name, email, description, hwid, hostname, os_user, submitter_ip, system_info, status) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)", + name, email, description, hwid, hostname, osUser, submitterIP, systemInfo, models.BugReportStatusNew, + ) + if err != nil { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusInternalServerError) + json.NewEncoder(w).Encode(map[string]string{"error": err.Error()}) + return + } + + reportID, err := result.LastInsertId() + if err != nil { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusInternalServerError) + json.NewEncoder(w).Encode(map[string]string{"error": err.Error()}) + return + } + + for _, fr := range fileRoles { + file, header, err := r.FormFile(fr.field) + if err != nil { + // no file uploaded for this role — skip + continue + } + defer file.Close() + + data, err := io.ReadAll(file) + if err != nil { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusInternalServerError) + json.NewEncoder(w).Encode(map[string]string{"error": "reading file " + fr.field + ": " + err.Error()}) + return + } + + mimeType := header.Header.Get("Content-Type") + if mimeType == "" { + mimeType = fr.defaultMime + } + filename := header.Filename + if filename == "" { + filename = fr.field + ".bin" + } + + log.Printf("[BUGREPORT] File uploaded: role=%s size=%d bytes", fr.role, len(data)) + + _, err = db.ExecContext(r.Context(), + "INSERT INTO emly_bugreports.bug_report_files (report_id, file_role, filename, mime_type, file_size, data) VALUES (?, ?, ?, ?, ?, ?)", + reportID, fr.role, filename, mimeType, len(data), data, + ) + if err != nil { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusInternalServerError) + json.NewEncoder(w).Encode(map[string]string{"error": err.Error()}) + return + } + } + + log.Printf("[BUGREPORT] Created successfully with id=%d", reportID) + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusCreated) + json.NewEncoder(w).Encode(map[string]interface{}{ + "success": true, + "report_id": reportID, + "message": "Bug report submitted successfully", + }) + } +} + func GetAllBugReports(db *sqlx.DB) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { var reports []models.BugReport diff --git a/main.go b/main.go index e654a09..e11b42d 100644 --- a/main.go +++ b/main.go @@ -1,6 +1,7 @@ package main import ( + apimw "emly-api-go/internal/middleware" "fmt" "log" "net/http" @@ -15,7 +16,6 @@ import ( "emly-api-go/internal/config" "emly-api-go/internal/database" "emly-api-go/internal/handlers" - apimw "emly-api-go/internal/middleware" ) func main() { @@ -52,7 +52,7 @@ func main() { w.Write([]byte("emly-api-go")) }) - r.Route("/api/v1", func(r chi.Router) { + r.Route("/v1", func(r chi.Router) { r.Use(func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("X-Server", "emly-api-go") @@ -63,30 +63,33 @@ func main() { // Health – public, no API key required r.Get("/health", handlers.Health(db)) - // ROUTE: Bug Reports - Protected via API Key - r.Route("/bug-reports", func(r chi.Router) { - r.Group(func(r chi.Router) { - r.Use(apimw.APIKeyAuth(db)) + r.Route("/api", func(r chi.Router) { + // ROUTE: Bug Reports - Protected via API Key + r.Route("/bug-reports", func(r chi.Router) { + r.Group(func(r chi.Router) { + r.Use(apimw.APIKeyAuth(db)) - // Tighter rate-limit on protected group: 30 req / min per IP - r.Use(httprate.LimitByIP(30, time.Minute)) + // Tighter rate-limit on protected group: 30 req / min per IP + r.Use(httprate.LimitByIP(30, time.Minute)) - r.Get("/count", handlers.GetReportsCount(db)) - }) + r.Get("/count", handlers.GetReportsCount(db)) + r.Post("/", handlers.CreateBugReport(db)) + }) - r.Group(func(r chi.Router) { - // More strict auth due to sensitive info - r.Use(apimw.APIKeyAuth(db)) - r.Use(apimw.AdminKeyAuth(db)) + r.Group(func(r chi.Router) { + // More strict auth due to sensitive info + r.Use(apimw.APIKeyAuth(db)) + r.Use(apimw.AdminKeyAuth(db)) - // Tighter rate-limit on protected group: 30 req / min per IP - r.Use(httprate.LimitByIP(30, time.Minute)) + // Tighter rate-limit on protected group: 30 req / min per IP + r.Use(httprate.LimitByIP(30, time.Minute)) - r.Get("/", handlers.GetAllBugReports(db)) - r.Get("/{id}", handlers.GetBugReportByID(db)) - r.Get("/{id}/files", handlers.GetReportFilesByReportID(db)) - r.Get("/{id}/files/{file_id}", handlers.GetReportFileByFileID(db)) - r.Get("/{id}/zip", handlers.GetBugReportZipById(db)) + r.Get("/", handlers.GetAllBugReports(db)) + r.Get("/{id}", handlers.GetBugReportByID(db)) + r.Get("/{id}/files", handlers.GetReportFilesByReportID(db)) + r.Get("/{id}/files/{file_id}", handlers.GetReportFileByFileID(db)) + r.Get("/{id}/zip", handlers.GetBugReportZipById(db)) + }) }) })