diff --git a/go.mod b/go.mod index d938e8a..dc94d15 100644 --- a/go.mod +++ b/go.mod @@ -24,6 +24,7 @@ require ( github.com/go-playground/universal-translator v0.18.1 // indirect github.com/go-playground/validator/v10 v10.26.0 // indirect github.com/goccy/go-json v0.10.5 // indirect + github.com/google/uuid v1.6.0 // indirect github.com/json-iterator/go v1.1.12 // indirect github.com/klauspost/cpuid/v2 v2.2.10 // indirect github.com/kr/text v0.2.0 // indirect diff --git a/go.sum b/go.sum index 9ff89cb..dee93ff 100644 --- a/go.sum +++ b/go.sum @@ -57,6 +57,8 @@ github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVI github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/gorilla/context v1.1.2 h1:WRkNAv2uoa03QNIc1A6u4O7DAGMUVoopZhkiXWA2V1o= github.com/gorilla/context v1.1.2/go.mod h1:KDPwT9i/MeWHiLl90fuTgrt4/wPcv75vFAZLaOOcbxM= github.com/gorilla/securecookie v1.1.2 h1:YCIWL56dvtr73r6715mJs5ZvhtnY73hBvEF8kXD8ePA= diff --git a/internal/app/server/middleware.go b/internal/app/server/middleware.go index 16b0966..cd13063 100644 --- a/internal/app/server/middleware.go +++ b/internal/app/server/middleware.go @@ -2,13 +2,13 @@ package server import ( "fmt" - "log" "net/http" "runtime/debug" "github.com/gin-gonic/gin" "github.com/golang-jwt/jwt/v5" domain "github.com/haydenhargreaves/Potion/internal/domain/server" + "github.com/haydenhargreaves/Potion/internal/infrastructure/logging" ) // DepedencyInjectionMiddleware injects the dependencies into the context set. This is a middleware @@ -77,14 +77,13 @@ func JwtAuthMiddleWare(jwtSecretKey []byte) gin.HandlerFunc { } } -func RecoveryMiddleware() gin.HandlerFunc { - +func RecoveryMiddleware(logs []logging.Logger) gin.HandlerFunc { return func(ctx *gin.Context) { defer func() { if r := recover(); r != nil { // Log the panic with stack trace err := fmt.Errorf("panic recovered: %v\n%s", r, debug.Stack()) - log.Printf("[PANIC RECOVERY] %s", err) + logging.LogAll(logs, logging.LogLevelFatal, "[PANIC RECOVERY] %s\n", err) ctx.JSON(http.StatusOK, gin.H{ "status": http.StatusOK, diff --git a/internal/app/server/middleware_v2.go b/internal/app/server/middleware_v2.go index fbdaa5f..1c73d8e 100644 --- a/internal/app/server/middleware_v2.go +++ b/internal/app/server/middleware_v2.go @@ -3,10 +3,12 @@ package server import ( "fmt" "net/http" + "time" "github.com/gin-gonic/gin" "github.com/golang-jwt/jwt/v5" domain "github.com/haydenhargreaves/Potion/internal/domain/server" + "github.com/haydenhargreaves/Potion/internal/infrastructure/logging" ) // JwtAuthMiddlewareV2 is responsible for protecting routes. Anything that may go wrong @@ -64,9 +66,9 @@ func JwtAuthMiddlewareV2(jwtSecretKey []byte) gin.HandlerFunc { } } -// JwtOptionalAuthMiddlewareV2 is responsible for collecting user data for routes where -// authentication is optional. Meaning: if the use is not logged in, this function does -// not fail or return, it simply does nothing. But if the user is logged in, then the +// JwtOptionalAuthMiddlewareV2 is responsible for collecting user data for routes where +// authentication is optional. Meaning: if the use is not logged in, this function does +// not fail or return, it simply does nothing. But if the user is logged in, then the // 'userId' and 'userEmail' context values are set. // // e.g., `userIdAny, exists := ctx.Get("userId")` @@ -96,3 +98,34 @@ func JwtOptionalAuthMiddlewareV2(jwtSecretKey []byte) gin.HandlerFunc { ctx.Next() } } + +func LoggingMiddleware(logs []logging.Logger) gin.HandlerFunc { + // TODO: Need traces using IDs? + return func(ctx *gin.Context) { + start := time.Now() + + ctx.Next() + + var ( + status int = ctx.Writer.Status() + latency string = time.Since(start).String() + client string = ctx.ClientIP() + method string = ctx.Request.Method + path string = ctx.Request.URL.Path + ) + + // TODO: Add color to status + + format := "%d | %-14s | %15s | %-9s \"%s\"" + logging.LogAll( + logs, + logging.LogLevelInfo, + format, + status, + latency, + client, + method, + path, + ) + } +} diff --git a/internal/app/server/server.go b/internal/app/server/server.go index a2a7b52..958d132 100644 --- a/internal/app/server/server.go +++ b/internal/app/server/server.go @@ -13,6 +13,8 @@ import ( domain "github.com/haydenhargreaves/Potion/internal/domain/server" "github.com/haydenhargreaves/Potion/internal/infrastructure/auth" "github.com/haydenhargreaves/Potion/internal/infrastructure/database/repository" + "github.com/haydenhargreaves/Potion/internal/infrastructure/logging" + "github.com/haydenhargreaves/Potion/internal/infrastructure/logging/loggers" _ "github.com/lib/pq" ) @@ -23,18 +25,24 @@ type Server struct { config cors.Config DB *sql.DB deps domain.InjectedDependencies + logs []logging.Logger } // Init initializes the server with the provided port. CORS settings are defined here. // A pointer to a server object is returned which allows for method chaining. func Init(port int) *Server { server := &Server{ - Router: gin.Default(), + Router: gin.New(), // Not default anymore, to allow for custom logger port: port, config: cors.DefaultConfig(), + logs: []logging.Logger{}, } + // Default logger which logs everything + server.logs = append(server.logs, loggers.NewConsoleLogger(logging.LogLevelTrace)) + // Some stuff for templ rendering + // TODO: Remove this htmlRenderer := server.Router.HTMLRender server.Router.HTMLRender = &gintemplrenderer.HTMLTemplRenderer{FallbackHtmlRenderer: htmlRenderer} @@ -54,22 +62,27 @@ func Init(port int) *Server { // Start starts the server on the port provided when the server was initialized func (s *Server) Start() { + logging.LogAll(s.logs, logging.LogLevelDebug, "Server started on :%d\n", s.port) s.Router.Run(fmt.Sprintf(":%d", s.port)) } -// TODO: (9/4/2025) Abstract these functions and cleanup. This is fucking messy... +// TODO: (9/4/2025) Abstract these functions and cleanup. This is fucking messy... +// TODO: (1/26/2026) Abstract these functions and cleanup. This is fucking messy... still func (s *Server) Setup() *Server { // SETUP THE ENVIRONMENT CONFIGURATION - cfg, err := domain.LoadEnvironment() + cfg, err := domain.LoadEnvironment(s.logs) if err != nil { + logging.LogAll(s.logs, logging.LogLevelFatal, err.Error()) panic(err.Error()) } if cfg == nil { + logging.LogAll(s.logs, logging.LogLevelFatal, "Environment configuration is nil, crashing.") panic("Environment configuration is nil, crashing.") } + // TODO: Using release on them all? Def need to clean this shitty environment up if cfg.Environment == "dev" { - gin.SetMode(gin.DebugMode) + gin.SetMode(gin.ReleaseMode) } else if cfg.Environment == "prod" { gin.SetMode(gin.ReleaseMode) } else { @@ -103,6 +116,24 @@ func (s *Server) Setup() *Server { s.DB = db + // TODO: Implement environment here for logging file + path := "./logs.log" + + fileLogger, cleanup, err := loggers.NewFileLogger(path, logging.LogLevelDebug) + if err != nil { + logging.LogAll(s.logs, logging.LogLevelWarning, "Failed to create file logger. %s\n", err.Error()) + } else { + s.logs = append(s.logs, fileLogger) + defer cleanup() + } + + databaseLogger, err := loggers.NewDatabaseLogger(s.DB, "logs", logging.LogLevelInfo) + if err != nil { + logging.LogAll(s.logs, logging.LogLevelWarning, "Failed to create database logger. %s\n", err.Error()) + } else { + s.logs = append(s.logs, databaseLogger) + } + // SETUP JWT jwtSecret := []byte(cfg.JwtSecret) @@ -111,7 +142,7 @@ func (s *Server) Setup() *Server { recipeRepo := repository.NewRecipeRepository(s.DB) engagementRepo := repository.NewEngagementRepository(s.DB) userService := service.NewUserService(userRepo) - authService := service.NewAuthService(userRepo, jwtSecret) + authService := service.NewAuthService(userRepo, jwtSecret, s.logs) recipeService := service.NewRecipeService(recipeRepo, engagementRepo) engagementService := service.NewEngagementService(engagementRepo, recipeRepo) @@ -124,9 +155,8 @@ func (s *Server) Setup() *Server { } // Apply middleware - s.Router.Use(RecoveryMiddleware()) - // NOTE: No longer running on every connection! - // s.Router.Use(JwtAuthMiddleWare(jwtSecret)) + // TODO: Review the recovery middleware + s.Router.Use(gin.Recovery(), RecoveryMiddleware(s.logs), LoggingMiddleware(s.logs)) // Redirect index to home page: Update this as needed s.Router.GET("/", func(ctx *gin.Context) { ctx.Redirect(http.StatusSeeOther, domain.WEB_HOME) }) @@ -219,14 +249,21 @@ func (s *Server) Setup() *Server { router_api_v2.GET("/user/recipes/made", JwtAuthMiddlewareV2([]byte(cfg.JwtSecret)), s.GetAuthenicatedUserMadeRecipesV2) router_api_v2.GET("/user/recipes/viewed", JwtAuthMiddlewareV2([]byte(cfg.JwtSecret)), s.GetAuthenicatedUserViewedRecipesV2) - router_api_v2.GET("/protected", JwtAuthMiddlewareV2([]byte(cfg.JwtSecret)), func(ctx *gin.Context) { - ctx.JSON(http.StatusOK, gin.H{"msg": "YAY"}) - }) - router_api_v2.POST("/engagement/view/:id", JwtOptionalAuthMiddlewareV2([]byte(cfg.JwtSecret)), s.EngagementViewRecipeHandlerV2) router_api_v2.POST("/engagement/share/:id", JwtOptionalAuthMiddlewareV2([]byte(cfg.JwtSecret)), s.EngagementShareRecipeHandlerV2) router_api_v2.POST("/engagement/favorite/:id", JwtAuthMiddlewareV2([]byte(cfg.JwtSecret)), s.EngagementFavoriteRecipeHandlerV2) router_api_v2.POST("/engagement/make/:id", JwtAuthMiddlewareV2([]byte(cfg.JwtSecret)), s.EngagementMakeRecipeHandlerV2) + if cfg.Environment == "dev" { + s.debugDisplayRoutes() + } + return s } + +func (s *Server) debugDisplayRoutes() { + for _, route := range s.Router.Routes() { + format := "%-8s %s" + logging.LogAll(s.logs, logging.LogLevelDebug, format, route.Method, route.Path) + } +} diff --git a/internal/app/service/auth_service.go b/internal/app/service/auth_service.go index 41d77a8..8ef4455 100644 --- a/internal/app/service/auth_service.go +++ b/internal/app/service/auth_service.go @@ -10,6 +10,7 @@ import ( domainServer "github.com/haydenhargreaves/Potion/internal/domain/server" domain "github.com/haydenhargreaves/Potion/internal/domain/user" "github.com/haydenhargreaves/Potion/internal/infrastructure/auth" + "github.com/haydenhargreaves/Potion/internal/infrastructure/logging" "golang.org/x/oauth2" ) @@ -31,6 +32,7 @@ import ( type AuthService struct { userRepository domain.UserRepository jwtSecret []byte + logs []logging.Logger } // Compile-time check to ensure the AuthService implements domain.AuthService @@ -38,10 +40,11 @@ var _ domainAuth.AuthService = (*AuthService)(nil) // NewAuthService creates a user service object which can be passed into the context. The service // requires a user repository which it will use to hit the database when needed. -func NewAuthService(userRepository domain.UserRepository, jwtSecret []byte) domainAuth.AuthService { +func NewAuthService(userRepository domain.UserRepository, jwtSecret []byte, logs []logging.Logger) domainAuth.AuthService { return &AuthService{ userRepository: userRepository, jwtSecret: jwtSecret, + logs: logs, } } @@ -124,3 +127,53 @@ func generateJwt(userId int, email string, jwtSecret []byte) (string, error) { return tokenString, nil } + +// import ( +// "bytes" +// "time" +// +// "github.com/gin-gonic/gin" +// "github.com/google/uuid" +// "golang.org/x/exp/slog" // or your logging.Logger +// ) +// +// // LoggerMiddleware logs HTTP requests with structured fields +// func LoggerMiddleware(logger *slog.Logger) gin.HandlerFunc { +// return func(c *gin.Context) { +// // Generate request ID for tracing +// reqID := uuid.New().String() +// start := time.Now() +// +// // Capture request body (if enabled) +// var reqBody []byte +// if c.Request.ContentLength > 0 && c.Request.Body != nil { +// reqBody, _ = io.ReadAll(c.Request.Body) +// c.Request.Body.Close() +// c.Request.Body = io.NopCloser(bytes.NewBuffer(reqBody)) +// } +// +// // Log request start +// logger.Info("request started", +// slog.String("req_id", reqID), +// slog.String("method", c.Request.Method), +// slog.String("path", c.Request.URL.Path), +// slog.Int("content_length", int(c.Request.ContentLength)), +// slog.String("user_agent", c.Request.UserAgent()), +// ) +// +// // Process request +// c.Next() +// +// // Log request completion +// duration := time.Since(start) +// logger.Info("request completed", +// slog.String("req_id", reqID), +// slog.Int("status", c.Writer.Status()), +// slog.String("method", c.Request.Method), +// slog.String("path", c.Request.URL.Path), +// slog.Duration("duration", duration), +// slog.Int("size", c.Writer.Size()), +// slog.Any("req_body", string(reqBody)), // truncate if too big +// ) +// } +// } diff --git a/internal/domain/server/server.go b/internal/domain/server/server.go index 7afc797..1f7a308 100644 --- a/internal/domain/server/server.go +++ b/internal/domain/server/server.go @@ -10,6 +10,7 @@ import ( domainEngagement "github.com/haydenhargreaves/Potion/internal/domain/engagement" domainRecipe "github.com/haydenhargreaves/Potion/internal/domain/recipe" domainUser "github.com/haydenhargreaves/Potion/internal/domain/user" + "github.com/haydenhargreaves/Potion/internal/infrastructure/logging" "github.com/joho/godotenv" ) @@ -55,10 +56,10 @@ func IsLoggedIn(ctx *gin.Context) bool { // the event that required fields are not provided, an error will return and the caller should handle // the missing value or panic. Toggles between 'dev', 'prod', etc are also handled by this method, // the values can be access assuming they are the proper values based on the provided environment. -func LoadEnvironment() (*EnvironmentConfig, error) { +func LoadEnvironment(logs []logging.Logger) (*EnvironmentConfig, error) { err := godotenv.Load(".env") if err != nil { - fmt.Printf("No .env file found or error loading .env: %v. Relying on system environment variables.", err) + logging.LogAll(logs, logging.LogLevelWarning, "No .env file found or error loading .env: %v. Relying on system environment variables.", err) } env := os.Getenv("ENVIRONMENT") @@ -130,7 +131,7 @@ func LoadEnvironment() (*EnvironmentConfig, error) { FrontendDomain: frontendDomain, } - fmt.Printf("Environment Config: %+v\n", cfg) + logging.LogAll(logs, logging.LogLevelDebug, "Environment Config: %+v\n", cfg) return cfg, nil } diff --git a/internal/infrastructure/database/migrations/014_create_logs_table.sql b/internal/infrastructure/database/migrations/014_create_logs_table.sql new file mode 100644 index 0000000..ef775a3 --- /dev/null +++ b/internal/infrastructure/database/migrations/014_create_logs_table.sql @@ -0,0 +1,14 @@ +-- Author: Hayden Hargreaves (hhargreaves2006@gmail.com) +-- Desc: Create the logs table. +-- Date: 01/23/2026 + +BEGIN; + +CREATE TABLE IF NOT EXISTS Logs ( + Id SERIAL PRIMARY KEY NOT NULL, + Level TEXT NOT NULL CHECK (level IN ('TRACE', 'DEBUG', 'INFO', 'WARN', 'ERROR', 'FATAL')), + Message TEXT NOT NULL, + Created TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +COMMIT; diff --git a/internal/infrastructure/database/migrations/100_init_database.sh b/internal/infrastructure/database/migrations/100_init_database.sh index e4893c4..73dd550 100755 --- a/internal/infrastructure/database/migrations/100_init_database.sh +++ b/internal/infrastructure/database/migrations/100_init_database.sh @@ -13,4 +13,5 @@ psql -h "$PSQL_HOST" -U "$PSQL_USERNAME" -d "$PSQL_DATABASE" -f ./internal/infra psql -h "$PSQL_HOST" -U "$PSQL_USERNAME" -d "$PSQL_DATABASE" -f ./internal/infrastructure/database/migrations/011_update_engagement_enum.sql psql -h "$PSQL_HOST" -U "$PSQL_USERNAME" -d "$PSQL_DATABASE" -f ./internal/infrastructure/database/migrations/013_update_recipes_allow_large_servings.sql +psql -h "$PSQL_HOST" -U "$PSQL_USERNAME" -d "$PSQL_DATABASE" -f ./internal/infrastructure/database/migrations/014_create_logs_table.sql diff --git a/internal/infrastructure/logging/logger.go b/internal/infrastructure/logging/logger.go new file mode 100644 index 0000000..57a4490 --- /dev/null +++ b/internal/infrastructure/logging/logger.go @@ -0,0 +1,63 @@ +package logging + +type LogLevel string + +const ( + LogLevelTrace LogLevel = "TRACE" + LogLevelDebug LogLevel = "DEBUG" + LogLevelInfo LogLevel = "INFO" + LogLevelWarning LogLevel = "WARN" + LogLevelError LogLevel = "ERROR" + LogLevelFatal LogLevel = "FATAL" +) + +// MatchFilter is called on a filter (l) with a target level to match on the filter. Match +// means returning true of the target is greater than OR EQUAL TO the filter level. They order +// by scale of magnitude. +func (filter LogLevel) MatchFilter(target LogLevel) bool { + // Define severity levels (higher number = more severe) + severity := map[LogLevel]int{ + LogLevelTrace: 0, + LogLevelDebug: 1, + LogLevelInfo: 2, + LogLevelWarning: 3, + LogLevelError: 4, + LogLevelFatal: 5, + } + + filterSeverity, filterOk := severity[filter] + targetSeverity, targetOk := severity[target] + + if !filterOk || !targetOk { + return false + } + + return targetSeverity >= filterSeverity +} + +const ( + // Background colors + BgBlack = "\033[40m" + BgRed = "\033[41m" + BgGreen = "\033[42m" + BgYellow = "\033[43m" + BgBlue = "\033[44m" + BgMagenta = "\033[45m" + BgCyan = "\033[46m" + BgWhite = "\033[47m" + + // Reset + Reset = "\033[0m" +) + +type Logger interface { + Log(level LogLevel, format string, v ...any) +} + +// LogAll takes all of the inputs for a single logger and executes the logging operation +// on each of the loggers (logs) provided. This is just a convince function. +func LogAll(logs []Logger, level LogLevel, format string, v ...any) { + for _, log := range logs { + log.Log(level, format, v...) + } +} diff --git a/internal/infrastructure/logging/loggers/console_logger.go b/internal/infrastructure/logging/loggers/console_logger.go new file mode 100644 index 0000000..671b12a --- /dev/null +++ b/internal/infrastructure/logging/loggers/console_logger.go @@ -0,0 +1,59 @@ +package loggers + +import ( + "fmt" + "io" + "os" + "time" + + "github.com/haydenhargreaves/Potion/internal/infrastructure/logging" +) + +type ConsoleLogger struct { + writer io.Writer + filter logging.LogLevel +} + +var _ logging.Logger = (*ConsoleLogger)(nil) + +// NewConsoleLogger creates a new logger which writes directly to standard out (stdout). +func NewConsoleLogger(filter logging.LogLevel) logging.Logger { + return &ConsoleLogger{ + writer: os.Stdout, + filter: filter, + } +} + +// formatLevelString converts a log level string (level) into a new, formatted output string. +// This also includes color, if the shell supports it. Otherwise, the rendering may appear odd. +func formatLevelString(level logging.LogLevel) string { + switch level { + case logging.LogLevelTrace: + return fmt.Sprintf("%s[%s]%s", logging.BgMagenta, level, logging.Reset) + case logging.LogLevelDebug: + return fmt.Sprintf("%s[%s]%s", logging.BgBlue, level, logging.Reset) + case logging.LogLevelInfo: + return fmt.Sprintf("%s[%s]%s", logging.BgGreen, level, logging.Reset) + case logging.LogLevelWarning: + return fmt.Sprintf("%s[%s]%s", logging.BgYellow, level, logging.Reset) + case logging.LogLevelError: + return fmt.Sprintf("%s[%s]%s", logging.BgRed, level, logging.Reset) + case logging.LogLevelFatal: + return fmt.Sprintf("%s[%s]%s", logging.BgRed, level, logging.Reset) + } + return fmt.Sprintf("[%s]", level) +} + +// Log implements the interface. +func (l *ConsoleLogger) Log(level logging.LogLevel, format string, v ...any) { + // level is too low, do not log + if !l.filter.MatchFilter(level) { + return + } + + timestamp := time.Now().UTC().Format("01/02/2006 - 15:04:05") + levelStr := formatLevelString(level) + fullFormat := fmt.Sprintf("%-18s %s | %s\n", levelStr, timestamp, format) + bytes := fmt.Appendf(nil, fullFormat, v...) + l.writer.Write(bytes) +} diff --git a/internal/infrastructure/logging/loggers/database_logger.go b/internal/infrastructure/logging/loggers/database_logger.go new file mode 100644 index 0000000..df4160a --- /dev/null +++ b/internal/infrastructure/logging/loggers/database_logger.go @@ -0,0 +1,77 @@ +package loggers + +import ( + "database/sql" + "fmt" + + "github.com/haydenhargreaves/Potion/internal/infrastructure/logging" +) + +type DatabaseLogger struct { + db *sql.DB + table string + filter logging.LogLevel +} + +var _ logging.Logger = (*DatabaseLogger)(nil) + +func NewDatabaseLogger(conn *sql.DB, table string, filter logging.LogLevel) (logging.Logger, error) { + if conn == nil { + return &DatabaseLogger{}, fmt.Errorf("Connection is nil, something is very wrong.") + } + // Ensure the DB is open + if err := conn.Ping(); err != nil { + return &DatabaseLogger{}, err + } + + // Ensure the table exists + exists, err := tableExists(conn, table) + if err != nil { + return &DatabaseLogger{}, err + } + + if !exists { + return &DatabaseLogger{}, fmt.Errorf("Database table '%s' does not exist on provided connection.", table) + } + + logger := &DatabaseLogger{ + db: conn, + table: table, + filter: filter, + } + + return logger, nil +} + +// tableExists queries a database connection and returns whether the table name provided +// exists on the table. +func tableExists(conn *sql.DB, tableName string) (bool, error) { + var exists bool + err := conn.QueryRow(` + SELECT EXISTS ( + SELECT FROM information_schema.tables + WHERE table_schema = 'public' + AND table_name = $1 + )`, + tableName).Scan(&exists) + return exists, err +} + +// Log implements the interface. +func (l *DatabaseLogger) Log(level logging.LogLevel, format string, v ...any) { + // level is too low, do not log + if !l.filter.MatchFilter(level) { + return + } + + message := fmt.Sprintf(format, v...) + query := "INSERT INTO logs (level, message) VALUES ($1, $2);" + + // Ignoring result and error, cuz what the hell would we do with them lol + _, err := l.db.Exec(query, level, message) + + // TODO: Remove + if err != nil { + println(err) + } +} diff --git a/internal/infrastructure/logging/loggers/file_logger.go b/internal/infrastructure/logging/loggers/file_logger.go new file mode 100644 index 0000000..c125d96 --- /dev/null +++ b/internal/infrastructure/logging/loggers/file_logger.go @@ -0,0 +1,58 @@ +package loggers + +import ( + "fmt" + "io" + "os" + "time" + + "github.com/haydenhargreaves/Potion/internal/infrastructure/logging" +) + +type FileLogger struct { + writer io.Writer + filter logging.LogLevel +} + +var _ logging.Logger = (*FileLogger)(nil) + +// NewFileLogger creates a new file logger, opened on the filepath provided. If any errors +// occur, an error will be returned, along with an EMPTY logger. This is not a pointer return +// so it will never be nil, just empty. +// +// This function does not close the file, cleanup function that is returned should be called +// to close the file opened in this function. +func NewFileLogger(filepath string, filter logging.LogLevel) (logging.Logger, func() error, error) { + f, err := os.OpenFile(filepath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644) + if err != nil { + return &FileLogger{}, nil, err + } + + if f == nil { + return &FileLogger{}, nil, fmt.Errorf("File could not be opened. File is nil.") + } + + logger := &FileLogger{ + writer: f, + filter: filter, + } + + cleanup := func() error { + return f.Close() + } + + return logger, cleanup, nil +} + +// Log implements the interface. +func (l *FileLogger) Log(level logging.LogLevel, format string, v ...any) { + // level is too low, do not log + if !l.filter.MatchFilter(level) { + return + } + + timestamp := time.Now().UTC().Format("01/02/2006 - 15:04:05") + fullFormat := fmt.Sprintf("%-13s %s | %s\n", "["+level+"]", timestamp, format) + bytes := fmt.Appendf(nil, fullFormat, v...) + l.writer.Write(bytes) +}