diff --git a/internal/config/config.go b/internal/config/config.go index 417ab97..db7fff5 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -6,9 +6,10 @@ import ( ) type Config struct { - Port string - DSN string - APIKeys []string + Port string + DSN string + APIKey string + AdminKey string } func Load() *Config { @@ -17,18 +18,30 @@ func Load() *Config { port = "8080" } - raw := os.Getenv("API_KEYS") - var keys []string + raw := os.Getenv("API_KEY") + var apiKey string for _, k := range strings.Split(raw, ",") { k = strings.TrimSpace(k) if k != "" { - keys = append(keys, k) + apiKey = k + break + } + } + + raw = os.Getenv("ADMIN_KEY") + var adminKey string + for _, k := range strings.Split(raw, ",") { + k = strings.TrimSpace(k) + if k != "" { + adminKey = k + break } } return &Config{ - Port: port, - DSN: os.Getenv("DB_DSN"), - APIKeys: keys, + Port: port, + DSN: os.Getenv("DB_DSN"), + APIKey: apiKey, + AdminKey: adminKey, } } diff --git a/internal/handlers/bug_report_handler.route.go b/internal/handlers/bug_report.route.go similarity index 100% rename from internal/handlers/bug_report_handler.route.go rename to internal/handlers/bug_report.route.go diff --git a/internal/handlers/example.go b/internal/handlers/example.go deleted file mode 100644 index f062054..0000000 --- a/internal/handlers/example.go +++ /dev/null @@ -1,24 +0,0 @@ -package handlers - -import ( - "encoding/json" - "io" - "net/http" -) - -var ExampleGet http.HandlerFunc = func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(map[string]string{"message": "example GET"}) -} - -var ExamplePost http.HandlerFunc = func(w http.ResponseWriter, r *http.Request) { - body, _ := io.ReadAll(r.Body) - defer r.Body.Close() - - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusCreated) - json.NewEncoder(w).Encode(map[string]any{ - "message": "example POST", - "received": string(body), - }) -} diff --git a/internal/middleware/adminKey.go b/internal/middleware/adminKey.go new file mode 100644 index 0000000..71e45b5 --- /dev/null +++ b/internal/middleware/adminKey.go @@ -0,0 +1,36 @@ +package middleware + +import ( + "encoding/json" + "log" + "net/http" + + "github.com/jmoiron/sqlx" + + "emly-api-go/internal/config" +) + +func AdminKeyAuth(_ *sqlx.DB) func(http.Handler) http.Handler { + cfg := config.Load() + + if len(cfg.AdminKey) == 0 { + log.Panic("API key or admin key are empty") + return nil + } + + allowed := make(map[string]struct{}, 1) + allowed[cfg.AdminKey] = struct{}{} + + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + key := r.Header.Get("X-Admin-Key") + if _, ok := allowed[key]; !ok { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusUnauthorized) + json.NewEncoder(w).Encode(map[string]string{"error": "unauthorized admin key"}) + return + } + next.ServeHTTP(w, r) + }) + } +} diff --git a/internal/middleware/apikey.go b/internal/middleware/apikey.go index 45ba10e..7dc8766 100644 --- a/internal/middleware/apikey.go +++ b/internal/middleware/apikey.go @@ -2,6 +2,7 @@ package middleware import ( "encoding/json" + "log" "net/http" "github.com/jmoiron/sqlx" @@ -12,11 +13,14 @@ import ( func APIKeyAuth(_ *sqlx.DB) func(http.Handler) http.Handler { cfg := config.Load() - allowed := make(map[string]struct{}, len(cfg.APIKeys)) - for _, k := range cfg.APIKeys { - allowed[k] = struct{}{} + if len(cfg.APIKey) == 0 { + log.Panic("API key or admin key are empty") + return nil } + allowed := make(map[string]struct{}, 1) + allowed[cfg.APIKey] = struct{}{} + return func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { key := r.Header.Get("X-API-Key") diff --git a/main.go b/main.go index a8bd2bd..e654a09 100644 --- a/main.go +++ b/main.go @@ -9,6 +9,7 @@ import ( "github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5/middleware" "github.com/go-chi/httprate" + "github.com/jmoiron/sqlx" "github.com/joho/godotenv" "emly-api-go/internal/config" @@ -27,27 +28,31 @@ func main() { if err != nil { log.Fatalf("database connection failed: %v", err) } - defer db.Close() + defer func(db *sqlx.DB) { + err := db.Close() + if err != nil { + log.Fatalf("closing database failed: %v", err) + } + }(db) r := chi.NewRouter() - // ── Global middleware ──────────────────────────────────────────────────── + // Global middlewares r.Use(middleware.RequestID) r.Use(middleware.RealIP) r.Use(middleware.Logger) r.Use(middleware.Recoverer) r.Use(middleware.Timeout(30 * time.Second)) - // ── Global rate-limit: 100 req / min per IP ────────────────────────────── + // Global rate limit to 100 requests per minute r.Use(httprate.LimitByIP(100, time.Minute)) - // ── Public routes ──────────────────────────────────────────────────────── + // Public routes (Not protected by any API Key) r.Get("/", func(w http.ResponseWriter, r *http.Request) { w.Write([]byte("emly-api-go")) }) 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") @@ -58,17 +63,7 @@ func main() { // Health – public, no API key required r.Get("/health", handlers.Health(db)) - // ── Protected routes: require valid API key ────────────────────────── - 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("/example", handlers.ExampleGet) - r.Post("/example", handlers.ExamplePost) - }) - + // 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)) @@ -76,9 +71,19 @@ func main() { // 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.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)) + 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))