From 28346571250e7a25a67d0e916d61333002842f0e Mon Sep 17 00:00:00 2001 From: Christoph Haas Date: Sun, 22 Mar 2026 23:28:28 +0100 Subject: [PATCH] Switch from net/http to gin-gonic web framework - Added gin-gonic v1.10.0 dependency - Refactored router.go: clean route groups with middleware chains - Refactored all handlers to use gin.Context instead of http.ResponseWriter/*http.Request - Simplified response helpers (JSON, Error, Success, Created, NoContent) - Clean auth middleware using Gin's c.Set() for context - Cleaner route definitions with path parameters (e.g., /domains/:name/users/:id) - Admin routes moved to /api/admin group with RequireAdmin middleware --- backend/cmd/server/main.go | 189 +++++---------- backend/go.mod | 25 ++ backend/go.sum | 85 +++++++ backend/internal/api/handlers/aliases.go | 95 +++----- backend/internal/api/handlers/auth.go | 122 ++++------ backend/internal/api/handlers/domains.go | 195 ++++++--------- backend/internal/api/handlers/logs.go | 16 +- backend/internal/api/handlers/middleware.go | 46 ++-- backend/internal/api/handlers/queue.go | 38 +-- backend/internal/api/handlers/response.go | 27 +-- backend/internal/api/handlers/stats.go | 12 +- backend/internal/api/handlers/users.go | 188 ++++++--------- backend/internal/api/router.go | 248 ++++---------------- 13 files changed, 474 insertions(+), 812 deletions(-) diff --git a/backend/cmd/server/main.go b/backend/cmd/server/main.go index c41b193..4ab763e 100644 --- a/backend/cmd/server/main.go +++ b/backend/cmd/server/main.go @@ -11,6 +11,7 @@ import ( "os" // OS-level operations like reading command-line args "strings" // string manipulation utilities + "github.com/gin-gonic/gin" // web framework "github.com/imc-vibe/backend/internal/api" // HTTP API routing and handlers "github.com/imc-vibe/backend/internal/auth" // JWT authentication "github.com/imc-vibe/backend/internal/config" // configuration loading @@ -110,37 +111,47 @@ func main() { // The frontend is compiled into the binary during build time. frontendFS := FrontendFileSystem() - // Create a new HTTP request multiplexer (router). - // The router maps URL paths to handler functions. - router := http.NewServeMux() + // Set Gin to release mode for production. + // This disables debug logging and other development features. + gin.SetMode(gin.ReleaseMode) - // Add health check endpoint. - // This is useful for load balancers and monitoring systems. - // Returns {"status":"ok"} with HTTP 200 status. - router.HandleFunc("/api/health", func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") - w.Write([]byte(`{"status":"ok"}`)) - }) + // Create a new Gin engine (router). + engine := gin.New() + engine.Use(gin.Recovery()) - // Set up the API router for all /api/* endpoints. - // The API router handles authentication, domain management, user management, etc. + // Register API routes from the router. apiRouter := api.New(database, cfg) - router.Handle("/api/", apiRouter.Handler()) // The trailing slash is important! + apiRouter.RegisterRoutes(engine) - // Serve static files from the embedded frontend. - // These routes handle the SvelteKit frontend application. + // Set up SPA fallback for non-API routes. + // This must be the last route to catch all unmatched paths. + engine.NoRoute(func(c *gin.Context) { + path := c.Request.URL.Path - // Serve the favicon at /favicon.png - router.HandleFunc("/favicon.png", serveStaticFile(frontendFS, "embed/favicon.png")) + // If it's an API path, return 404. + // The API router should have handled /api/* routes. + if strings.HasPrefix(path, "/api/") { + c.JSON(http.StatusNotFound, gin.H{"error": "not found"}) + return + } - // Serve SvelteKit's hashed assets (JavaScript, CSS) at /_app/* - // The StripPrefix removes "/_app" from the URL before looking up the file. - router.Handle("/_app/", http.StripPrefix("/_app/", serveStaticPrefixed(frontendFS, "embed/_app/"))) + // Serve SvelteKit hashed assets at /_app/* + if strings.HasPrefix(path, "/_app/") { + assetPath := "embed/_app/" + strings.TrimPrefix(path, "/_app/") + serveGinStaticFile(c, frontendFS, assetPath) + return + } - // Serve the main HTML file for all other routes. - // This is a Single Page Application (SPA) router - all routes serve index.html, - // and JavaScript handles showing the correct page. - router.HandleFunc("/", serveSPA(frontendFS)) + // Serve favicon + if path == "/favicon.png" { + serveGinStaticFile(c, frontendFS, "embed/favicon.png") + return + } + + // For all other paths, serve the SPA index.html. + // This allows client-side routing (e.g., /domains/example.org). + serveGinStaticFile(c, frontendFS, "embed/index.html") + }) // Build the address string for binding: "IP:PORT" // Example: "0.0.0.0:8080" or "127.0.0.1:8080" @@ -152,7 +163,32 @@ func main() { // Start the HTTP server. // ListenAndServe blocks until the server is stopped. // log.Fatal prints the error and exits if the server can't start. - log.Fatal(http.ListenAndServe(addr, router)) + log.Fatal(http.ListenAndServe(addr, engine)) +} + +// serveGinStaticFile serves a static file from the embedded filesystem. +func serveGinStaticFile(c *gin.Context, fs http.FileSystem, path string) { + file, err := fs.Open(path) + if err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "not found"}) + return + } + defer file.Close() + + fi, err := file.Stat() + if err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "not found"}) + return + } + + if fi.IsDir() { + c.JSON(http.StatusNotFound, gin.H{"error": "not found"}) + return + } + + contentType := mimeType(path) + c.Header("Content-Type", contentType) + http.ServeContent(c.Writer, c.Request, fi.Name(), fi.ModTime(), file) } // generateRandomPassword creates a cryptographically secure random password. @@ -182,109 +218,6 @@ func generateRandomPassword(length int) string { return string(result) // Convert bytes to string } -// serveStaticPrefixed serves static files from a specific prefix in the embedded filesystem. -// fsys: the embedded filesystem containing the files. -// prefix: the directory prefix, e.g., "embed/_app/" for SvelteKit assets. -// Returns: an HTTP handler function. -func serveStaticPrefixed(fsys http.FileSystem, prefix string) http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { - // Build the full path by combining prefix and URL path. - // Example: prefix="embed/_app/", URL="/_app/chunk.js" -> "embed/_app/chunk.js" - path := prefix + r.URL.Path - - // Try to open the file from the embedded filesystem. - file, err := fsys.Open(path) - if err != nil { - // File not found - return 404. - http.NotFound(w, r) - return - } - defer file.Close() // Always close the file, even if there's an error later - - // Get file information (size, modification time, etc.). - fi, err := file.Stat() - if err != nil { - http.NotFound(w, r) - return - } - - // Don't serve directories - return 404. - if fi.IsDir() { - http.NotFound(w, r) - return - } - - // Set the Content-Type header based on file extension. - contentType := mimeType(path) - w.Header().Set("Content-Type", contentType) - - // Serve the file content. - // ServeContent handles Range requests (for video/audio), caching headers, etc. - http.ServeContent(w, r, fi.Name(), fi.ModTime(), file) - } -} - -// serveStaticFile serves a single static file at a fixed path. -// fsys: the embedded filesystem. -// filename: the exact path to the file, e.g., "embed/favicon.png". -// Returns: an HTTP handler function. -func serveStaticFile(fsys http.FileSystem, filename string) http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { - file, err := fsys.Open(filename) - if err != nil { - http.NotFound(w, r) - return - } - defer file.Close() - - fi, err := file.Stat() - if err != nil { - http.NotFound(w, r) - return - } - - contentType := mimeType(filename) - w.Header().Set("Content-Type", contentType) - http.ServeContent(w, r, fi.Name(), fi.ModTime(), file) - } -} - -// serveSPA serves the Single Page Application (SvelteKit frontend). -// It always serves index.html for any route that isn't an API call. -// The JavaScript running in the browser then handles routing to the correct page. -// fsys: the embedded filesystem containing the frontend files. -// Returns: an HTTP handler function. -func serveSPA(fsys http.FileSystem) http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { - path := r.URL.Path - - // If this looks like an API call, something is wrong - return 404. - // The API router should have handled /api/* routes. - // If we get here, the request slipped through somehow. - if strings.HasPrefix(path, "/api/") { - http.NotFound(w, r) - return - } - - // For SPA, always serve index.html regardless of the requested path. - // This allows client-side routing (e.g., /domains/example.org). - path = "embed/index.html" - - file, err := fsys.Open(path) - if err != nil { - log.Printf("Error opening %s: %v", path, err) - http.Error(w, "Not found", http.StatusNotFound) - return - } - defer file.Close() - fi, _ := file.Stat() - - // Tell the browser this is HTML. - w.Header().Set("Content-Type", "text/html; charset=utf-8") - http.ServeContent(w, r, fi.Name(), fi.ModTime(), file) - } -} - // mimeType returns the MIME type for a file based on its extension. // MIME types tell the browser what kind of file it is. // This is important for browsers to correctly interpret files. diff --git a/backend/go.mod b/backend/go.mod index 2be6bef..4c2ea4d 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -3,6 +3,7 @@ module github.com/imc-vibe/backend go 1.25.0 require ( + github.com/gin-gonic/gin v1.10.0 github.com/golang-jwt/jwt/v5 v5.3.1 github.com/joho/godotenv v1.5.1 golang.org/x/crypto v0.49.0 @@ -12,8 +13,32 @@ require ( require ( filippo.io/edwards25519 v1.1.0 // indirect + github.com/bytedance/sonic v1.11.6 // indirect + github.com/bytedance/sonic/loader v0.1.1 // indirect + github.com/cloudwego/base64x v0.1.4 // indirect + github.com/cloudwego/iasm v0.2.0 // indirect + github.com/gabriel-vasile/mimetype v1.4.3 // indirect + github.com/gin-contrib/sse v0.1.0 // indirect + github.com/go-playground/locales v0.14.1 // indirect + github.com/go-playground/universal-translator v0.18.1 // indirect + github.com/go-playground/validator/v10 v10.20.0 // indirect github.com/go-sql-driver/mysql v1.9.3 // indirect + github.com/goccy/go-json v0.10.2 // indirect github.com/jinzhu/inflection v1.0.0 // indirect github.com/jinzhu/now v1.1.5 // indirect + github.com/json-iterator/go v1.1.12 // indirect + github.com/klauspost/cpuid/v2 v2.2.7 // indirect + github.com/leodido/go-urn v1.4.0 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect + github.com/modern-go/reflect2 v1.0.2 // indirect + github.com/pelletier/go-toml/v2 v2.2.2 // indirect + github.com/twitchyliquid64/golang-asm v0.15.1 // indirect + github.com/ugorji/go/codec v1.2.12 // indirect + golang.org/x/arch v0.8.0 // indirect + golang.org/x/net v0.51.0 // indirect + golang.org/x/sys v0.42.0 // indirect golang.org/x/text v0.35.0 // indirect + google.golang.org/protobuf v1.34.1 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/backend/go.sum b/backend/go.sum index 87f32ff..ed4dee7 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -1,20 +1,105 @@ filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= +github.com/bytedance/sonic v1.11.6 h1:oUp34TzMlL+OY1OUWxHqsdkgC/Zfc85zGqw9siXjrc0= +github.com/bytedance/sonic v1.11.6/go.mod h1:LysEHSvpvDySVdC2f87zGWf6CIKJcAvqab1ZaiQtds4= +github.com/bytedance/sonic/loader v0.1.1 h1:c+e5Pt1k/cy5wMveRDyk2X4B9hF4g7an8N3zCYjJFNM= +github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU= +github.com/cloudwego/base64x v0.1.4 h1:jwCgWpFanWmN8xoIUHa2rtzmkd5J2plF/dnLS6Xd/0Y= +github.com/cloudwego/base64x v0.1.4/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w= +github.com/cloudwego/iasm v0.2.0 h1:1KNIy1I1H9hNNFEEH3DVnI4UujN+1zjpuk6gwHLTssg= +github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/gabriel-vasile/mimetype v1.4.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0= +github.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk= +github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE= +github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI= +github.com/gin-gonic/gin v1.10.0 h1:nTuyha1TYqgedzytsKYqna+DfLos46nTv2ygFy86HFU= +github.com/gin-gonic/gin v1.10.0/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y= +github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= +github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= +github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= +github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= +github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= +github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= +github.com/go-playground/validator/v10 v10.20.0 h1:K9ISHbSaI0lyB2eWMPJo+kOS/FBExVwjEviJTixqxL8= +github.com/go-playground/validator/v10 v10.20.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM= github.com/go-sql-driver/mysql v1.9.3 h1:U/N249h2WzJ3Ukj8SowVFjdtZKfu9vlLZxjPXV1aweo= github.com/go-sql-driver/mysql v1.9.3/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU= +github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU= +github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I= github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= +github.com/google/go-cmp v0.5.5 h1:Khx7svrCpmxxtHBq5j2mp/xVjsi8hQMfNLvJFAlrGgU= +github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E= github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= +github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= +github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= +github.com/klauspost/cpuid/v2 v2.2.7 h1:ZWSB3igEs+d0qvnxR/ZBzXVmxkgt8DdzP6m9pfuVLDM= +github.com/klauspost/cpuid/v2 v2.2.7/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws= +github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M= +github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= +github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= +github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM= +github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= +github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= +github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= +github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE= +github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg= +golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8= +golang.org/x/arch v0.8.0 h1:3wRIsP3pM4yUptoR96otTUOXI367OS0+c9eeRi9doIc= +golang.org/x/arch v0.8.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys= golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4= golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA= +golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo= +golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y= +golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= +golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8= golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +google.golang.org/protobuf v1.34.1 h1:9ddQBjfCyZPOHPUiPxpYESBLc+T8P3E+Vo4IbKZgFWg= +google.golang.org/protobuf v1.34.1/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gorm.io/driver/mysql v1.6.0 h1:eNbLmNTpPpTOVZi8MMxCi2aaIm0ZpInbORNXDwyLGvg= gorm.io/driver/mysql v1.6.0/go.mod h1:D/oCC2GWK3M/dqoLxnOlaNKmXz8WNTfcS9y5ovaSqKo= gorm.io/gorm v1.31.1 h1:7CA8FTFz/gRfgqgpeKIBcervUn3xSyPUmr6B2WXJ7kg= gorm.io/gorm v1.31.1/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs= +nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50= +rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4= diff --git a/backend/internal/api/handlers/aliases.go b/backend/internal/api/handlers/aliases.go index bb60f3e..879dfa6 100644 --- a/backend/internal/api/handlers/aliases.go +++ b/backend/internal/api/handlers/aliases.go @@ -1,11 +1,10 @@ package handlers import ( - "encoding/json" "net/http" "strconv" - "strings" + "github.com/gin-gonic/gin" "github.com/imc-vibe/backend/internal/db" ) @@ -18,143 +17,115 @@ func NewAliasHandler(database *db.DB) *AliasHandler { } type CreateAliasRequest struct { - Source string `json:"source"` - Destination string `json:"destination"` + Source string `json:"source" binding:"required"` + Destination string `json:"destination" binding:"required"` } -func (h *AliasHandler) List(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodGet { - Error(w, http.StatusMethodNotAllowed, "method not allowed") - return - } - - domainName := extractDomainNameFromPath(r.URL.Path) +func (h *AliasHandler) List(c *gin.Context) { + domainName := c.Param("name") if domainName == "" { - Error(w, http.StatusBadRequest, "domain name required") + Error(c, http.StatusBadRequest, "domain name required") return } - authCtx := GetAuthContext(r) + authCtx := GetAuthContext(c) if authCtx == nil { - Error(w, http.StatusUnauthorized, "authentication required") + Error(c, http.StatusUnauthorized, "authentication required") return } canAccess, _ := h.db.CanAccessDomain(authCtx.UserID, domainName, authCtx.IsAdmin()) if !canAccess { - Error(w, http.StatusForbidden, "access denied") + Error(c, http.StatusForbidden, "access denied") return } domain, err := h.db.GetDomainByName(domainName) if err != nil { - Error(w, http.StatusNotFound, "domain not found") + Error(c, http.StatusNotFound, "domain not found") return } aliases, err := h.db.GetAliasesByDomain(domain.ID) if err != nil { - Error(w, http.StatusInternalServerError, "database error") + Error(c, http.StatusInternalServerError, "database error") return } - Success(w, aliases) + Success(c, aliases) } -func (h *AliasHandler) Create(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodPost { - Error(w, http.StatusMethodNotAllowed, "method not allowed") - return - } - - domainName := extractDomainNameFromPath(r.URL.Path) +func (h *AliasHandler) Create(c *gin.Context) { + domainName := c.Param("name") if domainName == "" { - Error(w, http.StatusBadRequest, "domain name required") + Error(c, http.StatusBadRequest, "domain name required") return } - authCtx := GetAuthContext(r) + authCtx := GetAuthContext(c) if authCtx == nil { - Error(w, http.StatusUnauthorized, "authentication required") + Error(c, http.StatusUnauthorized, "authentication required") return } canAccess, _ := h.db.CanAccessDomain(authCtx.UserID, domainName, authCtx.IsAdmin()) if !canAccess { - Error(w, http.StatusForbidden, "access denied") + Error(c, http.StatusForbidden, "access denied") return } domain, err := h.db.GetDomainByName(domainName) if err != nil { - Error(w, http.StatusNotFound, "domain not found") + Error(c, http.StatusNotFound, "domain not found") return } var req CreateAliasRequest - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - Error(w, http.StatusBadRequest, "invalid request body") - return - } - - if req.Source == "" || req.Destination == "" { - Error(w, http.StatusBadRequest, "source and destination required") + if err := c.ShouldBindJSON(&req); err != nil { + Error(c, http.StatusBadRequest, "invalid request body") return } alias, err := h.db.CreateAliasInDomain(req.Source, req.Destination, domain.ID) if err != nil { - Error(w, http.StatusInternalServerError, "failed to create alias") + Error(c, http.StatusInternalServerError, "failed to create alias") return } - Created(w, alias) + Created(c, alias) } -func (h *AliasHandler) Delete(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodDelete { - Error(w, http.StatusMethodNotAllowed, "method not allowed") - return - } - - pathParts := strings.Split(r.URL.Path, "/") - var domainName, idStr string - for i, part := range pathParts { - if part == "domains" && i+1 < len(pathParts) { - domainName = pathParts[i+1] - } - if part == "aliases" && i+1 < len(pathParts) { - idStr = pathParts[i+1] - } - } +func (h *AliasHandler) Delete(c *gin.Context) { + domainName := c.Param("name") + idStr := c.Param("id") if domainName == "" || idStr == "" { - Error(w, http.StatusBadRequest, "invalid path") + Error(c, http.StatusBadRequest, "domain name and alias id required") return } - authCtx := GetAuthContext(r) + authCtx := GetAuthContext(c) if authCtx == nil { - Error(w, http.StatusUnauthorized, "authentication required") + Error(c, http.StatusUnauthorized, "authentication required") return } canAccess, _ := h.db.CanAccessDomain(authCtx.UserID, domainName, authCtx.IsAdmin()) if !canAccess { - Error(w, http.StatusForbidden, "access denied") + Error(c, http.StatusForbidden, "access denied") return } id, err := strconv.ParseUint(idStr, 10, 64) if err != nil { - Error(w, http.StatusBadRequest, "invalid alias id") + Error(c, http.StatusBadRequest, "invalid alias id") return } if err := h.db.DeleteAlias(uint(id)); err != nil { - Error(w, http.StatusInternalServerError, "failed to delete alias") + Error(c, http.StatusInternalServerError, "failed to delete alias") return } - NoContent(w) + NoContent(c) } diff --git a/backend/internal/api/handlers/auth.go b/backend/internal/api/handlers/auth.go index 3ce3eca..a675321 100644 --- a/backend/internal/api/handlers/auth.go +++ b/backend/internal/api/handlers/auth.go @@ -1,12 +1,12 @@ package handlers import ( - "encoding/json" "log" "net/http" "strings" "time" + "github.com/gin-gonic/gin" "github.com/imc-vibe/backend/internal/auth" "github.com/imc-vibe/backend/internal/db" "github.com/imc-vibe/backend/internal/mail" @@ -29,17 +29,17 @@ func NewAuthHandler(database *db.DB, jwtManager *auth.JWTManager, emailService * } type LoginRequest struct { - Username string `json:"username"` - Password string `json:"password"` + Username string `json:"username" binding:"required"` + Password string `json:"password" binding:"required"` } type ChangePasswordRequest struct { - OldPassword string `json:"oldPassword"` - NewPassword string `json:"newPassword"` + OldPassword string `json:"oldPassword" binding:"required"` + NewPassword string `json:"newPassword" binding:"required,min=8"` } type ForgotPasswordRequest struct { - Identifier string `json:"identifier"` + Identifier string `json:"identifier" binding:"required"` } type UserResponse struct { @@ -49,34 +49,24 @@ type UserResponse struct { Domains []string `json:"domains"` } -func (h *AuthHandler) Login(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodPost { - Error(w, http.StatusMethodNotAllowed, "method not allowed") - return - } - +func (h *AuthHandler) Login(c *gin.Context) { var req LoginRequest - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - Error(w, http.StatusBadRequest, "invalid request body") + if err := c.ShouldBindJSON(&req); err != nil { + Error(c, http.StatusBadRequest, "invalid request body") return } - if req.Username == "" || req.Password == "" { - Error(w, http.StatusBadRequest, "username and password required") - return - } - - ip := getClientIP(r) + ip := getClientIP(c) if isLockedOut(ip, req.Username, h.db) { - Error(w, http.StatusTooManyRequests, "too many failed attempts, try again later") + Error(c, http.StatusTooManyRequests, "too many failed attempts, try again later") return } user, err := h.db.GetImcUserByUsername(req.Username) if err != nil || user == nil || !auth.CheckPassword(req.Password, user.PasswordHash) { recordFailedAttempt(req.Username, ip, h.db) - Error(w, http.StatusUnauthorized, "invalid credentials") + Error(c, http.StatusUnauthorized, "invalid credentials") return } @@ -91,11 +81,11 @@ func (h *AuthHandler) Login(w http.ResponseWriter, r *http.Request) { token, err := h.jwtManager.GenerateToken(user.ID, user.Username, user.Role, 24*time.Hour) if err != nil { - Error(w, http.StatusInternalServerError, "failed to generate token") + Error(c, http.StatusInternalServerError, "failed to generate token") return } - Success(w, map[string]interface{}{ + Success(c, map[string]interface{}{ "token": token, "user": UserResponse{ ID: user.ID, @@ -106,21 +96,16 @@ func (h *AuthHandler) Login(w http.ResponseWriter, r *http.Request) { }) } -func (h *AuthHandler) Me(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodGet { - Error(w, http.StatusMethodNotAllowed, "method not allowed") - return - } - - authCtx := GetAuthContext(r) +func (h *AuthHandler) Me(c *gin.Context) { + authCtx := GetAuthContext(c) if authCtx == nil { - Error(w, http.StatusUnauthorized, "not authenticated") + Error(c, http.StatusUnauthorized, "not authenticated") return } user, err := h.db.GetImcUserByID(authCtx.UserID) if err != nil || user == nil { - Error(w, http.StatusNotFound, "user not found") + Error(c, http.StatusNotFound, "user not found") return } @@ -131,7 +116,7 @@ func (h *AuthHandler) Me(w http.ResponseWriter, r *http.Request) { domainNames[i] = d.Name } - Success(w, UserResponse{ + Success(c, UserResponse{ ID: user.ID, Username: user.Username, Role: user.Role, @@ -139,27 +124,22 @@ func (h *AuthHandler) Me(w http.ResponseWriter, r *http.Request) { }) } -func (h *AuthHandler) ForgotPassword(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodPost { - Error(w, http.StatusMethodNotAllowed, "method not allowed") - return - } - +func (h *AuthHandler) ForgotPassword(c *gin.Context) { var req ForgotPasswordRequest - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - Error(w, http.StatusBadRequest, "invalid request") + if err := c.ShouldBindJSON(&req); err != nil { + Error(c, http.StatusBadRequest, "invalid request") return } req.Identifier = strings.TrimSpace(strings.ToLower(req.Identifier)) if req.Identifier == "" { - Error(w, http.StatusBadRequest, "identifier required") + Error(c, http.StatusBadRequest, "identifier required") return } user, err := h.db.GetImcUserByUsername(req.Identifier) if err != nil { - Success(w, map[string]string{ + Success(c, map[string]string{ "message": "If the account exists, a password reset link will be sent", }) return @@ -170,77 +150,62 @@ func (h *AuthHandler) ForgotPassword(w http.ResponseWriter, r *http.Request) { if err := h.db.CreatePasswordResetToken(user.ID, token, expiresAt); err != nil { log.Printf("Failed to create password reset token: %v", err) - Error(w, http.StatusInternalServerError, "failed to create reset token") + Error(c, http.StatusInternalServerError, "failed to create reset token") return } if err := h.emailService.SendPasswordReset(user.Username, token); err != nil { log.Printf("Failed to send password reset email: %v", err) - Error(w, http.StatusInternalServerError, "failed to send email") + Error(c, http.StatusInternalServerError, "failed to send email") return } - Success(w, map[string]string{ + Success(c, map[string]string{ "message": "If the account exists, a password reset link will be sent", }) } -func (h *AuthHandler) ChangePassword(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodPost { - Error(w, http.StatusMethodNotAllowed, "method not allowed") - return - } - - authCtx := GetAuthContext(r) +func (h *AuthHandler) ChangePassword(c *gin.Context) { + authCtx := GetAuthContext(c) if authCtx == nil { - Error(w, http.StatusUnauthorized, "authentication required") + Error(c, http.StatusUnauthorized, "authentication required") return } var req ChangePasswordRequest - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - Error(w, http.StatusBadRequest, "invalid request") - return - } - - if req.OldPassword == "" || req.NewPassword == "" { - Error(w, http.StatusBadRequest, "old and new password required") - return - } - - if len(req.NewPassword) < 8 { - Error(w, http.StatusBadRequest, "password must be at least 8 characters") + if err := c.ShouldBindJSON(&req); err != nil { + Error(c, http.StatusBadRequest, "invalid request") return } user, err := h.db.GetImcUserByID(authCtx.UserID) if err != nil || user == nil { - Error(w, http.StatusNotFound, "user not found") + Error(c, http.StatusNotFound, "user not found") return } if !auth.CheckPassword(req.OldPassword, user.PasswordHash) { - Error(w, http.StatusUnauthorized, "current password is incorrect") + Error(c, http.StatusUnauthorized, "current password is incorrect") return } newHash, err := auth.HashPassword(req.NewPassword) if err != nil { - Error(w, http.StatusInternalServerError, "failed to hash password") + Error(c, http.StatusInternalServerError, "failed to hash password") return } err = h.db.UpdateImcUserPassword(user.ID, newHash) if err != nil { - Error(w, http.StatusInternalServerError, "failed to update password") + Error(c, http.StatusInternalServerError, "failed to update password") return } - Success(w, map[string]string{"message": "password updated successfully"}) + Success(c, map[string]string{"message": "password updated successfully"}) } -func (h *AuthHandler) Logout(w http.ResponseWriter, r *http.Request) { - Success(w, map[string]string{"message": "logged out"}) +func (h *AuthHandler) Logout(c *gin.Context) { + Success(c, map[string]string{"message": "logged out"}) } func isLockedOut(ip, identifier string, database *db.DB) bool { @@ -276,13 +241,10 @@ func clearFailedAttempts(identifier, ip string, database *db.DB) { Update("successful", true) } -func getClientIP(r *http.Request) string { - forwarded := r.Header.Get("X-Forwarded-For") +func getClientIP(c *gin.Context) string { + forwarded := c.GetHeader("X-Forwarded-For") if forwarded != "" { return strings.Split(forwarded, ",")[0] } - if idx := strings.LastIndex(r.RemoteAddr, ":"); idx != -1 { - return r.RemoteAddr[:idx] - } - return r.RemoteAddr + return c.ClientIP() } diff --git a/backend/internal/api/handlers/domains.go b/backend/internal/api/handlers/domains.go index 804982f..4156ccf 100644 --- a/backend/internal/api/handlers/domains.go +++ b/backend/internal/api/handlers/domains.go @@ -1,10 +1,10 @@ package handlers import ( - "encoding/json" "net/http" - "strings" + "strconv" + "github.com/gin-gonic/gin" "github.com/imc-vibe/backend/internal/db" ) @@ -17,7 +17,7 @@ func NewDomainHandler(database *db.DB) *DomainHandler { } type CreateDomainRequest struct { - Name string `json:"name"` + Name string `json:"name" binding:"required"` } type DomainPermissions struct { @@ -27,22 +27,17 @@ type DomainPermissions struct { CanManage bool `json:"canManage"` } -func (h *DomainHandler) List(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodGet { - Error(w, http.StatusMethodNotAllowed, "method not allowed") - return - } - - authCtx := GetAuthContext(r) +func (h *DomainHandler) List(c *gin.Context) { + authCtx := GetAuthContext(c) if authCtx == nil { - Error(w, http.StatusUnauthorized, "authentication required") + Error(c, http.StatusUnauthorized, "authentication required") return } isAdmin := authCtx.IsAdmin() domains, err := h.db.GetUserAccessibleDomains(authCtx.UserID, isAdmin) if err != nil { - Error(w, http.StatusInternalServerError, "database error") + Error(c, http.StatusInternalServerError, "database error") return } @@ -59,144 +54,119 @@ func (h *DomainHandler) List(w http.ResponseWriter, r *http.Request) { } } - Success(w, domainStats) + Success(c, domainStats) } -func (h *DomainHandler) Get(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodGet { - Error(w, http.StatusMethodNotAllowed, "method not allowed") - return - } - - domainName := extractDomainName(r.URL.Path) +func (h *DomainHandler) Get(c *gin.Context) { + domainName := c.Param("name") if domainName == "" { - Error(w, http.StatusBadRequest, "domain name required") + Error(c, http.StatusBadRequest, "domain name required") return } domain, err := h.db.GetDomainByName(domainName) if err != nil { - Error(w, http.StatusNotFound, "domain not found") + Error(c, http.StatusNotFound, "domain not found") return } - authCtx := GetAuthContext(r) + authCtx := GetAuthContext(c) if authCtx == nil { - Error(w, http.StatusUnauthorized, "authentication required") + Error(c, http.StatusUnauthorized, "authentication required") return } canAccess, _ := h.db.CanAccessDomain(authCtx.UserID, domainName, authCtx.IsAdmin()) if !canAccess { - Error(w, http.StatusForbidden, "access denied") + Error(c, http.StatusForbidden, "access denied") return } - Success(w, domain) + Success(c, domain) } -func (h *DomainHandler) Create(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodPost { - Error(w, http.StatusMethodNotAllowed, "method not allowed") - return - } - - authCtx := GetAuthContext(r) +func (h *DomainHandler) Create(c *gin.Context) { + authCtx := GetAuthContext(c) if authCtx == nil || !authCtx.IsAdmin() { - Error(w, http.StatusForbidden, "admin access required") + Error(c, http.StatusForbidden, "admin access required") return } var req CreateDomainRequest - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - Error(w, http.StatusBadRequest, "invalid request body") - return - } - - if req.Name == "" { - Error(w, http.StatusBadRequest, "domain name required") + if err := c.ShouldBindJSON(&req); err != nil { + Error(c, http.StatusBadRequest, "invalid request body") return } existing, err := h.db.GetDomainByName(req.Name) if err == nil && existing != nil { - Error(w, http.StatusConflict, "domain already exists") + Error(c, http.StatusConflict, "domain already exists") return } domain, err := h.db.CreateDomain(req.Name) if err != nil { - Error(w, http.StatusInternalServerError, "failed to create domain") + Error(c, http.StatusInternalServerError, "failed to create domain") return } - Created(w, domain) + Created(c, domain) } -func (h *DomainHandler) Delete(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodDelete { - Error(w, http.StatusMethodNotAllowed, "method not allowed") - return - } - - authCtx := GetAuthContext(r) +func (h *DomainHandler) Delete(c *gin.Context) { + authCtx := GetAuthContext(c) if authCtx == nil || !authCtx.IsAdmin() { - Error(w, http.StatusForbidden, "admin access required") + Error(c, http.StatusForbidden, "admin access required") return } - domainName := extractDomainName(r.URL.Path) + domainName := c.Param("name") if domainName == "" { - Error(w, http.StatusBadRequest, "domain name required") + Error(c, http.StatusBadRequest, "domain name required") return } domain, err := h.db.GetDomainByName(domainName) if err != nil { - Error(w, http.StatusNotFound, "domain not found") + Error(c, http.StatusNotFound, "domain not found") return } if err := h.db.DeleteDomain(domain.ID); err != nil { - Error(w, http.StatusInternalServerError, "failed to delete domain") + Error(c, http.StatusInternalServerError, "failed to delete domain") return } - NoContent(w) + NoContent(c) } -func (h *DomainHandler) GetPermissions(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodGet { - Error(w, http.StatusMethodNotAllowed, "method not allowed") - return - } - - authCtx := GetAuthContext(r) +func (h *DomainHandler) GetPermissions(c *gin.Context) { + authCtx := GetAuthContext(c) if authCtx == nil { - Error(w, http.StatusUnauthorized, "authentication required") + Error(c, http.StatusUnauthorized, "authentication required") return } if !authCtx.IsAdmin() { - Error(w, http.StatusForbidden, "admin access required") + Error(c, http.StatusForbidden, "admin access required") return } - domainName := extractDomainName(r.URL.Path) + domainName := c.Param("name") if domainName == "" { - Error(w, http.StatusBadRequest, "domain name required") + Error(c, http.StatusBadRequest, "domain name required") return } domain, err := h.db.GetDomainByName(domainName) if err != nil { - Error(w, http.StatusNotFound, "domain not found") + Error(c, http.StatusNotFound, "domain not found") return } users, err := h.db.GetUsersForDomain(domain.ID) if err != nil { - Error(w, http.StatusInternalServerError, "database error") + Error(c, http.StatusInternalServerError, "database error") return } @@ -210,107 +180,74 @@ func (h *DomainHandler) GetPermissions(w http.ResponseWriter, r *http.Request) { } } - Success(w, permissions) + Success(c, permissions) } -func (h *DomainHandler) AddPermission(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodPost { - Error(w, http.StatusMethodNotAllowed, "method not allowed") - return - } - - authCtx := GetAuthContext(r) +func (h *DomainHandler) AddPermission(c *gin.Context) { + authCtx := GetAuthContext(c) if authCtx == nil || !authCtx.IsAdmin() { - Error(w, http.StatusForbidden, "admin access required") + Error(c, http.StatusForbidden, "admin access required") return } - domainName := extractDomainName(r.URL.Path) + domainName := c.Param("name") if domainName == "" { - Error(w, http.StatusBadRequest, "domain name required") + Error(c, http.StatusBadRequest, "domain name required") return } domain, err := h.db.GetDomainByName(domainName) if err != nil { - Error(w, http.StatusNotFound, "domain not found") + Error(c, http.StatusNotFound, "domain not found") return } var req struct { - UserID uint `json:"userId"` + UserID uint `json:"userId" binding:"required"` } - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - Error(w, http.StatusBadRequest, "invalid request") + if err := c.ShouldBindJSON(&req); err != nil { + Error(c, http.StatusBadRequest, "invalid request") return } if err := h.db.AddUserToDomain(req.UserID, domain.ID); err != nil { - Error(w, http.StatusInternalServerError, "failed to add user to domain") + Error(c, http.StatusInternalServerError, "failed to add user to domain") return } - Success(w, map[string]string{"message": "user added to domain"}) + Success(c, map[string]string{"message": "user added to domain"}) } -func (h *DomainHandler) RemovePermission(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodDelete { - Error(w, http.StatusMethodNotAllowed, "method not allowed") - return - } - - authCtx := GetAuthContext(r) +func (h *DomainHandler) RemovePermission(c *gin.Context) { + authCtx := GetAuthContext(c) if authCtx == nil || !authCtx.IsAdmin() { - Error(w, http.StatusForbidden, "admin access required") + Error(c, http.StatusForbidden, "admin access required") return } - domainName := extractDomainName(r.URL.Path) + domainName := c.Param("name") if domainName == "" { - Error(w, http.StatusBadRequest, "domain name required") + Error(c, http.StatusBadRequest, "domain name required") return } domain, err := h.db.GetDomainByName(domainName) if err != nil { - Error(w, http.StatusNotFound, "domain not found") + Error(c, http.StatusNotFound, "domain not found") return } - userID := extractIDFromPath(r.URL.Path) - - if err := h.db.RemoveUserFromDomain(userID, domain.ID); err != nil { - Error(w, http.StatusInternalServerError, "failed to remove user from domain") + userIDStr := c.Param("userId") + userID, err := strconv.ParseUint(userIDStr, 10, 64) + if err != nil { + Error(c, http.StatusBadRequest, "invalid user id") return } - NoContent(w) -} - -func extractDomainName(path string) string { - parts := strings.Split(strings.TrimPrefix(path, "/api/"), "/") - if len(parts) >= 2 && parts[1] != "" { - return parts[1] + if err := h.db.RemoveUserFromDomain(uint(userID), domain.ID); err != nil { + Error(c, http.StatusInternalServerError, "failed to remove user from domain") + return } - return "" -} -func extractIDFromPath(path string) uint { - parts := strings.Split(path, "/") - for i := len(parts) - 1; i >= 0; i-- { - if idStr := parts[i]; idStr != "" { - var id uint - for _, c := range idStr { - if c >= '0' && c <= '9' { - id = id*10 + uint(c-'0') - } else { - break - } - } - if id > 0 { - return id - } - } - } - return 0 + NoContent(c) } diff --git a/backend/internal/api/handlers/logs.go b/backend/internal/api/handlers/logs.go index 6e1b42d..e4b2001 100644 --- a/backend/internal/api/handlers/logs.go +++ b/backend/internal/api/handlers/logs.go @@ -4,6 +4,7 @@ import ( "net/http" "strconv" + "github.com/gin-gonic/gin" "github.com/imc-vibe/backend/internal/mail" ) @@ -13,26 +14,21 @@ func NewLogsHandler() *LogsHandler { return &LogsHandler{} } -func (h *LogsHandler) List(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodGet { - Error(w, http.StatusMethodNotAllowed, "method not allowed") - return - } - +func (h *LogsHandler) List(c *gin.Context) { hours := 1 - if h := r.URL.Query().Get("hours"); h != "" { + if h := c.Query("hours"); h != "" { if n, err := strconv.Atoi(h); err == nil && n > 0 { hours = n } } - filter := r.URL.Query().Get("filter") + filter := c.Query("filter") entries, err := mail.GetLogs(hours, filter) if err != nil { - Error(w, http.StatusServiceUnavailable, "log service not available") + Error(c, http.StatusServiceUnavailable, "log service not available") return } - Success(w, entries) + Success(c, entries) } diff --git a/backend/internal/api/handlers/middleware.go b/backend/internal/api/handlers/middleware.go index e539357..9a0016c 100644 --- a/backend/internal/api/handlers/middleware.go +++ b/backend/internal/api/handlers/middleware.go @@ -1,34 +1,32 @@ package handlers import ( - "context" "net/http" "strings" + "github.com/gin-gonic/gin" "github.com/imc-vibe/backend/internal/auth" ) -type contextKey string +const AuthContextKey = "auth" -const AuthContextKey contextKey = "auth" - -func AuthMiddleware(jwtManager *auth.JWTManager, next http.Handler) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - authHeader := r.Header.Get("Authorization") +func AuthMiddleware(jwtManager *auth.JWTManager) gin.HandlerFunc { + return func(c *gin.Context) { + authHeader := c.GetHeader("Authorization") if authHeader == "" { - http.Error(w, `{"error":"authorization header required"}`, http.StatusUnauthorized) + c.AbortWithStatusJSON(http.StatusUnauthorized, Response{Error: "authorization header required"}) return } parts := strings.SplitN(authHeader, " ", 2) if len(parts) != 2 || strings.ToLower(parts[0]) != "bearer" { - http.Error(w, `{"error":"invalid authorization header format"}`, http.StatusUnauthorized) + c.AbortWithStatusJSON(http.StatusUnauthorized, Response{Error: "invalid authorization header format"}) return } claims, err := jwtManager.ValidateToken(parts[1]) if err != nil { - http.Error(w, `{"error":"invalid token"}`, http.StatusUnauthorized) + c.AbortWithStatusJSON(http.StatusUnauthorized, Response{Error: "invalid token"}) return } @@ -38,13 +36,13 @@ func AuthMiddleware(jwtManager *auth.JWTManager, next http.Handler) http.Handler Role: auth.Role(claims.Role), } - ctx := context.WithValue(r.Context(), AuthContextKey, authCtx) - next.ServeHTTP(w, r.WithContext(ctx)) - }) + c.Set(AuthContextKey, authCtx) + c.Next() + } } -func GetAuthContext(r *http.Request) *auth.Context { - if ctx := r.Context().Value(AuthContextKey); ctx != nil { +func GetAuthContext(c *gin.Context) *auth.Context { + if ctx, exists := c.Get(AuthContextKey); exists { if authCtx, ok := ctx.(*auth.Context); ok { return authCtx } @@ -52,21 +50,13 @@ func GetAuthContext(r *http.Request) *auth.Context { return nil } -func RequireAdmin(next http.Handler) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - authCtx := GetAuthContext(r) +func RequireAdmin() gin.HandlerFunc { + return func(c *gin.Context) { + authCtx := GetAuthContext(c) if authCtx == nil || !authCtx.IsAdmin() { - http.Error(w, `{"error":"admin access required"}`, http.StatusForbidden) + c.AbortWithStatusJSON(http.StatusForbidden, Response{Error: "admin access required"}) return } - next.ServeHTTP(w, r) - }) -} - -func extractID(path string) string { - parts := strings.Split(path, "/") - if len(parts) > 0 { - return parts[len(parts)-1] + c.Next() } - return "" } diff --git a/backend/internal/api/handlers/queue.go b/backend/internal/api/handlers/queue.go index 1a41a16..1392e80 100644 --- a/backend/internal/api/handlers/queue.go +++ b/backend/internal/api/handlers/queue.go @@ -4,6 +4,7 @@ import ( "log" "net/http" + "github.com/gin-gonic/gin" "github.com/imc-vibe/backend/internal/mail" ) @@ -13,48 +14,33 @@ func NewQueueHandler() *QueueHandler { return &QueueHandler{} } -func (h *QueueHandler) List(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodGet { - Error(w, http.StatusMethodNotAllowed, "method not allowed") - return - } - +func (h *QueueHandler) List(c *gin.Context) { entries, err := mail.GetQueue() if err != nil { log.Printf("Queue error: %v", err) - Error(w, http.StatusServiceUnavailable, "failed to get queue: "+err.Error()) + Error(c, http.StatusServiceUnavailable, "failed to get queue: "+err.Error()) return } - Success(w, entries) + Success(c, entries) } -func (h *QueueHandler) Requeue(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodPost { - Error(w, http.StatusMethodNotAllowed, "method not allowed") - return - } - - id := extractID(r.URL.Path) +func (h *QueueHandler) Requeue(c *gin.Context) { + id := c.Param("id") if err := mail.RequeueMail(id); err != nil { - Error(w, http.StatusInternalServerError, "failed to requeue mail") + Error(c, http.StatusInternalServerError, "failed to requeue mail") return } - Success(w, map[string]string{"message": "mail requeued"}) + Success(c, map[string]string{"message": "mail requeued"}) } -func (h *QueueHandler) Delete(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodDelete { - Error(w, http.StatusMethodNotAllowed, "method not allowed") - return - } - - id := extractID(r.URL.Path) +func (h *QueueHandler) Delete(c *gin.Context) { + id := c.Param("id") if err := mail.DeleteFromQueue(id); err != nil { - Error(w, http.StatusInternalServerError, "failed to delete from queue") + Error(c, http.StatusInternalServerError, "failed to delete from queue") return } - NoContent(w) + NoContent(c) } diff --git a/backend/internal/api/handlers/response.go b/backend/internal/api/handlers/response.go index 025a474..61f1bea 100644 --- a/backend/internal/api/handlers/response.go +++ b/backend/internal/api/handlers/response.go @@ -1,8 +1,7 @@ package handlers import ( - "encoding/json" - "net/http" + "github.com/gin-gonic/gin" ) type Response struct { @@ -16,26 +15,22 @@ type Meta struct { Page int `json:"page,omitempty"` } -func JSON(w http.ResponseWriter, status int, data interface{}) { - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(status) - json.NewEncoder(w).Encode(Response{Data: data}) +func JSON(c *gin.Context, status int, data interface{}) { + c.JSON(status, Response{Data: data}) } -func Error(w http.ResponseWriter, status int, message string) { - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(status) - json.NewEncoder(w).Encode(Response{Error: message}) +func Error(c *gin.Context, status int, message string) { + c.JSON(status, Response{Error: message}) } -func Success(w http.ResponseWriter, data interface{}) { - JSON(w, http.StatusOK, data) +func Success(c *gin.Context, data interface{}) { + JSON(c, 200, data) } -func Created(w http.ResponseWriter, data interface{}) { - JSON(w, http.StatusCreated, data) +func Created(c *gin.Context, data interface{}) { + JSON(c, 201, data) } -func NoContent(w http.ResponseWriter) { - w.WriteHeader(http.StatusNoContent) +func NoContent(c *gin.Context) { + c.Status(204) } diff --git a/backend/internal/api/handlers/stats.go b/backend/internal/api/handlers/stats.go index b1c62ee..b4002d4 100644 --- a/backend/internal/api/handlers/stats.go +++ b/backend/internal/api/handlers/stats.go @@ -1,8 +1,7 @@ package handlers import ( - "net/http" - + "github.com/gin-gonic/gin" "github.com/imc-vibe/backend/internal/db" ) @@ -21,12 +20,7 @@ type Stats struct { QueueSize int `json:"queueSize"` } -func (h *StatsHandler) Get(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodGet { - Error(w, http.StatusMethodNotAllowed, "method not allowed") - return - } - +func (h *StatsHandler) Get(c *gin.Context) { domains, err := h.db.GetAllDomains() if err != nil { domains = []db.DomainStats{} @@ -49,5 +43,5 @@ func (h *StatsHandler) Get(w http.ResponseWriter, r *http.Request) { QueueSize: 0, } - Success(w, stats) + Success(c, stats) } diff --git a/backend/internal/api/handlers/users.go b/backend/internal/api/handlers/users.go index 7fb54ea..c8cbdb7 100644 --- a/backend/internal/api/handlers/users.go +++ b/backend/internal/api/handlers/users.go @@ -1,12 +1,10 @@ package handlers import ( - "encoding/json" "net/http" "strconv" - "strings" - "github.com/imc-vibe/backend/internal/auth" + "github.com/gin-gonic/gin" "github.com/imc-vibe/backend/internal/db" ) @@ -19,8 +17,8 @@ func NewUserHandler(database *db.DB) *UserHandler { } type CreateUserRequest struct { - Email string `json:"email"` - Password string `json:"password"` + Email string `json:"email" binding:"required"` + Password string `json:"password" binding:"required"` Quota int64 `json:"quota"` } @@ -29,130 +27,110 @@ type UpdateUserRequest struct { Quota int64 `json:"quota,omitempty"` } -func (h *UserHandler) List(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodGet { - Error(w, http.StatusMethodNotAllowed, "method not allowed") - return - } - - domainName := extractDomainNameFromPath(r.URL.Path) +func (h *UserHandler) List(c *gin.Context) { + domainName := c.Param("name") if domainName == "" { - Error(w, http.StatusBadRequest, "domain name required") + Error(c, http.StatusBadRequest, "domain name required") return } - authCtx := GetAuthContext(r) + authCtx := GetAuthContext(c) if authCtx == nil { - Error(w, http.StatusUnauthorized, "authentication required") + Error(c, http.StatusUnauthorized, "authentication required") return } canAccess, _ := h.db.CanAccessDomain(authCtx.UserID, domainName, authCtx.IsAdmin()) if !canAccess { - Error(w, http.StatusForbidden, "access denied") + Error(c, http.StatusForbidden, "access denied") return } domain, err := h.db.GetDomainByName(domainName) if err != nil { - Error(w, http.StatusNotFound, "domain not found") + Error(c, http.StatusNotFound, "domain not found") return } users, err := h.db.GetUsersByDomain(domain.ID) if err != nil { - Error(w, http.StatusInternalServerError, "database error") + Error(c, http.StatusInternalServerError, "database error") return } - Success(w, users) + Success(c, users) } -func (h *UserHandler) Get(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodGet { - Error(w, http.StatusMethodNotAllowed, "method not allowed") +func (h *UserHandler) Get(c *gin.Context) { + domainName := c.Param("name") + idStr := c.Param("id") + + if domainName == "" || idStr == "" { + Error(c, http.StatusBadRequest, "domain name and user id required") return } - parts := strings.Split(strings.TrimPrefix(r.URL.Path, "/api/domains/"), "/") - if len(parts) < 4 { - Error(w, http.StatusBadRequest, "invalid path") - return - } - domainName := parts[0] - - authCtx := GetAuthContext(r) + authCtx := GetAuthContext(c) if authCtx == nil { - Error(w, http.StatusUnauthorized, "authentication required") + Error(c, http.StatusUnauthorized, "authentication required") return } canAccess, _ := h.db.CanAccessDomain(authCtx.UserID, domainName, authCtx.IsAdmin()) if !canAccess { - Error(w, http.StatusForbidden, "access denied") + Error(c, http.StatusForbidden, "access denied") return } - idStr := parts[3] id, err := strconv.ParseUint(idStr, 10, 64) if err != nil { - Error(w, http.StatusBadRequest, "invalid user id") + Error(c, http.StatusBadRequest, "invalid user id") return } user, err := h.db.GetUserByID(uint(id)) if err != nil { - Error(w, http.StatusNotFound, "user not found") + Error(c, http.StatusNotFound, "user not found") return } - Success(w, user) + Success(c, user) } -func (h *UserHandler) Create(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodPost { - Error(w, http.StatusMethodNotAllowed, "method not allowed") - return - } - - domainName := extractDomainNameFromPath(r.URL.Path) +func (h *UserHandler) Create(c *gin.Context) { + domainName := c.Param("name") if domainName == "" { - Error(w, http.StatusBadRequest, "domain name required") + Error(c, http.StatusBadRequest, "domain name required") return } - authCtx := GetAuthContext(r) + authCtx := GetAuthContext(c) if authCtx == nil { - Error(w, http.StatusUnauthorized, "authentication required") + Error(c, http.StatusUnauthorized, "authentication required") return } canAccess, _ := h.db.CanAccessDomain(authCtx.UserID, domainName, authCtx.IsAdmin()) if !canAccess { - Error(w, http.StatusForbidden, "access denied") + Error(c, http.StatusForbidden, "access denied") return } domain, err := h.db.GetDomainByName(domainName) if err != nil { - Error(w, http.StatusNotFound, "domain not found") + Error(c, http.StatusNotFound, "domain not found") return } var req CreateUserRequest - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - Error(w, http.StatusBadRequest, "invalid request body") - return - } - - if req.Email == "" || req.Password == "" { - Error(w, http.StatusBadRequest, "email and password required") + if err := c.ShouldBindJSON(&req); err != nil { + Error(c, http.StatusBadRequest, "invalid request body") return } existing, _ := h.db.GetUserByEmail(req.Email) if existing != nil { - Error(w, http.StatusConflict, "user already exists") + Error(c, http.StatusConflict, "user already exists") return } @@ -160,145 +138,117 @@ func (h *UserHandler) Create(w http.ResponseWriter, r *http.Request) { user, err := h.db.CreateUserInDomain(req.Email, passwordHash, req.Quota, domain.ID) if err != nil { - Error(w, http.StatusInternalServerError, "failed to create user") + Error(c, http.StatusInternalServerError, "failed to create user") return } - Created(w, user) + Created(c, user) } -func (h *UserHandler) Update(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodPut { - Error(w, http.StatusMethodNotAllowed, "method not allowed") +func (h *UserHandler) Update(c *gin.Context) { + domainName := c.Param("name") + idStr := c.Param("id") + + if domainName == "" || idStr == "" { + Error(c, http.StatusBadRequest, "domain name and user id required") return } - parts := strings.Split(strings.TrimPrefix(r.URL.Path, "/api/domains/"), "/") - if len(parts) < 4 { - Error(w, http.StatusBadRequest, "invalid path") - return - } - domainName := parts[0] - - authCtx := GetAuthContext(r) + authCtx := GetAuthContext(c) if authCtx == nil { - Error(w, http.StatusUnauthorized, "authentication required") + Error(c, http.StatusUnauthorized, "authentication required") return } canAccess, _ := h.db.CanAccessDomain(authCtx.UserID, domainName, authCtx.IsAdmin()) if !canAccess { - Error(w, http.StatusForbidden, "access denied") + Error(c, http.StatusForbidden, "access denied") return } - idStr := parts[3] id, err := strconv.ParseUint(idStr, 10, 64) if err != nil { - Error(w, http.StatusBadRequest, "invalid user id") + Error(c, http.StatusBadRequest, "invalid user id") return } user, err := h.db.GetUserByID(uint(id)) if err != nil { - Error(w, http.StatusNotFound, "user not found") + Error(c, http.StatusNotFound, "user not found") return } var req UpdateUserRequest - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - Error(w, http.StatusBadRequest, "invalid request body") + if err := c.ShouldBindJSON(&req); err != nil { + Error(c, http.StatusBadRequest, "invalid request") return } if req.Password != "" { passwordHash := "{BLF-CRYPT}" + req.Password if err := h.db.UpdateUserPassword(user.ID, passwordHash); err != nil { - Error(w, http.StatusInternalServerError, "failed to update password") + Error(c, http.StatusInternalServerError, "failed to update password") return } } if req.Quota >= 0 { if err := h.db.UpdateUserQuota(user.ID, req.Quota); err != nil { - Error(w, http.StatusInternalServerError, "failed to update quota") + Error(c, http.StatusInternalServerError, "failed to update quota") return } } - Success(w, map[string]string{"message": "user updated"}) + Success(c, map[string]string{"message": "user updated"}) } -func (h *UserHandler) Delete(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodDelete { - Error(w, http.StatusMethodNotAllowed, "method not allowed") +func (h *UserHandler) Delete(c *gin.Context) { + domainName := c.Param("name") + idStr := c.Param("id") + + if domainName == "" || idStr == "" { + Error(c, http.StatusBadRequest, "domain name and user id required") return } - parts := strings.Split(strings.TrimPrefix(r.URL.Path, "/api/domains/"), "/") - if len(parts) < 4 { - Error(w, http.StatusBadRequest, "invalid path") - return - } - domainName := parts[0] - - authCtx := GetAuthContext(r) + authCtx := GetAuthContext(c) if authCtx == nil { - Error(w, http.StatusUnauthorized, "authentication required") + Error(c, http.StatusUnauthorized, "authentication required") return } canAccess, _ := h.db.CanAccessDomain(authCtx.UserID, domainName, authCtx.IsAdmin()) if !canAccess { - Error(w, http.StatusForbidden, "access denied") + Error(c, http.StatusForbidden, "access denied") return } - idStr := parts[3] id, err := strconv.ParseUint(idStr, 10, 64) if err != nil { - Error(w, http.StatusBadRequest, "invalid user id") + Error(c, http.StatusBadRequest, "invalid user id") return } if err := h.db.DeleteUser(uint(id)); err != nil { - Error(w, http.StatusInternalServerError, "failed to delete user") + Error(c, http.StatusInternalServerError, "failed to delete user") return } - NoContent(w) + NoContent(c) } -func (h *UserHandler) ListAll(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodGet { - Error(w, http.StatusMethodNotAllowed, "method not allowed") - return - } - - authCtx := GetAuthContext(r) +func (h *UserHandler) ListAll(c *gin.Context) { + authCtx := GetAuthContext(c) if authCtx == nil || !authCtx.IsAdmin() { - Error(w, http.StatusForbidden, "admin access required") + Error(c, http.StatusForbidden, "admin access required") return } users, err := h.db.GetAllMailUsers() if err != nil { - Error(w, http.StatusInternalServerError, "database error") + Error(c, http.StatusInternalServerError, "database error") return } - Success(w, users) + Success(c, users) } - -func extractDomainNameFromPath(path string) string { - parts := strings.Split(strings.TrimPrefix(path, "/api/"), "/") - if len(parts) >= 2 && parts[0] == "domains" && parts[1] != "" { - if idx := strings.Index(parts[1], "/"); idx > 0 { - return parts[1][:idx] - } - return parts[1] - } - return "" -} - -var _ = auth.RoleAdmin diff --git a/backend/internal/api/router.go b/backend/internal/api/router.go index b77e2ae..749e325 100644 --- a/backend/internal/api/router.go +++ b/backend/internal/api/router.go @@ -1,10 +1,9 @@ package api import ( - "context" "net/http" - "strings" + "github.com/gin-gonic/gin" "github.com/imc-vibe/backend/internal/api/handlers" "github.com/imc-vibe/backend/internal/auth" "github.com/imc-vibe/backend/internal/config" @@ -21,14 +20,13 @@ type Router struct { queueHandler *handlers.QueueHandler logsHandler *handlers.LogsHandler jwtManager *auth.JWTManager - db *db.DB } func New(database *db.DB, cfg *config.Config) *Router { jwtManager := auth.NewJWTManager(cfg.JWTSecret, "imc-vibe") emailService := mail.NewEmailService(cfg) - return &Router{ + r := &Router{ authHandler: handlers.NewAuthHandler(database, jwtManager, emailService), domainHandler: handlers.NewDomainHandler(database), userHandler: handlers.NewUserHandler(database), @@ -37,213 +35,53 @@ func New(database *db.DB, cfg *config.Config) *Router { queueHandler: handlers.NewQueueHandler(), logsHandler: handlers.NewLogsHandler(), jwtManager: jwtManager, - db: database, } + + return r } -func (r *Router) Handler() http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { - path := req.URL.Path - - // Only handle /api/* routes - if !strings.HasPrefix(path, "/api/") { - http.NotFound(w, req) - return - } - - // Public routes - no auth required - switch path { - case "/api/auth/login": - r.authHandler.Login(w, req) - return - case "/api/auth/forgot": - r.authHandler.ForgotPassword(w, req) - return - case "/api/auth/logout": - r.authHandler.Logout(w, req) - return - } - - // Protected routes - auth required - req = r.validateAuth(req) - if req == nil { - http.Error(w, `{"error":"not authenticated"}`, http.StatusUnauthorized) - return - } - - switch { - case path == "/api/auth/me": - r.authHandler.Me(w, req) - case path == "/api/auth/change-password": - r.authHandler.ChangePassword(w, req) - case path == "/api/stats": - r.statsHandler.Get(w, req) - case strings.HasPrefix(path, "/api/domains/"): - if strings.Contains(strings.TrimPrefix(path, "/api/domains/"), "/users") { - r.handleDomainUsers(w, req) - } else if strings.Contains(strings.TrimPrefix(path, "/api/domains/"), "/aliases") { - r.handleDomainAliases(w, req) - } else { - r.handleDomains(w, req) - } - case strings.HasPrefix(path, "/api/domains"): - r.handleDomains(w, req) - case strings.HasPrefix(path, "/api/queue"): - r.handleQueue(w, req) - case path == "/api/logs": - r.logsHandler.List(w, req) - default: - http.NotFound(w, req) - } +func (r *Router) RegisterRoutes(engine *gin.Engine) { + engine.GET("/api/health", func(c *gin.Context) { + c.JSON(http.StatusOK, gin.H{"status": "ok"}) }) -} -func (r *Router) validateAuth(req *http.Request) *http.Request { - authHeader := req.Header.Get("Authorization") - if authHeader == "" { - return nil + engine.POST("/api/auth/login", r.authHandler.Login) + engine.POST("/api/auth/forgot", r.authHandler.ForgotPassword) + engine.POST("/api/auth/logout", r.authHandler.Logout) + + authGroup := engine.Group("/api") + authGroup.Use(handlers.AuthMiddleware(r.jwtManager)) + { + authGroup.GET("/auth/me", r.authHandler.Me) + authGroup.POST("/auth/change-password", r.authHandler.ChangePassword) + + authGroup.GET("/stats", r.statsHandler.Get) + + authGroup.GET("/domains", r.domainHandler.List) + authGroup.GET("/domains/:name", r.domainHandler.Get) + authGroup.GET("/domains/:name/users", r.userHandler.List) + authGroup.GET("/domains/:name/users/:id", r.userHandler.Get) + authGroup.POST("/domains/:name/users", r.userHandler.Create) + authGroup.PUT("/domains/:name/users/:id", r.userHandler.Update) + authGroup.DELETE("/domains/:name/users/:id", r.userHandler.Delete) + authGroup.GET("/domains/:name/aliases", r.aliasHandler.List) + authGroup.POST("/domains/:name/aliases", r.aliasHandler.Create) + authGroup.DELETE("/domains/:name/aliases/:id", r.aliasHandler.Delete) + + authGroup.GET("/queue", r.queueHandler.List) + authGroup.POST("/queue/:id/requeue", r.queueHandler.Requeue) + authGroup.DELETE("/queue/:id", r.queueHandler.Delete) + + authGroup.GET("/logs", r.logsHandler.List) } - parts := strings.SplitN(authHeader, " ", 2) - if len(parts) != 2 || strings.ToLower(parts[0]) != "bearer" { - return nil - } - - claims, err := r.jwtManager.ValidateToken(parts[1]) - if err != nil { - return nil - } - - authCtx := &auth.Context{ - UserID: claims.UserID, - Username: claims.Username, - Role: auth.Role(claims.Role), - } - - ctx := context.WithValue(req.Context(), handlers.AuthContextKey, authCtx) - return req.WithContext(ctx) -} - -func (r *Router) handleDomains(w http.ResponseWriter, req *http.Request) { - switch req.Method { - case http.MethodGet: - if len(req.URL.Path) > len("/api/domains/") { - r.domainHandler.Get(w, req) - } else { - r.domainHandler.List(w, req) - } - case http.MethodPost: - r.domainHandler.Create(w, req) - case http.MethodDelete: - r.domainHandler.Delete(w, req) - default: - http.Error(w, `{"error":"method not allowed"}`, http.StatusMethodNotAllowed) - } -} - -func (r *Router) handleDomainUsers(w http.ResponseWriter, req *http.Request) { - path := req.URL.Path - parts := strings.Split(strings.TrimPrefix(path, "/api/domains/"), "/") - if len(parts) < 2 || parts[1] != "users" { - http.NotFound(w, req) - return - } - - domainName := parts[0] - var idStr string - if len(parts) >= 3 { - idStr = parts[2] - } - - switch req.Method { - case http.MethodGet: - if idStr != "" { - req.URL.Path = "/api/domains/" + domainName + "/users/" + idStr - r.userHandler.Get(w, req) - } else { - req.URL.Path = "/api/domains/" + domainName + "/users" - r.userHandler.List(w, req) - } - case http.MethodPost: - req.URL.Path = "/api/domains/" + domainName + "/users" - r.userHandler.Create(w, req) - case http.MethodPut: - if idStr != "" { - req.URL.Path = "/api/domains/" + domainName + "/users/" + idStr - r.userHandler.Update(w, req) - } else { - http.Error(w, `{"error":"method not allowed"}`, http.StatusMethodNotAllowed) - } - case http.MethodDelete: - if idStr != "" { - req.URL.Path = "/api/domains/" + domainName + "/users/" + idStr - r.userHandler.Delete(w, req) - } else { - http.Error(w, `{"error":"method not allowed"}`, http.StatusMethodNotAllowed) - } - default: - http.Error(w, `{"error":"method not allowed"}`, http.StatusMethodNotAllowed) - } -} - -func (r *Router) handleDomainAliases(w http.ResponseWriter, req *http.Request) { - path := req.URL.Path - parts := strings.Split(strings.TrimPrefix(path, "/api/domains/"), "/") - if len(parts) < 2 || parts[1] != "aliases" { - http.NotFound(w, req) - return - } - - domainName := parts[0] - var idStr string - if len(parts) >= 3 { - idStr = parts[2] - } - - switch req.Method { - case http.MethodGet: - req.URL.Path = "/api/domains/" + domainName + "/aliases" - r.aliasHandler.List(w, req) - case http.MethodPost: - req.URL.Path = "/api/domains/" + domainName + "/aliases" - r.aliasHandler.Create(w, req) - case http.MethodDelete: - if idStr != "" { - req.URL.Path = "/api/domains/" + domainName + "/aliases/" + idStr - r.aliasHandler.Delete(w, req) - } else { - http.Error(w, `{"error":"method not allowed"}`, http.StatusMethodNotAllowed) - } - default: - http.Error(w, `{"error":"method not allowed"}`, http.StatusMethodNotAllowed) - } -} - -func (r *Router) handleQueue(w http.ResponseWriter, req *http.Request) { - path := req.URL.Path - id := "" - if len(path) > len("/api/queue/") { - id = path[len("/api/queue/"):] - } - - switch req.Method { - case http.MethodGet: - r.queueHandler.List(w, req) - case http.MethodPost: - if id != "" { - req.URL.Path = "/api/queue/" + id - r.queueHandler.Requeue(w, req) - } else { - http.Error(w, `{"error":"method not allowed"}`, http.StatusMethodNotAllowed) - } - case http.MethodDelete: - if id != "" { - req.URL.Path = "/api/queue/" + id - r.queueHandler.Delete(w, req) - } else { - http.Error(w, `{"error":"method not allowed"}`, http.StatusMethodNotAllowed) - } - default: - http.Error(w, `{"error":"method not allowed"}`, http.StatusMethodNotAllowed) + adminGroup := engine.Group("/api/admin") + adminGroup.Use(handlers.AuthMiddleware(r.jwtManager), handlers.RequireAdmin()) + { + adminGroup.POST("/domains", r.domainHandler.Create) + adminGroup.DELETE("/domains/:name", r.domainHandler.Delete) + adminGroup.GET("/domains/:name/permissions", r.domainHandler.GetPermissions) + adminGroup.POST("/domains/:name/permissions", r.domainHandler.AddPermission) + adminGroup.DELETE("/domains/:name/permissions/:userId", r.domainHandler.RemovePermission) } }