Merging dev into master #80

Merged
azpect merged 7 commits from dev into master 2026-01-26 23:34:00 -07:00
13 changed files with 437 additions and 110 deletions

2
go.mod
View File

@ -24,7 +24,9 @@ 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/kelseyhightower/envconfig v1.4.0 // indirect
github.com/klauspost/cpuid/v2 v2.2.10 // indirect
github.com/kr/text v0.2.0 // indirect
github.com/leodido/go-urn v1.4.0 // indirect

4
go.sum
View File

@ -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=
@ -67,6 +69,8 @@ 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/kelseyhightower/envconfig v1.4.0 h1:Im6hONhd3pLkfDFsbRgu68RDNkGF1r3dvMUtDTo2cv8=
github.com/kelseyhightower/envconfig v1.4.0/go.mod h1:cccZRl6mQpaq41TPp5QxidR+Sa3axMbJDNb//FQX6Gg=
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=

View File

@ -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,

View File

@ -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
@ -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,
)
}
}

View File

@ -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}
@ -49,32 +57,33 @@ func Init(port int) *Server {
server.config.AllowCredentials = true
server.Router.Use(cors.New(server.config))
// We can use release mode since we don't need Gin's logging
gin.SetMode(gin.ReleaseMode)
return 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: (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()
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.")
}
if cfg.Environment == "dev" {
gin.SetMode(gin.DebugMode)
} else if cfg.Environment == "prod" {
gin.SetMode(gin.ReleaseMode)
} else {
gin.SetMode(gin.TestMode)
}
logging.LogAll(s.logs, logging.LogLevelDebug, "env: %+v\n", cfg)
// SETUP GOOGLE AUTH
var (
@ -103,6 +112,26 @@ func (s *Server) Setup() *Server {
s.DB = db
// TODO: Implement environment here for logging file
if cfg.LogFilePath != "" {
fileLogger, cleanup, err := loggers.NewFileLogger(cfg.LogFilePath, logging.LogLevelDebug)
if err != nil {
logging.LogAll(s.logs, logging.LogLevelWarning, "Failed to create file logger. %s\n", err.Error())
} else {
logging.LogAll(s.logs, logging.LogLevelDebug, "Initialized file logger on file '%s'\n", cfg.LogFilePath)
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 +140,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 +153,7 @@ func (s *Server) Setup() *Server {
}
// Apply middleware
s.Router.Use(RecoveryMiddleware())
// NOTE: No longer running on every connection!
// s.Router.Use(JwtAuthMiddleWare(jwtSecret))
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 +246,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)
}
}

View File

@ -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
// )
// }
// }

View File

@ -2,7 +2,6 @@ package domain
import (
"fmt"
"os"
"github.com/gin-gonic/gin"
"github.com/golang-jwt/jwt/v5"
@ -11,19 +10,21 @@ import (
domainRecipe "github.com/haydenhargreaves/Potion/internal/domain/recipe"
domainUser "github.com/haydenhargreaves/Potion/internal/domain/user"
"github.com/joho/godotenv"
"github.com/kelseyhightower/envconfig"
)
// EnvironmentConfig stores the configuration of the environment. Anything loaded from the .env
// or docker environment will be stored here and can be accessed from the InjectedDependencies
// struct, which this is attached to.
type EnvironmentConfig struct {
GoogleClientId string
GoogleClientSecret string
JwtSecret string
DatabaseUrl string
Environment string
Domain string
FrontendDomain string
GoogleClientId string `envconfig:"GOOGLE_CLIENT_ID" required:"true"`
GoogleClientSecret string `envconfig:"GOOGLE_CLIENT_SECRET" required:"true"`
JwtSecret string `envconfig:"JWT_SECRET" required:"true"`
DatabaseUrl string `envconfig:"DATABASE_URL" required:"true"`
Domain string `envconfig:"DOMAIN" required:"true"`
FrontendDomain string `envconfig:"FRONTEND_DOMAIN" required:"true"`
Environment string `envconfig:"ENVIRONMENT" required:"true"`
LogFilePath string `envconfig:"LOG_FILE_PATH" required:"false"`
}
// InjectedDependencies is a collection of dependencies that are injected into the application. They
@ -51,87 +52,16 @@ func IsLoggedIn(ctx *gin.Context) bool {
return id && email
}
// LoadEnvironment loads the environment values from either an .env file or docker environment. In
// 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.
// LoadEnvironment loads the environment values from either an .env file or docker environment.
func LoadEnvironment() (*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)
}
// NOTE: Does the error return matter?
godotenv.Load(".env")
env := os.Getenv("ENVIRONMENT")
if env == "" {
return nil, fmt.Errorf("ENVIRONMENT environment variable is required.")
cfg := &EnvironmentConfig{}
if err := envconfig.Process("", cfg); err != nil {
return nil, fmt.Errorf("Failed to load environment: %w", err)
}
googleClientId := os.Getenv("GOOGLE_CLIENT_ID")
if googleClientId == "" {
return nil, fmt.Errorf("GOOGLE_CLIENT_ID environment variable is required.")
}
googleClientSecret := os.Getenv("GOOGLE_CLIENT_SECRET")
if googleClientSecret == "" {
return nil, fmt.Errorf("GOOGLE_CLIENT_SECRET environment variable is required.")
}
jwtSecret := os.Getenv("JWT_SECRET")
if jwtSecret == "" {
return nil, fmt.Errorf("JWT_SECRET environment variable is required.")
}
var domain string
var frontendDomain string
if env == "dev" {
domain = os.Getenv("DOMAIN_DEV")
if domain == "" {
return nil, fmt.Errorf("DOMAIN_DEV environment variable is required when ENVIRONMENT is 'dev'.")
}
frontendDomain = os.Getenv("FRONTEND_DOMAIN_DEV")
if frontendDomain == "" {
return nil, fmt.Errorf("FRONTEND_DOMAIN_DEV environment variable is required when ENVIRONMENT is 'dev'.")
}
} else if env == "prod" {
domain = os.Getenv("DOMAIN_PROD")
if domain == "" {
return nil, fmt.Errorf("DOMAIN_PROD environment variable is required when ENVIRONMENT is 'prod'.")
}
frontendDomain = os.Getenv("FRONTEND_DOMAIN_PROD")
if frontendDomain == "" {
return nil, fmt.Errorf("FRONTEND_DOMAIN_PROD environment variable is required when ENVIRONMENT is 'dev'.")
}
} else {
return nil, fmt.Errorf("ENVIRONMENT environment variable is required and must be 'dev' or 'prod'.")
}
var dbUrl string
if env == "dev" {
dbUrl = os.Getenv("DATABASE_URL_DEV")
if dbUrl == "" {
return nil, fmt.Errorf("DATABASE_URL_DEV environment variable is required when ENVIRONMENT is 'dev'.")
}
} else if env == "prod" {
dbUrl = os.Getenv("DATABASE_URL_PROD")
if dbUrl == "" {
return nil, fmt.Errorf("DATABASE_URL_PROD environment variable is required when ENVIRONMENT is 'prod'.")
}
} else {
return nil, fmt.Errorf("ENVIRONMENT environment variable is required and must be 'dev' or 'prod'.")
}
cfg := &EnvironmentConfig{
GoogleClientId: googleClientId,
GoogleClientSecret: googleClientSecret,
JwtSecret: jwtSecret,
DatabaseUrl: dbUrl,
Environment: env,
Domain: domain,
FrontendDomain: frontendDomain,
}
fmt.Printf("Environment Config: %+v\n", cfg)
return cfg, nil
}

View File

@ -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;

View File

@ -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

View File

@ -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...)
}
}

View File

@ -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)
}

View File

@ -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)
}
}

View File

@ -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)
}