From 8097be88a6f29aeabb770c3ce7f3c1a8bd087c57 Mon Sep 17 00:00:00 2001 From: Flavio Fois Date: Tue, 17 Mar 2026 15:08:54 +0100 Subject: [PATCH] refactor bug report structure and add API endpoints for bug report management --- go.mod | 4 +- go.sum | 4 + internal/handlers/bug_report_handler.route.go | 243 ++++++++++++++++++ internal/models/bug_report.go | 20 +- internal/models/bug_report_file.go | 5 +- main.go | 25 ++ 6 files changed, 289 insertions(+), 12 deletions(-) create mode 100644 internal/handlers/bug_report_handler.route.go diff --git a/go.mod b/go.mod index a60502a..ee318de 100644 --- a/go.mod +++ b/go.mod @@ -3,14 +3,14 @@ module emly-api-go go 1.26 require ( - github.com/go-chi/chi/v5 v5.1.0 + github.com/go-chi/chi/v5 v5.2.4 github.com/go-chi/httprate v0.14.1 github.com/go-sql-driver/mysql v1.8.1 github.com/joho/godotenv v1.5.1 ) require ( - filippo.io/edwards25519 v1.1.0 // indirect + filippo.io/edwards25519 v1.1.1 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/jmoiron/sqlx v1.3.5 ) diff --git a/go.sum b/go.sum index ba0a05e..6d19146 100644 --- a/go.sum +++ b/go.sum @@ -1,9 +1,13 @@ filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= +filippo.io/edwards25519 v1.1.1 h1:YpjwWWlNmGIDyXOn8zLzqiD+9TyIlPhGFG96P39uBpw= +filippo.io/edwards25519 v1.1.1/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/go-chi/chi/v5 v5.1.0 h1:acVI1TYaD+hhedDJ3r54HyA6sExp3HfXq7QWEEY/xMw= github.com/go-chi/chi/v5 v5.1.0/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8= +github.com/go-chi/chi/v5 v5.2.4 h1:WtFKPHwlywe8Srng8j2BhOD9312j9cGUxG1SP4V2cR4= +github.com/go-chi/chi/v5 v5.2.4/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0= github.com/go-chi/httprate v0.14.1 h1:EKZHYEZ58Cg6hWcYzoZILsv7ppb46Wt4uQ738IRtpZs= github.com/go-chi/httprate v0.14.1/go.mod h1:TUepLXaz/pCjmCtf/obgOQJ2Sz6rC8fSf5cAt5cnTt0= github.com/go-sql-driver/mysql v1.6.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LBy8hT2VhHyBg= diff --git a/internal/handlers/bug_report_handler.route.go b/internal/handlers/bug_report_handler.route.go new file mode 100644 index 0000000..544cbd7 --- /dev/null +++ b/internal/handlers/bug_report_handler.route.go @@ -0,0 +1,243 @@ +package handlers + +import ( + "archive/zip" + "bytes" + "database/sql" + "encoding/json" + "errors" + "fmt" + "net/http" + + "github.com/go-chi/chi/v5" + "github.com/jmoiron/sqlx" + + "emly-api-go/internal/models" +) + +func GetAllBugReports(db *sqlx.DB) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var reports []models.BugReport + if err := db.SelectContext(r.Context(), &reports, "SELECT * FROM emly_bugreports.bug_reports"); err != nil { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusInternalServerError) + json.NewEncoder(w).Encode(map[string]string{"error": err.Error()}) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(reports) + } +} + +func GetBugReportByID(db *sqlx.DB) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + id := chi.URLParam(r, "id") + if id == "" { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusBadRequest) + json.NewEncoder(w).Encode(map[string]string{"error": "missing id parameter"}) + return + } + + var report models.BugReport + err := db.GetContext(r.Context(), &report, "SELECT * FROM emly_bugreports.bug_reports WHERE id = ?", id) + if errors.Is(err, sql.ErrNoRows) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusNotFound) + json.NewEncoder(w).Encode(map[string]string{"error": "bug report not found"}) + return + } + 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 + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(report) + } +} + +func GetReportsCount(db *sqlx.DB) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var count int + if err := db.GetContext(r.Context(), &count, "SELECT COUNT(*) FROM emly_bugreports.bug_reports"); err != nil { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusInternalServerError) + json.NewEncoder(w).Encode(map[string]string{"error": err.Error()}) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]int{"count": count}) + } +} + +func GetReportFilesByReportID(db *sqlx.DB) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + id := chi.URLParam(r, "id") + if id == "" { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusBadRequest) + json.NewEncoder(w).Encode(map[string]string{"error": "missing id parameter"}) + return + } + + var files []models.BugReportFile + if err := db.SelectContext(r.Context(), &files, "SELECT * FROM emly_bugreports.bug_report_files WHERE report_id = ?", id); err != nil { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusInternalServerError) + json.NewEncoder(w).Encode(map[string]string{"error": err.Error()}) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(files) + } +} + +func GetBugReportZipById(db *sqlx.DB) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + id := chi.URLParam(r, "id") + if id == "" { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusBadRequest) + json.NewEncoder(w).Encode(map[string]string{"error": "missing id parameter"}) + return + } + + var report models.BugReport + err := db.GetContext(r.Context(), &report, "SELECT * FROM emly_bugreports.bug_reports WHERE id = ?", id) + if errors.Is(err, sql.ErrNoRows) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusNotFound) + json.NewEncoder(w).Encode(map[string]string{"error": "bug report not found"}) + return + } + 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 + } + + var files []models.BugReportFile + if err := db.SelectContext(r.Context(), &files, "SELECT * FROM emly_bugreports.bug_report_files WHERE report_id = ?", id); err != nil { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusInternalServerError) + json.NewEncoder(w).Encode(map[string]string{"error": err.Error()}) + return + } + + reportText := fmt.Sprintf( + "Bug Report #%d\n========================\n\nName: %s\nEmail: %s\nHostname: %s\nOS User: %s\nHWID: %s\nIP: %s\nStatus: %s\nCreated: %s\nUpdated: %s\n\nDescription:\n------------\n%s\n", + report.ID, + report.Name, + report.Email, + report.Hostname, + report.OsUser, + report.HWID, + report.SubmitterIP, + report.Status, + report.CreatedAt.UTC().Format("2006-01-02T15:04:05.000Z"), + report.UpdatedAt.UTC().Format("2006-01-02T15:04:05.000Z"), + report.Description, + ) + if len(report.SystemInfo) > 0 && string(report.SystemInfo) != "null" { + pretty, err := json.MarshalIndent(report.SystemInfo, "", " ") + if err == nil { + reportText += fmt.Sprintf("\nSystem Info:\n------------\n%s\n", string(pretty)) + } + } + + var buf bytes.Buffer + zw := zip.NewWriter(&buf) + + rf, err := zw.Create("report.txt") + 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 + } + if _, err = rf.Write([]byte(reportText)); 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 _, file := range files { + ff, err := zw.Create(fmt.Sprintf("%s/%s", file.FileRole, file.Filename)) + 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 + } + if _, err = ff.Write(file.Data); err != nil { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusInternalServerError) + json.NewEncoder(w).Encode(map[string]string{"error": err.Error()}) + return + } + } + + if err := zw.Close(); err != nil { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusInternalServerError) + json.NewEncoder(w).Encode(map[string]string{"error": err.Error()}) + return + } + + w.Header().Set("Content-Type", "application/zip") + w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=\"report-%d.zip\"", report.ID)) + w.Write(buf.Bytes()) + } +} + +func GetReportFileByFileID(db *sqlx.DB) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + reportId := chi.URLParam(r, "id") + if reportId == "" { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusBadRequest) + json.NewEncoder(w).Encode(map[string]string{"error": "missing report id parameter"}) + return + } + fileId := chi.URLParam(r, "file_id") + if fileId == "" { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusBadRequest) + json.NewEncoder(w).Encode(map[string]string{"error": "missing file id parameter"}) + return + } + var file models.BugReportFile + err := db.GetContext(r.Context(), &file, "SELECT filename, mime_type, data FROM emly_bugreports.bug_report_files WHERE report_id = ? AND id = ?", reportId, fileId) + if errors.Is(err, sql.ErrNoRows) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusNotFound) + json.NewEncoder(w).Encode(map[string]string{"error": "file not found"}) + return + } + 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 + } + + mimeType := file.MimeType + if mimeType == "" { + mimeType = "application/octet-stream" + } + w.Header().Set("Content-Type", mimeType) + w.Header().Set("Content-Disposition", "attachment; filename=\""+file.Filename+"\"") + _, err = w.Write(file.Data) + if err != nil { + return + } + } +} diff --git a/internal/models/bug_report.go b/internal/models/bug_report.go index 194d6d5..02e5bd0 100644 --- a/internal/models/bug_report.go +++ b/internal/models/bug_report.go @@ -8,19 +8,23 @@ import ( type BugReportStatus string const ( - BugReportStatusOpen BugReportStatus = "open" - BugReportStatusInProgress BugReportStatus = "in_progress" - BugReportStatusResolved BugReportStatus = "resolved" - BugReportStatusClosed BugReportStatus = "closed" + BugReportStatusNew BugReportStatus = "new" + BugReportStatusInReview BugReportStatus = "in_review" + BugReportStatusResolved BugReportStatus = "resolved" + BugReportStatusClosed BugReportStatus = "closed" ) type BugReport struct { - ID int64 `db:"id" json:"id"` - UserID *int64 `db:"user_id" json:"user_id"` - Title string `db:"title" json:"title"` + ID uint64 `db:"id" json:"id"` + Name string `db:"name" json:"name"` + Email string `db:"email" json:"email"` Description string `db:"description" json:"description"` - Status BugReportStatus `db:"status" json:"status"` + HWID string `db:"hwid" json:"hwid"` + Hostname string `db:"hostname" json:"hostname"` + OsUser string `db:"os_user" json:"os_user"` + SubmitterIP string `db:"submitter_ip" json:"submitter_ip"` SystemInfo json.RawMessage `db:"system_info" json:"system_info,omitempty"` + Status BugReportStatus `db:"status" json:"status"` CreatedAt time.Time `db:"created_at" json:"created_at"` UpdatedAt time.Time `db:"updated_at" json:"updated_at"` } diff --git a/internal/models/bug_report_file.go b/internal/models/bug_report_file.go index d7a9cce..9d8e24a 100644 --- a/internal/models/bug_report_file.go +++ b/internal/models/bug_report_file.go @@ -12,10 +12,11 @@ const ( type BugReportFile struct { ID int64 `db:"id" json:"id"` - BugReportID int64 `db:"bug_report_id" json:"bug_report_id"` + BugReportID int64 `db:"report_id" json:"report_id"` + FileRole FileRole `db:"file_role" json:"file_role"` Filename string `db:"filename" json:"filename"` MimeType string `db:"mime_type" json:"mime_type"` - Role FileRole `db:"role" json:"role"` + FileSize int64 `db:"file_size" json:"file_size"` Data []byte `db:"data" json:"-"` CreatedAt time.Time `db:"created_at" json:"created_at"` } diff --git a/main.go b/main.go index 23807b8..a8bd2bd 100644 --- a/main.go +++ b/main.go @@ -47,6 +47,14 @@ func main() { }) r.Route("/api/v1", func(r chi.Router) { + // Add a header called X-Server + 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") + next.ServeHTTP(w, r) + }) + }) + // Health – public, no API key required r.Get("/health", handlers.Health(db)) @@ -60,6 +68,23 @@ func main() { r.Get("/example", handlers.ExampleGet) r.Post("/example", handlers.ExamplePost) }) + + 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)) + + r.Get("/", handlers.GetAllBugReports(db)) + r.Get("/{id}", handlers.GetBugReportByID(db)) + r.Get("/count", handlers.GetReportsCount(db)) + r.Get("/{id}/files", handlers.GetReportFilesByReportID(db)) + r.Get("/{id}/files/{file_id}", handlers.GetReportFileByFileID(db)) + r.Get("/{id}/zip", handlers.GetBugReportZipById(db)) + }) + }) + }) addr := fmt.Sprintf(":%s", cfg.Port)