From 576ce0b1b5a2d893eca80619248ff8ab665ae7f7 Mon Sep 17 00:00:00 2001 From: Flavio Fois Date: Mon, 23 Mar 2026 21:29:50 +0100 Subject: [PATCH] add v2 API routes for admin and bug report management with rate limiting --- internal/handlers/bug_report.route.go | 37 +++++++++++++++------- internal/models/bug_report.go | 18 +++++++++++ internal/models/bug_report_file.go | 7 +++-- internal/routes/routes.go | 2 ++ internal/routes/v1/admin.go | 15 ++++++--- internal/routes/v1/v1.go | 11 +++++++ internal/routes/v2/admin.go | 37 ++++++++++++++++++++++ internal/routes/v2/bug_reports.go | 41 ++++++++++++++++++++++++ internal/routes/v2/v2.go | 45 +++++++++++++++++++++++++++ 9 files changed, 195 insertions(+), 18 deletions(-) create mode 100644 internal/routes/v2/admin.go create mode 100644 internal/routes/v2/bug_reports.go create mode 100644 internal/routes/v2/v2.go diff --git a/internal/handlers/bug_report.route.go b/internal/handlers/bug_report.route.go index 4668f83..f93aa06 100644 --- a/internal/handlers/bug_report.route.go +++ b/internal/handlers/bug_report.route.go @@ -35,9 +35,10 @@ var fileRoles = []struct { role models.FileRole defaultMime string }{ - {"attachment", models.FileRoleAttachment, "application/octet-stream"}, {"screenshot", models.FileRoleScreenshot, "image/png"}, - {"log", models.FileRoleLog, "text/plain"}, + {"mail_file", models.FileRoleMailFile, "message/rfc822"}, + {"localstorage", models.FileRoleLocalStorage, "application/json"}, + {"config", models.FileRoleConfig, "application/json"}, } func CreateBugReport(db *sqlx.DB) http.HandlerFunc { @@ -47,6 +48,8 @@ func CreateBugReport(db *sqlx.DB) http.HandlerFunc { return } + log.Println("Req form value", r.Form) + name := r.FormValue("name") email := r.FormValue("email") description := r.FormValue("description") @@ -91,7 +94,9 @@ func CreateBugReport(db *sqlx.DB) http.HandlerFunc { } for _, fr := range fileRoles { + log.Println("Processing file role", fr.field) file, header, err := r.FormFile(fr.field) + log.Printf("FormFile for field %s returned error: %v", fr.field, err) if err != nil { continue } @@ -153,17 +158,12 @@ func GetAllBugReports(db *sqlx.DB) http.HandlerFunc { } } - status := r.URL.Query().Get("status") search := r.URL.Query().Get("search") offset := (page - 1) * pageSize var conditions []string var params []interface{} - if status != "" { - conditions = append(conditions, "br.status = ?") - params = append(params, status) - } if search != "" { like := "%" + search + "%" conditions = append(conditions, "(br.hostname LIKE ? OR br.os_user LIKE ? OR br.name LIKE ? OR br.email LIKE ?)") @@ -176,20 +176,20 @@ func GetAllBugReports(db *sqlx.DB) http.HandlerFunc { } var total int - countQuery := fmt.Sprintf("SELECT COUNT(*) FROM emly_bugreports_dev.bug_reports br " + whereClause) + countQuery := "SELECT COUNT(*) FROM emly_bugreports_dev.bug_reports br " + whereClause if err := db.GetContext(r.Context(), &total, countQuery, params...); err != nil { jsonError(w, http.StatusInternalServerError, err.Error()) return } - mainQuery := fmt.Sprintf(` + mainQuery := ` SELECT br.*, COUNT(bf.id) as file_count FROM emly_bugreports_dev.bug_reports br LEFT JOIN emly_bugreports_dev.bug_report_files bf ON bf.report_id = br.id ` + whereClause + ` GROUP BY br.id ORDER BY br.created_at DESC - LIMIT ? OFFSET ?`) + LIMIT ? OFFSET ?` listParams := append(params, pageSize, offset) var reports []models.BugReportListItem @@ -241,8 +241,23 @@ func GetBugReportByID(db *sqlx.DB) http.HandlerFunc { func GetReportsCount(db *sqlx.DB) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { + rawStatus := r.URL.Query().Get("status") + + query := "SELECT COUNT(*) FROM emly_bugreports_dev.bug_reports" + var args []interface{} + + if strings.TrimSpace(rawStatus) != "" { + status, ok := models.ParseBugReportStatus(rawStatus) + if !ok { + jsonError(w, http.StatusBadRequest, "invalid status. allowed values: new, in_review, resolved, closed") + return + } + query += " WHERE status = ?" + args = append(args, status) + } + var count int - if err := db.GetContext(r.Context(), &count, "SELECT COUNT(*) FROM emly_bugreports_dev.bug_reports"); err != nil { + if err := db.GetContext(r.Context(), &count, query, args...); err != nil { jsonError(w, http.StatusInternalServerError, err.Error()) return } diff --git a/internal/models/bug_report.go b/internal/models/bug_report.go index 3163707..c61e9a8 100644 --- a/internal/models/bug_report.go +++ b/internal/models/bug_report.go @@ -2,6 +2,7 @@ package models import ( "encoding/json" + "strings" "time" ) @@ -14,6 +15,23 @@ const ( BugReportStatusClosed BugReportStatus = "closed" ) +func (s BugReportStatus) IsValid() bool { + switch s { + case BugReportStatusNew, BugReportStatusInReview, BugReportStatusResolved, BugReportStatusClosed: + return true + default: + return false + } +} + +func ParseBugReportStatus(value string) (BugReportStatus, bool) { + status := BugReportStatus(strings.ToLower(strings.TrimSpace(value))) + if status == "" { + return "", false + } + return status, status.IsValid() +} + type BugReportListItem struct { BugReport FileCount int `db:"file_count" json:"file_count"` diff --git a/internal/models/bug_report_file.go b/internal/models/bug_report_file.go index 9d8e24a..d0117d2 100644 --- a/internal/models/bug_report_file.go +++ b/internal/models/bug_report_file.go @@ -5,9 +5,10 @@ import "time" type FileRole string const ( - FileRoleAttachment FileRole = "attachment" - FileRoleScreenshot FileRole = "screenshot" - FileRoleLog FileRole = "log" + FileRoleScreenshot FileRole = "screenshot" + FileRoleMailFile FileRole = "mail_file" + FileRoleLocalStorage FileRole = "localstorage" + FileRoleConfig FileRole = "config" ) type BugReportFile struct { diff --git a/internal/routes/routes.go b/internal/routes/routes.go index 07f2b72..a8aff3e 100644 --- a/internal/routes/routes.go +++ b/internal/routes/routes.go @@ -1,6 +1,7 @@ package routes import ( + v2 "emly-api-go/internal/routes/v2" "net/http" v1 "emly-api-go/internal/routes/v1" @@ -22,4 +23,5 @@ func RegisterAll(r chi.Router, db *sqlx.DB) { }) r.Mount("/v1", v1.NewRouter(db)) + r.Mount("/v2", v2.NewRouter(db)) } diff --git a/internal/routes/v1/admin.go b/internal/routes/v1/admin.go index 7301796..8777bbd 100644 --- a/internal/routes/v1/admin.go +++ b/internal/routes/v1/admin.go @@ -13,17 +13,24 @@ import ( func registerAdmin(r chi.Router, db *sqlx.DB) { r.Route("/admin", func(r chi.Router) { - r.Use(httprate.LimitByIP(30, time.Minute)) - // Auth — public, handles its own credential checks + // Auth — public, handles its own credential checks. + // Only /login is rate-limited: it is the only endpoint vulnerable to + // brute-force. /validate and /logout require a 256-bit session token + // and are called frequently by authenticated clients, so no limit is + // applied there. r.Route("/auth", func(r chi.Router) { - r.Post("/login", handlers.LoginUser(db)) + r.Group(func(r chi.Router) { + r.Use(httprate.LimitByIP(30, time.Minute)) + r.Post("/login", handlers.LoginUser(db)) + }) r.Get("/validate", handlers.ValidateSession(db)) r.Post("/logout", handlers.LogoutSession(db)) }) // User management — protected via Admin Key r.Route("/users", func(r chi.Router) { + r.Use(httprate.LimitByIP(30, time.Minute)) r.Use(apimw.AdminKeyAuth(db)) r.Get("/", handlers.ListUsers(db)) @@ -34,4 +41,4 @@ func registerAdmin(r chi.Router, db *sqlx.DB) { r.Delete("/{id}", handlers.DeleteUser(db)) }) }) -} \ No newline at end of file +} diff --git a/internal/routes/v1/v1.go b/internal/routes/v1/v1.go index 67c29fe..f0af0d3 100644 --- a/internal/routes/v1/v1.go +++ b/internal/routes/v1/v1.go @@ -1,7 +1,9 @@ package v1 import ( + emlyMiddleware "emly-api-go/internal/middleware" "net/http" + "time" "emly-api-go/internal/handlers" @@ -15,6 +17,15 @@ import ( func NewRouter(db *sqlx.DB) http.Handler { r := chi.NewRouter() + rl := emlyMiddleware.NewRateLimiter( + 5, // 5 req/sec per IP + 10, // burst fino a 10 + 20, // ban dopo 20 violazioni + 15*time.Minute, // ban di 15 minuti + ) + + r.Use(rl.Handler) + 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") diff --git a/internal/routes/v2/admin.go b/internal/routes/v2/admin.go new file mode 100644 index 0000000..349e358 --- /dev/null +++ b/internal/routes/v2/admin.go @@ -0,0 +1,37 @@ +package v2 + +import ( + apimw "emly-api-go/internal/middleware" + "time" + + "emly-api-go/internal/handlers" + + "github.com/go-chi/chi/v5" + "github.com/go-chi/httprate" + "github.com/jmoiron/sqlx" +) + +func registerAdmin(r chi.Router, db *sqlx.DB) { + r.Route("/admin", func(r chi.Router) { + r.Use(httprate.LimitByIP(30, time.Minute)) + + // Auth — public, handles its own credential checks + r.Route("/auth", func(r chi.Router) { + r.Post("/login", handlers.LoginUser(db)) + r.Get("/validate", handlers.ValidateSession(db)) + r.Post("/logout", handlers.LogoutSession(db)) + }) + + // User management — protected via Admin Key + r.Route("/users", func(r chi.Router) { + r.Use(apimw.AdminKeyAuth(db)) + + r.Get("/", handlers.ListUsers(db)) + r.Post("/", handlers.CreateUser(db)) + r.Get("/{id}", handlers.GetUserByID(db)) + r.Patch("/{id}", handlers.UpdateUser(db)) + r.Post("/{id}/reset-password", handlers.ResetPassword(db)) + r.Delete("/{id}", handlers.DeleteUser(db)) + }) + }) +} diff --git a/internal/routes/v2/bug_reports.go b/internal/routes/v2/bug_reports.go new file mode 100644 index 0000000..405fb46 --- /dev/null +++ b/internal/routes/v2/bug_reports.go @@ -0,0 +1,41 @@ +package v2 + +import ( + apimw "emly-api-go/internal/middleware" + "time" + + "emly-api-go/internal/handlers" + + "github.com/go-chi/chi/v5" + "github.com/go-chi/httprate" + "github.com/jmoiron/sqlx" +) + +func registerBugReports(r chi.Router, db *sqlx.DB) { + r.Route("/bug-report", func(r chi.Router) { + // API key only: submit a report and check count + r.Group(func(r chi.Router) { + r.Use(apimw.APIKeyAuth(db)) + r.Use(httprate.LimitByIP(30, time.Minute)) + + r.Get("/count", handlers.GetReportsCount(db)) + r.Post("/", handlers.CreateBugReport(db)) + }) + + // API key + admin key: full read/write access + r.Group(func(r chi.Router) { + r.Use(apimw.APIKeyAuth(db)) + r.Use(apimw.AdminKeyAuth(db)) + r.Use(httprate.LimitByIP(30, time.Minute)) + + r.Get("/", handlers.GetAllBugReports(db)) + r.Get("/{id}", handlers.GetBugReportByID(db)) + r.Get("/{id}/status", handlers.GetReportStatusByID(db)) + r.Get("/{id}/files", handlers.GetReportFilesByReportID(db)) + r.Get("/{id}/files/{file_id}", handlers.GetReportFileByFileID(db)) + r.Get("/{id}/download", handlers.GetBugReportZipById(db)) + r.Patch("/{id}/status", handlers.PatchBugReportStatus(db)) + r.Delete("/{id}", handlers.DeleteBugReportByID(db)) + }) + }) +} diff --git a/internal/routes/v2/v2.go b/internal/routes/v2/v2.go new file mode 100644 index 0000000..710a3da --- /dev/null +++ b/internal/routes/v2/v2.go @@ -0,0 +1,45 @@ +package v2 + +import ( + emlyMiddleware "emly-api-go/internal/middleware" + "net/http" + "time" + + "emly-api-go/internal/handlers" + + "github.com/go-chi/chi/v5" + "github.com/jmoiron/sqlx" +) + +// NewRouter returns a chi.Router with all /v1 routes mounted. +// Add new API versions by creating an analogous package (e.g. v2) and +// mounting it alongside this one in internal/routes/routes.go. +func NewRouter(db *sqlx.DB) http.Handler { + r := chi.NewRouter() + + rl := emlyMiddleware.NewRateLimiter( + 5, // 5 req/sec per IP + 10, // burst fino a 10 + 20, // ban dopo 20 violazioni + 15*time.Minute, // ban di 15 minuti + ) + + r.Use(rl.Handler) + + 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") + w.Header().Set("X-Powered-By", "Rexouium in a suit") + next.ServeHTTP(w, r) + }) + }) + + r.Get("/health", handlers.Health(db)) + + r.Route("/api", func(r chi.Router) { + registerAdmin(r, db) + registerBugReports(r, db) + }) + + return r +}