diff --git a/cmd/server/main.go b/cmd/server/main.go index 25c82a8..8c42c9a 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -6,6 +6,7 @@ import ( "ResendIt/internal/config" "ResendIt/internal/db" "ResendIt/internal/file" + "ResendIt/internal/logger" "ResendIt/internal/user" "ResendIt/internal/util" "ResendIt/internal/web" @@ -28,29 +29,37 @@ func main() { fmt.Printf("Error loading .env file\n") } + os.Setenv("LOG_FORMAT", "json") + + logger.Log.Info().Str("type", "startup").Msg("Starting ReSendIt") + dbCon, err := db.Connect() if err != nil { - panic(fmt.Errorf("failed to connect database: %w", err)) + logger.Log.Fatal().Err(err).Str("type", "startup").Msg("Failed to connect to database") } err = dbCon.AutoMigrate(&user.User{}, &file.FileRecord{}, &config.ConfigEntry{}) if err != nil { - fmt.Printf("Error migrating database: %v\n", err) + logger.Log.Error().Err(err).Str("type", "startup").Msg("Database migration failed") return } // create temp folder path := "./tmp" if os.IsExist(os.Mkdir(path, os.ModePerm)) { - fmt.Printf("Temp folder already exists, skipping creation\n") + logger.Log.Info().Str("type", "startup").Msg("Temp folder already exists") } else { if err := os.MkdirAll(path, os.ModePerm); err != nil { - fmt.Printf("Error creating temp folder: %v\n", err) + logger.Log.Error().Err(err).Str("type", "startup").Msg("Failed to create temp folder") return } } - r := gin.Default() + // Use gin.New() instead of gin.Default() to have custom middleware + r := gin.New() + // Add structured logging and recovery middleware + r.Use(middleware.StructuredLogger()) + r.Use(gin.Recovery()) r.MaxMultipartMemory = 10 << 30 r.SetFuncMap(template.FuncMap{ @@ -60,9 +69,11 @@ func main() { }) r.LoadHTMLGlob("templates/*.html") - //r.LoadHTMLGlob("internal/templates/new/*.html") r.Static("/static", "./static") + // Add request ID middleware + r.Use(middleware.RequestIDMiddleware()) + r.GET("/ping", func(c *gin.Context) { c.JSON(http.StatusOK, gin.H{ "message": "hello", @@ -85,7 +96,7 @@ func main() { configService := config.NewService(configRepo) if err := configService.EnsureDefaults(); err != nil { - panic(fmt.Errorf("failed to ensure config defaults: %w", err)) + logger.Log.Fatal().Err(err).Str("type", "startup").Msg("Failed to ensure config defaults") } fileRepo := file.NewRepository(dbCon) @@ -125,8 +136,15 @@ func main() { os.Setenv("DOMAIN", domain) } + logger.Log.Info(). + Str("type", "startup"). + Str("port", port). + Str("domain", domain). + Msg("Server starting") + err = r.Run(":" + port) if err != nil { + logger.Log.Error().Err(err).Str("type", "startup").Msg("Server failed to start") return } } @@ -143,15 +161,15 @@ func createAdminUser(service *user.Service) { _, err := service.FindByUsername("admin") if err == nil { - fmt.Println("Admin user already exists, skipping creation") + logger.Log.Info().Str("type", "startup").Msg("Admin user already exists") return } else if errors.Is(err, user.ErrUserNotFound) { - fmt.Println("Admin user not found, creating new admin user") + logger.Log.Info().Str("type", "startup").Msg("Creating admin user") password := generateRandomPassword(16) adminUser, err := service.CreateUser("admin", password, "admin") if err != nil { - fmt.Printf("Error creating admin user: %v\n", err) + logger.Log.Error().Err(err).Str("type", "startup").Msg("Failed to create admin user") return } @@ -159,13 +177,16 @@ func createAdminUser(service *user.Service) { _, err = service.UpdateUser(adminUser) if err != nil { - fmt.Printf("Error creating admin user: %v\n", err) + logger.Log.Error().Err(err).Str("type", "startup").Msg("Failed to update admin user") } else { - fmt.Printf("Admin user created with random password: %s\n", password) + logger.Log.Info(). + Str("type", "startup"). + Str("password", password). + Msg("Admin user created") } return } - fmt.Printf("Error checking for admin user: %v\n", err) + logger.Log.Error().Err(err).Str("type", "startup").Msg("Error checking for admin user") return } diff --git a/docker-compose.yml b/docker-compose.yml index 0302912..bf01dec 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -15,6 +15,8 @@ services: DB_TYPE: sqlite DATABASE_URL: ./data/database.db + LOG_FORMAT: "json" + DOMAIN: "" USE_HTTPS: "false" diff --git a/go.mod b/go.mod index 10a591f..6283f94 100644 --- a/go.mod +++ b/go.mod @@ -7,6 +7,7 @@ require ( github.com/golang-jwt/jwt/v4 v4.5.2 github.com/google/uuid v1.6.0 github.com/joho/godotenv v1.5.1 + github.com/rs/zerolog v1.33.0 golang.org/x/crypto v0.49.0 gorm.io/driver/mysql v1.6.0 gorm.io/driver/postgres v1.6.0 @@ -37,6 +38,7 @@ require ( github.com/json-iterator/go v1.1.12 // indirect github.com/klauspost/cpuid/v2 v2.3.0 // indirect github.com/leodido/go-urn v1.4.0 // indirect + github.com/mattn/go-colorable v0.1.13 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-sqlite3 v1.14.22 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect diff --git a/go.sum b/go.sum index bff7b62..f8edec4 100644 --- a/go.sum +++ b/go.sum @@ -8,6 +8,7 @@ github.com/bytedance/sonic/loader v0.5.0 h1:gXH3KVnatgY7loH5/TkeVyXPfESoqSBSBEiD github.com/bytedance/sonic/loader v0.5.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo= github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M= github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU= +github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= 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= @@ -31,6 +32,7 @@ github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4= github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM= github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= +github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= github.com/golang-jwt/jwt/v4 v4.5.2 h1:YtQM7lnr8iZ+j5q71MGKkNw9Mn7AjHM68uc9g5fXeUI= github.com/golang-jwt/jwt/v4 v4.5.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= @@ -58,6 +60,10 @@ github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzh github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= 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-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= +github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= +github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= +github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 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/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU= @@ -69,12 +75,16 @@ github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9G github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 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/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8= github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII= github.com/quic-go/quic-go v0.59.0 h1:OLJkp1Mlm/aS7dpKgTc6cnpynnD2Xg7C1pwL6vy/SAw= github.com/quic-go/quic-go v0.59.0/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU= +github.com/rs/xid v1.5.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg= +github.com/rs/zerolog v1.33.0 h1:1cU2KZkvPxNyfgEmhHAz/1A9Bz+llsdYzklWFzgp0r8= +github.com/rs/zerolog v1.33.0/go.mod h1:/7mN4D5sKwJLZQ2b/znpjC3/GQWY/xaDXUM0kKWRHss= 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= @@ -103,7 +113,9 @@ 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/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.12.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= diff --git a/internal/api/middleware/auth.go b/internal/api/middleware/auth.go index b236982..97df0bc 100644 --- a/internal/api/middleware/auth.go +++ b/internal/api/middleware/auth.go @@ -19,6 +19,9 @@ type Claims struct { func AuthMiddleware() gin.HandlerFunc { return func(c *gin.Context) { + log := StructuredLog(c).With(). + Str("event", "auth_middleware"). + Logger() var tokenString string @@ -39,6 +42,7 @@ func AuthMiddleware() gin.HandlerFunc { } if tokenString == "" { + log.Warn().Str("reason", "no_token").Msg("Auth failed - no token provided") abortUnauthorized(c) return } @@ -51,13 +55,26 @@ func AuthMiddleware() gin.HandlerFunc { return jwtSecret, nil }) - if err != nil || !token.Valid { + if err != nil { + log.Warn(). + Str("reason", "token_parse_error"). + Err(err). + Msg("Auth failed - token parse error") + abortUnauthorized(c) + return + } + + if !token.Valid { + log.Warn().Str("reason", "invalid_token").Msg("Auth failed - invalid token") abortUnauthorized(c) return } c.Set("user_id", claims.UserID) c.Set("role", claims.Role) + c.Set("username", claims.UserID) + + log.Debug().Str("user_id", claims.UserID).Str("role", claims.Role).Msg("Auth successful") c.Next() } @@ -76,26 +93,37 @@ func abortUnauthorized(c *gin.Context) { func RequireRole(roles ...string) gin.HandlerFunc { return func(c *gin.Context) { + log := StructuredLog(c).With(). + Str("event", "role_check"). + Logger() roleValue, exists := c.Get("role") if !exists { + log.Warn().Str("reason", "no_role").Msg("Role check failed - no role in context") abortForbidden(c) return } userRole, ok := roleValue.(string) if !ok { + log.Warn().Str("reason", "invalid_role_type").Msg("Role check failed - invalid role type") abortForbidden(c) return } for _, allowed := range roles { if userRole == allowed { + log.Debug().Str("required_roles", strings.Join(roles, ",")).Str("user_role", userRole).Msg("Role check passed") c.Next() return } } + log.Warn(). + Str("required_roles", strings.Join(roles, ",")). + Str("user_role", userRole). + Msg("Role check failed - insufficient permissions") + abortForbidden(c) } } diff --git a/internal/api/middleware/logging.go b/internal/api/middleware/logging.go index c870d7c..8fca646 100644 --- a/internal/api/middleware/logging.go +++ b/internal/api/middleware/logging.go @@ -1 +1,87 @@ package middleware + +import ( + "strconv" + "time" + + "ResendIt/internal/logger" + + "github.com/gin-gonic/gin" + "github.com/rs/zerolog" +) + +// StructuredLogger returns a gin middleware that logs HTTP requests in JSON format +func StructuredLogger() gin.HandlerFunc { + return func(c *gin.Context) { + start := time.Now() + path := c.Request.URL.Path + query := c.Request.URL.RawQuery + method := c.Request.Method + + c.Next() + + latency := time.Since(start) + status := c.Writer.Status() + clientIP := c.ClientIP() + userAgent := c.Request.UserAgent() + requestID := c.GetString("request_id") + + evt := logger.Log.Info(). + Str("type", "http_request"). + Str("method", method). + Str("path", path). + Str("query", query). + Int("status", status). + Dur("latency_ms", latency). + Str("client_ip", clientIP). + Str("user_agent", userAgent). + Str("request_id", requestID) + + if len(c.Errors) > 0 { + evt = evt.Str("error", c.Errors.ByType(gin.ErrorTypePrivate).String()) + } + + if userID, exists := c.Get("user_id"); exists { + evt = evt.Str("user_id", userID.(string)) + } + if username, exists := c.Get("username"); exists { + evt = evt.Str("username", username.(string)) + } + + evt.Msg("") + } +} + +// RequestIDMiddleware adds a unique request ID to each request +func RequestIDMiddleware() gin.HandlerFunc { + return func(c *gin.Context) { + requestID := c.GetHeader("X-Request-ID") + if requestID == "" { + requestID = generateRequestID() + } + c.Set("request_id", requestID) + c.Header("X-Request-ID", requestID) + c.Next() + } +} + +func generateRequestID() string { + // Simple request ID generation - could use uuid package for more entropy + return strconv.FormatInt(time.Now().UnixNano(), 36) +} + +// StructuredLog returns a child logger with HTTP context for use in handlers +func StructuredLog(c *gin.Context) zerolog.Logger { + log := logger.Log.With(). + Str("type", "app_log"). + Str("request_id", c.GetString("request_id")) + + if userID, exists := c.Get("user_id"); exists { + log = log.Str("user_id", userID.(string)) + } + if username, exists := c.Get("username"); exists { + log = log.Str("username", username.(string)) + } + + return log.Logger() +} diff --git a/internal/api/middleware/ratelimit.go b/internal/api/middleware/ratelimit.go index 00498ab..5e75e6b 100644 --- a/internal/api/middleware/ratelimit.go +++ b/internal/api/middleware/ratelimit.go @@ -114,6 +114,9 @@ func RateLimitByIPDynamic(maxFn func() int, per time.Duration, burstFn func() in } return func(c *gin.Context) { + log := StructuredLog(c).With(). + Str("event", "rate_limit_check"). + Logger() // Kinda a shitty fix if c.FullPath() == "/api/files/upload/chunk" || c.FullPath() == "/api/files/upload/init" || c.FullPath() == "/api/files/upload/complete" { @@ -136,6 +139,12 @@ func RateLimitByIPDynamic(maxFn func() int, per time.Duration, burstFn func() in client := getClient(ip, now, max, burst) if !client.bucket.allow() { + log.Warn(). + Str("ip", ip). + Int("max", max). + Int("burst", burst). + Msg("Rate limit exceeded") + c.AbortWithStatusJSON(http.StatusTooManyRequests, gin.H{ "error": "rate limit exceeded", }) @@ -144,4 +153,4 @@ func RateLimitByIPDynamic(maxFn func() int, per time.Duration, burstFn func() in c.Next() } -} +} \ No newline at end of file diff --git a/internal/auth/handler.go b/internal/auth/handler.go index cb0f055..c1d6069 100644 --- a/internal/auth/handler.go +++ b/internal/auth/handler.go @@ -2,6 +2,9 @@ package auth import ( "os" + "time" + + "ResendIt/internal/api/middleware" "github.com/gin-gonic/gin" ) @@ -37,16 +40,44 @@ func (h *Handler) Login(c *gin.Context) { } if err := c.ShouldBindJSON(&req); err != nil { + log := middleware.StructuredLog(c) + + log.Warn(). + Str("event", "login_failed"). + Str("reason", "invalid_request"). + Str("username", req.Username). + Msg("Login attempt with invalid request") + c.JSON(400, gin.H{"error": "Invalid request body"}) return } + log := middleware.StructuredLog(c).With(). + Str("event", "login_attempt"). + Str("username", req.Username). + Str("ip", c.ClientIP()). + Logger() + + start := time.Now() token, err := h.service.Login(req.Username, req.Password) + latency := time.Since(start) + if err != nil { + log.Warn(). + Str("result", "failed"). + Dur("latency_ms", latency). + Err(err). + Msg("Login failed") + c.JSON(401, gin.H{"error": "Invalid credentials"}) return } + log.Info(). + Str("result", "success"). + Dur("latency_ms", latency). + Msg("Login successful") + isSecure := os.Getenv("USE_HTTPS") == "true" c.SetCookie( @@ -56,7 +87,7 @@ func (h *Handler) Login(c *gin.Context) { "/", os.Getenv("DOMAIN"), isSecure, - true, // httpOnly (IMPORTANT) + true, ) c.JSON(200, gin.H{"token": token}) diff --git a/internal/db/db.go b/internal/db/db.go index dfc60ae..3cddb37 100644 --- a/internal/db/db.go +++ b/internal/db/db.go @@ -5,6 +5,8 @@ import ( "os" "path/filepath" + "ResendIt/internal/logger" + "gorm.io/driver/mysql" "gorm.io/driver/postgres" "gorm.io/driver/sqlite" @@ -21,19 +23,43 @@ func Connect() (*gorm.DB, error) { dsn = "./data/database.db" } + logger.Log.Info(). + Str("type", "db_connect"). + Str("db_type", dbType). + Msg("Connecting to database") + + var db *gorm.DB + var err error + switch dbType { case "sqlite": - return connectSQLite(dsn) + db, err = connectSQLite(dsn) case "postgres": - return connectPostgres(dsn) + db, err = connectPostgres(dsn) case "mysql": - return connectMySQL(dsn) + db, err = connectMySQL(dsn) default: return nil, fmt.Errorf("unsupported DB_TYPE: %s", dbType) } + + if err != nil { + logger.Log.Error(). + Str("type", "db_connect"). + Str("db_type", dbType). + Err(err). + Msg("Failed to connect to database") + return nil, err + } + + logger.Log.Info(). + Str("type", "db_connect"). + Str("db_type", dbType). + Msg("Database connected successfully") + + return db, nil } func connectSQLite(filePath string) (*gorm.DB, error) { @@ -51,6 +77,12 @@ func connectSQLite(filePath string) (*gorm.DB, error) { return nil, fmt.Errorf("failed to open SQLite database: %w", err) } + sqlDB, err := db.DB() + if err == nil { + sqlDB.SetMaxIdleConns(10) + sqlDB.SetMaxOpenConns(100) + } + return db, nil } @@ -64,6 +96,12 @@ func connectPostgres(dsn string) (*gorm.DB, error) { return nil, fmt.Errorf("failed to connect to Postgres: %w", err) } + sqlDB, err := db.DB() + if err == nil { + sqlDB.SetMaxIdleConns(10) + sqlDB.SetMaxOpenConns(100) + } + return db, nil } @@ -77,5 +115,11 @@ func connectMySQL(dsn string) (*gorm.DB, error) { return nil, fmt.Errorf("failed to connect to MySQL: %w", err) } + sqlDB, err := db.DB() + if err == nil { + sqlDB.SetMaxIdleConns(10) + sqlDB.SetMaxOpenConns(100) + } + return db, nil -} +} \ No newline at end of file diff --git a/internal/file/handlers.go b/internal/file/handlers.go index 854f035..9aa197c 100644 --- a/internal/file/handlers.go +++ b/internal/file/handlers.go @@ -1,12 +1,13 @@ package file import ( + "ResendIt/internal/api/middleware" "ResendIt/internal/config" + "ResendIt/internal/logger" "ResendIt/internal/notify" "ResendIt/internal/util" "fmt" "io" - "log" "net/http" "os" "path/filepath" @@ -34,26 +35,38 @@ func NewHandler(s *Service, cfg ConfigService) *Handler { } func (h *Handler) Upload(c *gin.Context) { + log := middleware.StructuredLog(c).With(). + Str("event", "file_upload"). + Logger() + err := c.Request.ParseMultipartForm(0) if err != nil { + log.Warn().Str("reason", "parse_error").Err(err).Msg("Upload failed") c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } file, err := c.FormFile("file") if err != nil { + log.Warn().Str("reason", "missing_file").Msg("Upload failed") c.JSON(http.StatusBadRequest, gin.H{"error": "missing file"}) return } maxSize := h.configService.GetInt64Default(config.KeyUploadMaxFileSizeBytes, config.DefaultUploadMaxFileSizeBytes) if file.Size > maxSize { + log.Warn(). + Str("reason", "file_too_large"). + Int64("file_size", file.Size). + Int64("max_size", maxSize). + Msg("Upload rejected") c.JSON(http.StatusRequestEntityTooLarge, gin.H{"error": "file too large"}) return } f, err := file.Open() if err != nil { + log.Error().Err(err).Msg("Failed to open uploaded file") c.JSON(http.StatusInternalServerError, gin.H{"error": "cannot open file"}) return } @@ -80,10 +93,18 @@ func (h *Handler) Upload(c *gin.Context) { duration, ) if err != nil { + log.Error().Err(err).Msg("Upload failed") c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } + log.Info(). + Str("file_id", record.ID). + Str("filename", record.Filename). + Int64("file_size", record.Size). + Bool("once", once). + Msg("File uploaded successfully") + enabled := h.configService.GetIntDefault(config.KeyUseNtfy, config.DefaultUseNtfy) if enabled == 1 { ntfyURL := h.configService.GetStringDefault(config.KeyNtfyUrl, "") @@ -94,7 +115,7 @@ func (h *Handler) Upload(c *gin.Context) { msg := fmt.Sprintf("%s (%s)\nID: %s", record.Filename, util.HumanSize(record.Size), record.ID) clickUrl := fmt.Sprintf("f/%s", record.ViewID) if err := notify.Publish(ntfyURL, topic, title, msg, clickUrl); err != nil { - log.Printf("ntfy publish failed: %v", err) + logger.Log.Warn().Err(err).Str("type", "ntfy").Msg("ntfy publish failed") } }() } @@ -110,13 +131,21 @@ func (h *Handler) Upload(c *gin.Context) { } func (h *Handler) View(c *gin.Context) { + log := middleware.StructuredLog(c).With(). + Str("event", "file_view"). + Logger() + id := c.Param("id") record, err := h.service.DownloadFile(id) if err != nil { + log.Warn().Str("file_id", id).Err(err).Msg("File view failed - not found") c.HTML(http.StatusOK, "error.html", nil) return } + + log.Info().Str("file_id", id).Str("filename", record.Filename).Msg("File viewed") + name := util.SafeFilename(record.Filename) c.Header("Content-Disposition", fmt.Sprintf(`inline; filename="%s"`, name)) c.Header("X-Content-Type-Options", "nosniff") @@ -134,31 +163,46 @@ func isXSSRisk(filename string) bool { } func (h *Handler) Download(c *gin.Context) { + log := middleware.StructuredLog(c).With(). + Str("event", "file_download"). + Logger() + id := c.Param("id") record, err := h.service.DownloadFile(id) if err != nil { + log.Warn().Str("file_id", id).Err(err).Msg("File download failed - not found") c.HTML(http.StatusOK, "error.html", nil) return } + + log.Info().Str("file_id", id).Str("filename", record.Filename).Int64("size", record.Size).Msg("File downloaded") + name := util.SafeFilename(record.Filename) c.Header("Content-Disposition", fmt.Sprintf(`inline; filename="%s"`, name)) c.Header("X-Content-Type-Options", "nosniff") - //c.Header("Content-Security-Policy", "default-src 'none'; img-src 'self'; media-src 'self'; script-src 'none'; style-src 'none';") - //c.Header("Content-Type", "application/octet-stream") c.File(record.Path) } func (h *Handler) Delete(c *gin.Context) { + log := middleware.StructuredLog(c).With(). + Str("event", "file_delete"). + Logger() + id := c.Param("del_id") - _, err := h.service.DeleteFileByDeletionID(id) + record, err := h.service.DeleteFileByDeletionID(id) if err != nil { + log.Warn().Str("deletion_id", id).Err(err).Msg("File delete failed") c.HTML(http.StatusOK, "error.html", nil) return } - //c.JSON(http.StatusOK, gin.H{"status": "deleted"}) + log.Info(). + Str("file_id", record.ID). + Str("filename", record.Filename). + Msg("File deleted") + c.HTML(http.StatusOK, "deleted.html", nil) } @@ -185,18 +229,29 @@ func (h *Handler) AdminGet(c *gin.Context) { } func (h *Handler) AdminDelete(c *gin.Context) { + log := middleware.StructuredLog(c).With(). + Str("event", "admin_file_delete"). + Logger() + id := c.Param("id") - _, err := h.service.DeleteFileByID(id) + record, err := h.service.DeleteFileByID(id) if err != nil { + log.Warn().Str("file_id", id).Err(err).Msg("Admin file delete failed") c.JSON(http.StatusNotFound, gin.H{"error": "file not found"}) return } + log.Info().Str("file_id", record.ID).Str("filename", record.Filename).Msg("Admin deleted file") + c.Redirect(301, "/admin") } func (h *Handler) AdminForceDelete(c *gin.Context) { + log := middleware.StructuredLog(c).With(). + Str("event", "admin_file_force_delete"). + Logger() + id := c.Param("id") _, err := h.service.GetFileByID(id) @@ -206,14 +261,21 @@ func (h *Handler) AdminForceDelete(c *gin.Context) { } if _, err := h.service.ForceDelete(id); err != nil { + log.Error().Err(err).Msg("Admin force delete failed") c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } + log.Info().Str("file_id", id).Msg("Admin force deleted file") + c.Redirect(301, "/admin") } func (h *Handler) AdminReinstate(c *gin.Context) { + log := middleware.StructuredLog(c).With(). + Str("event", "admin_file_reinstate"). + Logger() + id := c.Param("id") _, err := h.service.GetFileByID(id) @@ -223,10 +285,13 @@ func (h *Handler) AdminReinstate(c *gin.Context) { } if _, err := h.service.ReinstateFile(id); err != nil { + log.Error().Err(err).Msg("Admin reinstate failed") c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } + log.Info().Str("file_id", id).Msg("Admin reinstated file") + c.Redirect(301, "/admin") } @@ -331,6 +396,10 @@ func (h *Handler) UploadChunk(c *gin.Context) { } func (h *Handler) UploadComplete(c *gin.Context) { + log := middleware.StructuredLog(c).With(). + Str("event", "chunked_upload_complete"). + Logger() + var req struct { FileID string `json:"fileId"` Filename string `json:"filename"` @@ -377,6 +446,7 @@ func (h *Handler) UploadComplete(c *gin.Context) { 24*time.Hour, ) if err != nil { + log.Error().Err(err).Msg("Chunked upload failed") c.JSON(500, gin.H{"error": err.Error()}) return } @@ -384,6 +454,12 @@ func (h *Handler) UploadComplete(c *gin.Context) { // cleanup temp _ = os.RemoveAll(tmpDir) + log.Info(). + Str("file_id", record.ID). + Str("filename", record.Filename). + Int("chunks", req.TotalChunks). + Msg("Chunked upload completed") + c.JSON(200, gin.H{ "id": record.ID, "view_key": record.ViewID, @@ -414,4 +490,4 @@ func (h *Handler) UploadStatus(c *gin.Context) { c.JSON(200, gin.H{ "uploadedChunks": uploaded, }) -} +} \ No newline at end of file diff --git a/internal/logger/logger.go b/internal/logger/logger.go new file mode 100644 index 0000000..668e969 --- /dev/null +++ b/internal/logger/logger.go @@ -0,0 +1,40 @@ +package logger + +import ( + "os" + "time" + + "github.com/rs/zerolog" +) + +var Log zerolog.Logger + +func init() { + output := os.Stderr + + logFormat := os.Getenv("LOG_FORMAT") + if logFormat == "json" { + Log = zerolog.New(output).With().Timestamp().Logger() + } else { + Log = zerolog.New(zerolog.ConsoleWriter{ + Out: output, + TimeFormat: time.RFC3339, + }).With().Timestamp().Logger() + } + + level := os.Getenv("LOG_LEVEL") + switch level { + case "debug": + Log = Log.Level(zerolog.DebugLevel) + case "warn": + Log = Log.Level(zerolog.WarnLevel) + case "error": + Log = Log.Level(zerolog.ErrorLevel) + default: + Log = Log.Level(zerolog.InfoLevel) + } +} + +func With() zerolog.Context { + return Log.With() +} diff --git a/internal/user/handler.go b/internal/user/handler.go index 8690b46..08331f7 100644 --- a/internal/user/handler.go +++ b/internal/user/handler.go @@ -2,6 +2,9 @@ package user import ( "fmt" + "time" + + "ResendIt/internal/api/middleware" "github.com/gin-gonic/gin" ) @@ -15,6 +18,10 @@ func NewHandler(service *Service) *Handler { } func (h *Handler) Register(c *gin.Context) { + log := middleware.StructuredLog(c).With(). + Str("event", "user_register"). + Logger() + var req struct { Username string `json:"username"` Password string `json:"password"` @@ -22,20 +29,43 @@ func (h *Handler) Register(c *gin.Context) { } if err := c.ShouldBindJSON(&req); err != nil { + log.Warn(). + Str("reason", "invalid_request"). + Msg("Registration failed") + c.JSON(400, gin.H{"error": "invalid request"}) return } user, err := h.service.CreateUser(req.Username, req.Password, req.Role) if err != nil { + log.Error(). + Err(err). + Str("username", req.Username). + Msg("Registration failed") + c.JSON(500, gin.H{"error": err.Error()}) return } - c.JSON(201, gin.H{"id": user.ID, "username": user.Username, "role": user.Role}) + log.Info(). + Str("user_id", fmt.Sprint(user.ID)). + Str("username", user.Username). + Str("role", user.Role). + Msg("User registered successfully") + + c.JSON(201, gin.H{ + "id": user.ID, + "username": user.Username, + "role": user.Role, + }) } func (h *Handler) ChangePassword(c *gin.Context) { + log := middleware.StructuredLog(c).With(). + Str("event", "password_change"). + Logger() + var req struct { OldPassword string `json:"old_password"` NewPassword string `json:"new_password"` @@ -43,41 +73,75 @@ func (h *Handler) ChangePassword(c *gin.Context) { userID, exists := c.Get("user_id") if !exists { - fmt.Println("User ID not found in context") c.JSON(401, gin.H{"error": "unauthorized"}) return } if err := c.ShouldBindJSON(&req); err != nil { + log.Warn(). + Str("reason", "invalid_request"). + Msg("Password change failed") + c.JSON(400, gin.H{"error": "invalid request"}) return } - err := h.service.ChangePassword(userID.(string), req.OldPassword, req.NewPassword) + uid := fmt.Sprint(userID) + + start := time.Now() + err := h.service.ChangePassword(uid, req.OldPassword, req.NewPassword) + latency := time.Since(start) + if err != nil { + log.Warn(). + Str("user_id", uid). + Str("reason", err.Error()). + Dur("latency_ms", latency). + Msg("Password change failed") + c.JSON(500, gin.H{"error": err.Error()}) return } + log.Info(). + Str("user_id", uid). + Dur("latency_ms", latency). + Msg("Password changed successfully") + c.JSON(200, gin.H{"message": "password changed successfully"}) } func ForcePasswordChangeMiddleware(userService *Service) gin.HandlerFunc { return func(c *gin.Context) { + log := middleware.StructuredLog(c).With(). + Str("event", "force_password_check"). + Logger() + userID, exists := c.Get("user_id") if !exists { c.Next() return } - user, err := userService.FindByID(userID.(string)) + uid := fmt.Sprint(userID) + + user, err := userService.FindByID(uid) if err != nil { + log.Error(). + Err(err). + Str("user_id", uid). + Msg("Failed to find user for password check") + c.AbortWithStatus(500) return } - // Allow access to change password page itself if user.ForceChangePassword && c.Request.URL.Path != "/change-password" { + log.Warn(). + Str("user_id", uid). + Str("path", c.Request.URL.Path). + Msg("Access denied - force password change required") + c.Redirect(302, "/change-password") c.Abort() return diff --git a/internal/web/config.go b/internal/web/config.go index da018c1..d943be9 100644 --- a/internal/web/config.go +++ b/internal/web/config.go @@ -1,6 +1,7 @@ package web import ( + "ResendIt/internal/api/middleware" "ResendIt/internal/config" "net/http" "strconv" @@ -30,6 +31,10 @@ type ConfigPageData struct { // ConfigPage renders a modular admin config screen. func (h *Handler) ConfigPage(c *gin.Context) { + log := middleware.StructuredLog(c).With(). + Str("event", "config_page_view"). + Logger() + cfg := h.configService maxBytes := cfg.GetInt64Default(config.KeyUploadMaxFileSizeBytes, config.DefaultUploadMaxFileSizeBytes) @@ -48,10 +53,16 @@ func (h *Handler) ConfigPage(c *gin.Context) { NtfyTopic: cfg.GetStringDefault(config.KeyNtfyTopic, config.DefaultNtfyTopic), } + log.Debug().Msg("Config page viewed") + c.HTML(http.StatusOK, "config.html", data) } func (h *Handler) ConfigSave(c *gin.Context) { + log := middleware.StructuredLog(c).With(). + Str("event", "config_save"). + Logger() + cfg := h.configService // Parse + validate. @@ -87,22 +98,26 @@ func (h *Handler) ConfigSave(c *gin.Context) { newMODT, err := strconv.Unquote(`"` + c.PostForm("site_modt") + `"`) if err != nil { + log.Warn().Str("reason", "invalid_modtext").Msg("Config save failed") h.renderConfigError(c, "invalid modtext") return } maxMB, err := parseInt64("upload_max_file_size_mb", 1, 1024*1024) if err != nil { + log.Warn().Str("key", "upload_max_file_size_mb").Msg("Config save failed - invalid value") h.renderConfigError(c, "invalid max file size") return } maxFiles, err := parseInt("upload_multi_max_files", 1, 500) if err != nil { + log.Warn().Str("key", "upload_multi_max_files").Msg("Config save failed - invalid value") h.renderConfigError(c, "invalid max files") return } maxHours, err := parseInt("upload_max_hours", 1, 24*365) if err != nil { + log.Warn().Str("key", "upload_max_hours").Msg("Config save failed - invalid value") h.renderConfigError(c, "invalid max hours") return } @@ -110,27 +125,32 @@ func (h *Handler) ConfigSave(c *gin.Context) { // Rate limits: stored, but not applied dynamically yet. loginPerMin, err := parseInt("ratelimit_login_per_minute", 1, 10000) if err != nil { + log.Warn().Str("key", "ratelimit_login_per_minute").Msg("Config save failed - invalid value") h.renderConfigError(c, "invalid login rate") return } loginBurst, err := parseInt("ratelimit_login_burst", 1, 10000) if err != nil { + log.Warn().Str("key", "ratelimit_login_burst").Msg("Config save failed - invalid value") h.renderConfigError(c, "invalid login burst") return } apiPerMin, err := parseInt("ratelimit_api_per_minute", 1, 100000) if err != nil { + log.Warn().Str("key", "ratelimit_api_per_minute").Msg("Config save failed - invalid value") h.renderConfigError(c, "invalid api rate") return } apiBurst, err := parseInt("ratelimit_api_burst", 1, 100000) if err != nil { + log.Warn().Str("key", "ratelimit_api_burst").Msg("Config save failed - invalid value") h.renderConfigError(c, "invalid api burst") return } useNTFY, err := strconv.ParseBool(c.PostForm("ntfy_use")) if err != nil { + log.Warn().Str("key", "ntfy_use").Msg("Config save failed - invalid value") h.renderConfigError(c, "invalid ntfy use value") return } @@ -139,14 +159,17 @@ func (h *Handler) ConfigSave(c *gin.Context) { // Persist. if err := cfg.SetString(config.KeyUploadMaxFileSizeBytes, strconv.FormatInt(maxMB*1024*1024, 10)); err != nil { + log.Error().Err(err).Str("key", config.KeyUploadMaxFileSizeBytes).Msg("Config save failed") h.renderConfigError(c, err.Error()) return } if err := cfg.SetString(config.KeyUploadMultiMaxFiles, strconv.Itoa(maxFiles)); err != nil { + log.Error().Err(err).Str("key", config.KeyUploadMultiMaxFiles).Msg("Config save failed") h.renderConfigError(c, err.Error()) return } if err := cfg.SetString(config.KeyUploadMaxHours, strconv.Itoa(maxHours)); err != nil { + log.Error().Err(err).Str("key", config.KeyUploadMaxHours).Msg("Config save failed") h.renderConfigError(c, err.Error()) return } @@ -167,6 +190,13 @@ func (h *Handler) ConfigSave(c *gin.Context) { _ = cfg.SetString(config.KeyNtfyUrl, ntfyUrl) _ = cfg.SetString(config.KeyNtfyTopic, ntfyTopic) + log.Info(). + Str("modtext", newMODT). + Int64("max_file_size_mb", maxMB). + Int("max_files", maxFiles). + Int("max_hours", maxHours). + Msg("Config saved successfully") + c.Redirect(http.StatusFound, "/config?saved=1") } @@ -183,4 +213,4 @@ func (h *Handler) renderConfigError(c *gin.Context, msg string) { RateLimitApiBurst: h.configService.GetIntDefault(config.KeyRateLimitApiBurst, config.DefaultRateLimitApiBurst), } c.HTML(http.StatusBadRequest, "config.html", data) -} +} \ No newline at end of file diff --git a/internal/web/handler.go b/internal/web/handler.go index 509fe5f..d50b685 100644 --- a/internal/web/handler.go +++ b/internal/web/handler.go @@ -1,6 +1,7 @@ package web import ( + "ResendIt/internal/api/middleware" "ResendIt/internal/buildinfo" "ResendIt/internal/config" "ResendIt/internal/file" @@ -51,16 +52,23 @@ func (h *Handler) LoginPage(c *gin.Context) { } func (h *Handler) FileView(c *gin.Context) { + log := middleware.StructuredLog(c).With(). + Str("event", "file_view_page"). + Logger() + id := c.Param("id") fileRecord, err := h.fileService.GetFileByViewID(id) if err != nil { + log.Warn().Str("view_id", id).Err(err).Msg("File view failed - not found") c.HTML(404, "error.html", gin.H{ "MODT": h.configService.GetStringDefault(config.KeyModtext, config.DefaultModt), }) return } + log.Info().Str("view_id", id).Str("filename", fileRecord.Filename).Msg("File view page rendered") + downloadKey := fileRecord.ID deleteKey := fileRecord.DeletionID @@ -73,6 +81,10 @@ func (h *Handler) FileView(c *gin.Context) { } func (h *Handler) AdminPage(c *gin.Context) { + log := middleware.StructuredLog(c).With(). + Str("event", "admin_page_view"). + Logger() + pageStr := c.Query("page") page, err := strconv.Atoi(pageStr) if err != nil || page < 1 { @@ -84,6 +96,7 @@ func (h *Handler) AdminPage(c *gin.Context) { files, totalCount, err := h.fileService.GetPaginatedFiles(limit, offset) if err != nil { + log.Error().Err(err).Msg("Failed to load files for admin page") c.HTML(500, "admin.html", gin.H{ "error": err.Error(), }) @@ -117,6 +130,8 @@ func (h *Handler) AdminPage(c *gin.Context) { totalPages := (totalCount + limit - 1) / limit + log.Debug().Int("page", page).Int("total_files", totalCount).Msg("Admin page viewed") + c.HTML(200, "admin.html", gin.H{ "Files": adminFiles, "Page": page, @@ -126,10 +141,16 @@ func (h *Handler) AdminPage(c *gin.Context) { } func (h *Handler) Logout(c *gin.Context) { + log := middleware.StructuredLog(c).With(). + Str("event", "logout"). + Logger() + + log.Info().Msg("User logged out") + c.SetCookie("auth_token", "", -1, "/", "", false, true) c.Redirect(302, "/") } func (h *Handler) ChangePasswordPage(c *gin.Context) { c.HTML(200, "changePassword.html", nil) -} +} \ No newline at end of file