diff options
| -rw-r--r-- | .gitignore | 14 | ||||
| -rw-r--r-- | Makefile | 56 | ||||
| -rw-r--r-- | config/config.go | 5 | ||||
| -rw-r--r-- | config/constants.go | 14 | ||||
| -rw-r--r-- | config/embed.go | 6 | ||||
| -rw-r--r-- | config/functions.go | 13 | ||||
| -rw-r--r-- | dove/main.go | 71 | ||||
| -rw-r--r-- | example.config.toml | 50 | ||||
| -rw-r--r-- | go.mod | 7 | ||||
| -rw-r--r-- | go.sum | 28 | ||||
| -rw-r--r-- | messages/config.go | 2 | ||||
| -rw-r--r-- | messages/logger.go | 2 | ||||
| -rw-r--r-- | messages/server.go | 9 | ||||
| -rw-r--r-- | middleware/constants.go | 5 | ||||
| -rw-r--r-- | middleware/globals.go | 12 | ||||
| -rw-r--r-- | middleware/logging.go | 71 | ||||
| -rw-r--r-- | middleware/middleware.go | 3 | ||||
| -rw-r--r-- | pages/dashboard.go | 2 | ||||
| -rw-r--r-- | pages/home.go | 4 | ||||
| -rw-r--r-- | scripts/htmx.setup.sh | 32 | ||||
| -rw-r--r-- | scripts/tailwind.setup.sh | 78 | ||||
| -rw-r--r-- | static/css/tailwind.css | 1 | ||||
| -rw-r--r-- | tailwind.config.js | 9 | ||||
| -rw-r--r-- | templates/auth/login.django | 45 | ||||
| -rw-r--r-- | templates/dashboard.django | 30 | ||||
| -rw-r--r-- | templates/error.django | 11 | ||||
| -rw-r--r-- | templates/layouts/base.django | 15 | ||||
| -rw-r--r-- | utils/auth/auth.go | 8 | ||||
| -rw-r--r-- | utils/logger/logger.go | 2 | ||||
| -rw-r--r-- | utils/meta/title.go | 7 |
30 files changed, 596 insertions, 16 deletions
@@ -32,5 +32,17 @@ go.work.sum .vscode/ .claude/ -# Binaries +# Binaries bin/ + +# Toolchain +toolchain/ + +# Built assets +static/css/style.css +static/js/htmx.min.js + +# Embedded copies +config/example.config.toml +config.toml + diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..1e64aa9 --- /dev/null +++ b/Makefile @@ -0,0 +1,56 @@ +BINARY_NAME = dove +BUILD_PATH = bin/$(BINARY_NAME) +MAIN_PATH = $(BINARY_NAME)/main.go +TAILWIND = toolchain/tailwind + +.PHONY: setup clean tidy embed build run dev css watch all + +setup: + @echo "Setting up environment..." + @go mod download + @go mod tidy + @./scripts/tailwind.setup.sh + @./scripts/htmx.setup.sh + @echo "Environment setup complete." + +clean: + @echo "Cleaning up..." + @rm -rf bin + @rm -rf tmp + @echo "Cleanup complete." + +tidy: + @echo "Tidying modules..." + @go mod tidy + @echo "Modules tidied." + +embed: + @cp example.config.toml config/example.config.toml + +build: css embed + @echo "Building..." + @go build -o $(BUILD_PATH) $(MAIN_PATH) + @echo "Build complete." + +run: + @if [ ! -f $(BUILD_PATH) ]; then echo "Binary not found. Building..."; $(MAKE) -s build; fi + @echo "Running..." + @$(BUILD_PATH) + +dev: css embed + @echo "Running in development mode..." + @go run $(MAIN_PATH) + +css: + @if [ ! -f $(TAILWIND) ]; then echo "Tailwind not found. Installing..."; ./scripts/tailwind.setup.sh; fi + @echo "Building CSS..." + @$(TAILWIND) -i static/css/tailwind.css -o static/css/style.css --minify + +watch: + @if [ ! -f $(TAILWIND) ]; then echo "Tailwind not found. Installing..."; ./scripts/tailwind.setup.sh; fi + @echo "Watching CSS..." + @$(TAILWIND) -i static/css/tailwind.css -o static/css/style.css --watch + +all: setup clean build run + +.SILENT: diff --git a/config/config.go b/config/config.go index ed9dab3..8a481c7 100644 --- a/config/config.go +++ b/config/config.go @@ -25,6 +25,11 @@ func init() { DataDir = resolveDataDirectory(DevMode, osConfigDirectory) configFilePath := resolveConfigFilePath(DevMode, osConfigDirectory) + + if createError := ensureConfigFileExists(configFilePath); createError != nil { + logger.Fatalf(LOG_PREFIX, messages.ConfigCreateFailed, createError) + } + if loadError := loadConfigFile(configFilePath); loadError != nil { logger.Fatalf(LOG_PREFIX, messages.ConfigFileLoadFailed, loadError) } diff --git a/config/constants.go b/config/constants.go index 14b6276..dae1811 100644 --- a/config/constants.go +++ b/config/constants.go @@ -1,10 +1,12 @@ package config const ( - APPLICATION_DIRECTORY = "dove" - CONFIG_FILE_NAME = "config.toml" - CURRENT_DIRECTORY = "." - GO_BUILD_ALT_INDICATOR = "go_build" - GO_BUILD_PATH_INDICATOR = "go-build" - LOG_PREFIX = "Config" + APPLICATION_DIRECTORY = "dove" + CONFIG_DIRECTORY_PERMISSIONS = 0755 + CONFIG_FILE_NAME = "config.toml" + CONFIG_FILE_PERMISSIONS = 0644 + CURRENT_DIRECTORY = "." + GO_BUILD_ALT_INDICATOR = "go_build" + GO_BUILD_PATH_INDICATOR = "go-build" + LOG_PREFIX = "Config" ) diff --git a/config/embed.go b/config/embed.go new file mode 100644 index 0000000..696ab8e --- /dev/null +++ b/config/embed.go @@ -0,0 +1,6 @@ +package config + +import _ "embed" + +//go:embed example.config.toml +var defaultConfigContent []byte diff --git a/config/functions.go b/config/functions.go index 1a49226..c6ce601 100644 --- a/config/functions.go +++ b/config/functions.go @@ -45,6 +45,19 @@ func resolveDataDirectory(developmentMode bool, osConfigDirectory string) string } } +func ensureConfigFileExists(configFilePath string) error { + if _, statError := os.Stat(configFilePath); statError == nil { + return nil + } + + configDirectory := filepath.Dir(configFilePath) + if mkdirError := os.MkdirAll(configDirectory, CONFIG_DIRECTORY_PERMISSIONS); mkdirError != nil { + return mkdirError + } + + return os.WriteFile(configFilePath, defaultConfigContent, CONFIG_FILE_PERMISSIONS) +} + func loadConfigFile(configFilePath string) error { if _, statError := os.Stat(configFilePath); statError != nil { return nil diff --git a/dove/main.go b/dove/main.go new file mode 100644 index 0000000..207565e --- /dev/null +++ b/dove/main.go @@ -0,0 +1,71 @@ +package main + +import ( + "fmt" + "os" + "os/signal" + "syscall" + + "dove/config" + "dove/messages" + "dove/middleware" + "dove/router" + "dove/utils/logger" + + "github.com/gofiber/fiber/v2" + "github.com/gofiber/fiber/v2/middleware/recover" + "github.com/gofiber/template/django/v3" + "github.com/spf13/cobra" +) + +const LOG_PREFIX = "Server" + +var rootCommand = &cobra.Command{ + Use: "dove", + Short: "Local SMTP email testing tool.", + RunE: serve, +} + +func main() { + if executeError := rootCommand.Execute(); executeError != nil { + logger.Fatalf(LOG_PREFIX, "%v", executeError) + } +} + +func serve(command *cobra.Command, arguments []string) error { + engine := django.New("./templates", ".django") + engine.Reload(config.DevMode) + + application := fiber.New(fiber.Config{ + DisableStartupMessage: true, + Views: engine, + ErrorHandler: router.ErrorHandler, + }) + + application.Use(recover.New()) + + middleware.Initialize(application) + router.Initialize(application) + + shutdownSignal := make(chan os.Signal, 1) + signal.Notify(shutdownSignal, syscall.SIGINT, syscall.SIGTERM) + + go func() { + address := fmt.Sprintf("%s:%d", config.Server.Host, config.Server.Port) + logger.Successf(LOG_PREFIX, messages.ServerStarting, address) + + if listenError := application.Listen(address); listenError != nil { + logger.Fatalf(LOG_PREFIX, messages.ServerListenFailed, listenError) + } + }() + + <-shutdownSignal + logger.Infof(LOG_PREFIX, messages.ServerShuttingDown) + + if shutdownError := application.Shutdown(); shutdownError != nil { + logger.Errorf(LOG_PREFIX, messages.ServerShutdownFailed, shutdownError) + } + + logger.Successf(LOG_PREFIX, messages.ServerShutdownComplete) + return nil +} diff --git a/example.config.toml b/example.config.toml new file mode 100644 index 0000000..8748a3b --- /dev/null +++ b/example.config.toml @@ -0,0 +1,50 @@ +[server] +host = "0.0.0.0" +port = 8080 +debug = false +# username = "" +# password = "" + +[smtp] +host = "0.0.0.0" +port = 5025 +smtps_port = 5465 +starttls_port = 5587 +max_message_size = 26214400 +auth_required = false +# username = "" +# password = "" +tls_enabled = false +# tls_cert = "" +# tls_key = "" +relay_enabled = false +# relay_host = "" +relay_port = 587 +# relay_username = "" +# relay_password = "" +relay_starttls = true + +[imap] +host = "0.0.0.0" +port = 5143 +imaps_port = 5993 +auth_required = false +# username = "" +# password = "" +tls_enabled = false +# tls_cert = "" +# tls_key = "" + +[pop3] +host = "0.0.0.0" +port = 5110 +pop3s_port = 5995 +auth_required = false +# username = "" +# password = "" +tls_enabled = false +# tls_cert = "" +# tls_key = "" + +[mailbox] +mode = "registered"
\ No newline at end of file @@ -4,7 +4,9 @@ go 1.25.0 require ( github.com/gofiber/fiber/v2 v2.52.12 + github.com/gofiber/template/django/v3 v3.1.14 github.com/pelletier/go-toml/v2 v2.2.4 + github.com/spf13/cobra v1.10.2 go.uber.org/zap v1.27.1 gorm.io/driver/sqlite v1.6.0 gorm.io/gorm v1.31.1 @@ -12,7 +14,11 @@ require ( require ( github.com/andybalholm/brotli v1.1.0 // indirect + github.com/flosch/pongo2/v6 v6.0.0 // indirect + github.com/gofiber/template v1.8.3 // indirect + github.com/gofiber/utils v1.1.0 // indirect github.com/google/uuid v1.6.0 // indirect + github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/jinzhu/inflection v1.0.0 // indirect github.com/jinzhu/now v1.1.5 // indirect github.com/klauspost/compress v1.17.9 // indirect @@ -21,6 +27,7 @@ require ( github.com/mattn/go-runewidth v0.0.16 // indirect github.com/mattn/go-sqlite3 v1.14.22 // indirect github.com/rivo/uniseg v0.2.0 // indirect + github.com/spf13/pflag v1.0.9 // indirect github.com/valyala/bytebufferpool v1.0.0 // indirect github.com/valyala/fasthttp v1.51.0 // indirect github.com/valyala/tcplisten v1.0.0 // indirect @@ -1,17 +1,32 @@ github.com/andybalholm/brotli v1.1.0 h1:eLKJA0d02Lf0mVpIDgYnqXcUn0GqVmEFny3VuID1U3M= github.com/andybalholm/brotli v1.1.0/go.mod h1:sms7XGricyQI9K10gOSf56VKKWS4oLer58Q+mhRPtnY= +github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= 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/flosch/pongo2/v6 v6.0.0 h1:lsGru8IAzHgIAw6H2m4PCyleO58I40ow6apih0WprMU= +github.com/flosch/pongo2/v6 v6.0.0/go.mod h1:CuDpFm47R0uGGE7z13/tTlt1Y6zdxvr2RLT5LJhsHEU= github.com/gofiber/fiber/v2 v2.52.12 h1:0LdToKclcPOj8PktUdIKo9BUohjjwfnQl42Dhw8/WUw= github.com/gofiber/fiber/v2 v2.52.12/go.mod h1:YEcBbO/FB+5M1IZNBP9FO3J9281zgPAreiI1oqg8nDw= +github.com/gofiber/template v1.8.3 h1:hzHdvMwMo/T2kouz2pPCA0zGiLCeMnoGsQZBTSYgZxc= +github.com/gofiber/template v1.8.3/go.mod h1:bs/2n0pSNPOkRa5VJ8zTIvedcI/lEYxzV3+YPXdBvq8= +github.com/gofiber/template/django/v3 v3.1.14 h1:SvTvs+u5vTZuu1Y2pMUD2NhaGIjBj9FmDA3XD50QBvw= +github.com/gofiber/template/django/v3 v3.1.14/go.mod h1:gP4vH+T1ajZw7yaejqG1dZVdHQkMC/jPoQbmlG812I0= +github.com/gofiber/utils v1.1.0 h1:vdEBpn7AzIUJRhe+CiTOJdUcTg4Q9RK+pEa0KPbLdrM= +github.com/gofiber/utils v1.1.0/go.mod h1:poZpsnhBykfnY1Mc0KeEa6mSHrS3dV0+oBWyeQmb2e0= 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/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= 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/klauspost/compress v1.17.9 h1:6KIumPrER1LHsvBVuDa0r5xaG0Es51mhhB9BQB2qeMA= github.com/klauspost/compress v1.17.9/go.mod h1:Di0epgTjJY877eYKx5yC51cX2A2Vl2ibi7bDH9ttBbw= +github.com/kr/pretty v0.2.1 h1:Fmg33tUaq4/8ym9TJN1x7sLJnHVwhP33CNkpYV/7rwI= +github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= 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= @@ -27,8 +42,13 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY= github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= -github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk= -github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= +github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= +github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= +github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= github.com/valyala/fasthttp v1.51.0 h1:8b30A5JlZ6C7AS81RsWjYMQmrZG6feChmgAolCl1SqA= @@ -41,12 +61,16 @@ go.uber.org/multierr v1.10.0 h1:S0h4aNzvfcFsC3dRF1jLoaov7oRaKqRGC/pUEJ2yvPQ= go.uber.org/multierr v1.10.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= go.uber.org/zap v1.27.1 h1:08RqriUEv8+ArZRYSTXy1LeBScaMpVSTBhCeaZYfMYc= go.uber.org/zap v1.27.1/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= 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.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA= golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/text v0.20.0 h1:gK/Kv2otX8gz+wn7Rmb3vT96ZwuoxnQlY+HlJVj7Qug= golang.org/x/text v0.20.0/go.mod h1:D4IsuqiFMhST5bX19pQ9ikHC2GsaKyk/oF+pn3ducp4= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= 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/sqlite v1.6.0 h1:WHRRrIiulaPiPFmDcod6prc4l2VGVWHz80KspNsxSfQ= diff --git a/messages/config.go b/messages/config.go index 43c9d7f..10c85db 100644 --- a/messages/config.go +++ b/messages/config.go @@ -1,6 +1,8 @@ package messages const ( + ConfigCreated = "Created default config at %s." + ConfigCreateFailed = "Failed to create config file: %v" ConfigFileLoadFailed = "Failed to load config: %v" ConfigFileReadFailed = "Failed to read config file %s: %s." ConfigLoaded = "Configuration loaded successfully." diff --git a/messages/logger.go b/messages/logger.go index 755e259..7f6e99b 100644 --- a/messages/logger.go +++ b/messages/logger.go @@ -1,5 +1,5 @@ package messages const ( - LoggerNotInitialized = "logger.Init() was not called." + LoggerNotInitialized = "Logger was not initialized." ) diff --git a/messages/server.go b/messages/server.go new file mode 100644 index 0000000..9f2b086 --- /dev/null +++ b/messages/server.go @@ -0,0 +1,9 @@ +package messages + +const ( + ServerListenFailed = "Failed to start server: %v" + ServerShutdownComplete = "Shutdown complete." + ServerShutdownFailed = "Error during server shutdown: %v" + ServerShuttingDown = "Shutting down gracefully..." + ServerStarting = "Server started on %s." +) diff --git a/middleware/constants.go b/middleware/constants.go new file mode 100644 index 0000000..dc6505b --- /dev/null +++ b/middleware/constants.go @@ -0,0 +1,5 @@ +package middleware + +const ( + LOG_PREFIX = "HTTP" +) diff --git a/middleware/globals.go b/middleware/globals.go new file mode 100644 index 0000000..8a186bb --- /dev/null +++ b/middleware/globals.go @@ -0,0 +1,12 @@ +package middleware + +import ( + "dove/config" + + "github.com/gofiber/fiber/v2" +) + +func globals(context *fiber.Ctx) error { + context.Locals("AuthEnabled", config.AuthEnabled) + return context.Next() +} diff --git a/middleware/logging.go b/middleware/logging.go new file mode 100644 index 0000000..15c82b6 --- /dev/null +++ b/middleware/logging.go @@ -0,0 +1,71 @@ +package middleware + +import ( + "fmt" + "strconv" + "strings" + "time" + + "dove/utils/logger" + + "github.com/gofiber/fiber/v2" +) + +func httpLogger() fiber.Handler { + return func(context *fiber.Ctx) error { + startTime := time.Now() + + responseError := context.Next() + + duration := time.Since(startTime) + statusCode := context.Response().StatusCode() + method := context.Method() + path := context.Path() + ipAddress := context.IP() + + paddedMethod := method + if len(method) < 7 { + paddedMethod = method + strings.Repeat(" ", 7-len(method)) + } + + message := fmt.Sprintf( + "%s %-3d %-15s %-10s %s", + paddedMethod, statusCode, "IP: "+ipAddress, "TTR: "+formatDuration(duration), "Path: "+path, + ) + + logByStatus(statusCode, LOG_PREFIX, message) + + return responseError + } +} + +func logByStatus(statusCode int, prefix string, message string) { + switch { + case statusCode >= fiber.StatusInternalServerError: + logger.Errorf(prefix, "%s", message) + case statusCode >= fiber.StatusBadRequest: + logger.Warnf(prefix, "%s", message) + case statusCode >= fiber.StatusMultipleChoices: + logger.Infof(prefix, "%s", message) + case statusCode >= fiber.StatusOK: + logger.Successf(prefix, "%s", message) + default: + logger.Infof(prefix, "%s", message) + } +} + +func formatDuration(duration time.Duration) string { + if duration < time.Microsecond { + return strconv.FormatInt(duration.Nanoseconds(), 10) + "ns" + } + + if duration < time.Millisecond { + return strconv.FormatInt(duration.Nanoseconds()/1_000, 10) + "µs" + } + + if duration < time.Second { + return strconv.FormatFloat(float64(duration.Nanoseconds())/float64(time.Millisecond), 'f', 3, 64) + "ms" + } + + return strconv.FormatFloat(float64(duration.Nanoseconds())/float64(time.Second), 'f', 3, 64) + "s" +} diff --git a/middleware/middleware.go b/middleware/middleware.go index dffc109..88e0820 100644 --- a/middleware/middleware.go +++ b/middleware/middleware.go @@ -7,6 +7,9 @@ import ( ) func Initialize(application *fiber.App) { + application.Use(httpLogger) + application.Use(globals) + switch config.AuthEnabled { case true: application.Use(authentication) diff --git a/pages/dashboard.go b/pages/dashboard.go index 323cd1f..2fd1f1d 100644 --- a/pages/dashboard.go +++ b/pages/dashboard.go @@ -1,11 +1,13 @@ package pages import ( + "dove/utils/meta" "dove/utils/shortcuts" "github.com/gofiber/fiber/v2" ) func Dashboard(context *fiber.Ctx) error { + meta.SetPageTitle(context, "Dashboard") return shortcuts.Render(context, "dashboard", nil) } diff --git a/pages/home.go b/pages/home.go index cbf5a5d..06f836e 100644 --- a/pages/home.go +++ b/pages/home.go @@ -2,6 +2,7 @@ package pages import ( "dove/config" + "dove/utils/meta" "dove/utils/shortcuts" "github.com/gofiber/fiber/v2" @@ -10,8 +11,9 @@ import ( func Home(context *fiber.Ctx) error { switch config.AuthEnabled { case true: + meta.SetPageTitle(context, "Login") return shortcuts.Render(context, "auth/login", nil) default: - return shortcuts.Render(context, "dashboard", nil) + return shortcuts.Redirect(context, "dashboard.index") } } diff --git a/scripts/htmx.setup.sh b/scripts/htmx.setup.sh new file mode 100644 index 0000000..b8d6fe6 --- /dev/null +++ b/scripts/htmx.setup.sh @@ -0,0 +1,32 @@ +#!/usr/bin/env bash +set -e + +STATIC_JS_DIR="static/js" +HTMX_FILE="$STATIC_JS_DIR/htmx.min.js" + +echo "Fetching latest HTMX release..." +LATEST_RELEASE=$(curl -s https://api.github.com/repos/bigskysoftware/htmx/releases/latest | grep '"tag_name":' | sed -E 's/.*"v([^"]+)".*/\1/') + +if [ -z "$LATEST_RELEASE" ]; then + echo "Failed to fetch latest release, using default version 2.0.8." + LATEST_RELEASE="2.0.8" +fi + +echo "Latest version: $LATEST_RELEASE" +DOWNLOAD_URL="https://unpkg.com/htmx.org@${LATEST_RELEASE}/dist/htmx.min.js" + +echo "Downloading from: $DOWNLOAD_URL" +echo "" + +mkdir -p "$STATIC_JS_DIR" + +if curl -fSL "$DOWNLOAD_URL" -o "$HTMX_FILE"; then + echo "" + echo "HTMX installed successfully!" + echo "Location: $HTMX_FILE" + echo "Version: $LATEST_RELEASE" +else + echo "" + echo "Failed to download HTMX." + exit 1 +fi diff --git a/scripts/tailwind.setup.sh b/scripts/tailwind.setup.sh new file mode 100644 index 0000000..c77f615 --- /dev/null +++ b/scripts/tailwind.setup.sh @@ -0,0 +1,78 @@ +#!/usr/bin/env bash +set -e + +TOOLCHAIN_DIR="toolchain" +TAILWIND_BIN="$TOOLCHAIN_DIR/tailwind" + +echo "Detecting platform..." + +OS=$(uname -s | tr '[:upper:]' '[:lower:]') +ARCH=$(uname -m) + +case "$OS" in + linux*) + PLATFORM="linux" + ;; + darwin*) + PLATFORM="macos" + ;; + msys*|mingw*|cygwin*) + PLATFORM="windows" + ;; + *) + echo "Unsupported OS: $OS" + exit 1 + ;; +esac + +case "$ARCH" in + x86_64|amd64) + ARCHITECTURE="x64" + ;; + arm64|aarch64) + ARCHITECTURE="arm64" + ;; + armv7l) + ARCHITECTURE="armv7" + ;; + *) + echo "Unsupported architecture: $ARCH" + exit 1 + ;; +esac + +BINARY_NAME="tailwindcss-${PLATFORM}-${ARCHITECTURE}" + +echo "Platform: $PLATFORM" +echo "Architecture: $ARCHITECTURE" +echo "Binary: $BINARY_NAME" +echo "" + +echo "Fetching latest Tailwind CSS release..." +LATEST_RELEASE=$(curl -s https://api.github.com/repos/tailwindlabs/tailwindcss/releases/latest | grep '"tag_name":' | sed -E 's/.*"([^"]+)".*/\1/') + +if [ -z "$LATEST_RELEASE" ]; then + echo "Failed to fetch latest release." + exit 1 +fi + +echo "Latest version: $LATEST_RELEASE" +DOWNLOAD_URL="https://github.com/tailwindlabs/tailwindcss/releases/download/${LATEST_RELEASE}/${BINARY_NAME}" + +echo "Downloading from: $DOWNLOAD_URL" +echo "" + +mkdir -p "$TOOLCHAIN_DIR" + +if curl -fSL "$DOWNLOAD_URL" -o "$TAILWIND_BIN"; then + chmod +x "$TAILWIND_BIN" + echo "" + echo "Tailwind CSS installed successfully!" + echo "Location: $TAILWIND_BIN" + echo "Version: $LATEST_RELEASE" +else + echo "" + echo "Failed to download Tailwind CSS binary." + echo "URL: $DOWNLOAD_URL" + exit 1 +fi diff --git a/static/css/tailwind.css b/static/css/tailwind.css new file mode 100644 index 0000000..f1d8c73 --- /dev/null +++ b/static/css/tailwind.css @@ -0,0 +1 @@ +@import "tailwindcss"; diff --git a/tailwind.config.js b/tailwind.config.js new file mode 100644 index 0000000..967b8e1 --- /dev/null +++ b/tailwind.config.js @@ -0,0 +1,9 @@ +export default { + content: [ + "./templates/**/*.django", + "./static/js/**/*.js", + ], + theme: { + extend: {}, + }, +}; diff --git a/templates/auth/login.django b/templates/auth/login.django new file mode 100644 index 0000000..3981abd --- /dev/null +++ b/templates/auth/login.django @@ -0,0 +1,45 @@ +{% extends "layouts/base.django" %} + +{% block content %} +<div class="min-h-screen flex items-center justify-center bg-gray-950"> + <div class="w-full max-w-sm space-y-8"> + <div class="text-center"> + <h1 class="text-4xl font-bold text-white tracking-tight">Dove</h1> + <p class="mt-2 text-sm text-gray-400">Local SMTP server for peaceful email testing</p> + </div> + + {% if ErrorMessage %} + <div class="rounded-lg bg-red-500/10 border border-red-500/20 px-4 py-3 text-sm text-red-400"> + {{ ErrorMessage }} + </div> + {% endif %} + + <form method="POST" action="/auth/login" class="space-y-5"> + <div> + <input + type="text" + name="username" + placeholder="Username" + required + class="w-full rounded-lg border border-gray-700 bg-gray-900 px-4 py-3 text-sm text-white placeholder-gray-500 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 transition" + > + </div> + <div> + <input + type="password" + name="password" + placeholder="Password" + required + class="w-full rounded-lg border border-gray-700 bg-gray-900 px-4 py-3 text-sm text-white placeholder-gray-500 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 transition" + > + </div> + <button + type="submit" + class="w-full rounded-lg bg-blue-600 px-4 py-3 text-sm font-medium text-white hover:bg-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 focus:ring-offset-gray-950 transition" + > + Sign in + </button> + </form> + </div> +</div> +{% endblock %} diff --git a/templates/dashboard.django b/templates/dashboard.django new file mode 100644 index 0000000..35c1246 --- /dev/null +++ b/templates/dashboard.django @@ -0,0 +1,30 @@ +{% extends "layouts/base.django" %} + +{% block content %} +<div class="min-h-screen bg-gray-950"> + <nav class="border-b border-gray-800 bg-gray-900/50 backdrop-blur-sm"> + <div class="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8"> + <div class="flex h-14 items-center justify-between"> + <div class="flex items-center space-x-3"> + <span class="text-lg font-semibold text-white">Dove</span> + </div> + {% if AuthEnabled %} + <div class="flex items-center space-x-4"> + <a href="/auth/logout" class="text-sm text-gray-400 hover:text-white transition">Logout</a> + </div> + {% endif %} + </div> + </div> + </nav> + + <main class="mx-auto max-w-7xl px-4 py-8 sm:px-6 lg:px-8"> + <div class="flex items-center justify-between"> + <h1 class="text-2xl font-bold text-white">Mailboxes</h1> + </div> + + <div class="mt-6 rounded-lg border border-gray-800 bg-gray-900/50 p-12 text-center"> + <p class="text-gray-400">No mailboxes yet. Emails will appear here when received.</p> + </div> + </main> +</div> +{% endblock %} diff --git a/templates/error.django b/templates/error.django new file mode 100644 index 0000000..8c1521d --- /dev/null +++ b/templates/error.django @@ -0,0 +1,11 @@ +{% extends "layouts/base.django" %} + +{% block content %} +<div class="min-h-screen flex items-center justify-center bg-gray-950"> + <div class="text-center space-y-4"> + <h1 class="text-6xl font-bold text-gray-600">Error</h1> + <p class="text-gray-400">{{ ErrorMessage }}</p> + <a href="/" class="inline-block mt-4 text-sm text-blue-400 hover:text-blue-300 transition">Go back home</a> + </div> +</div> +{% endblock %} diff --git a/templates/layouts/base.django b/templates/layouts/base.django new file mode 100644 index 0000000..567b81d --- /dev/null +++ b/templates/layouts/base.django @@ -0,0 +1,15 @@ +<!DOCTYPE html> +<html lang="en"> +<head> + <meta charset="UTF-8"> + <meta name="viewport" content="width=device-width, initial-scale=1.0"> + <title>{% if PageTitle %}{{ PageTitle }} - {% endif %}Dove</title> + <link rel="stylesheet" href="/static/css/style.css"> + {% block head %}{% endblock %} +</head> +<body class="antialiased"> + {% block content %}{% endblock %} + <script src="/static/js/htmx.min.js"></script> + {% block scripts %}{% endblock %} +</body> +</html> diff --git a/utils/auth/auth.go b/utils/auth/auth.go index 5abb496..b3d322c 100644 --- a/utils/auth/auth.go +++ b/utils/auth/auth.go @@ -1,6 +1,7 @@ package auth import ( + "dove/config" "dove/session" "github.com/gofiber/fiber/v2" @@ -17,12 +18,11 @@ func IsAuthenticated(context *fiber.Ctx) bool { func RequireAuthentication(handler fiber.Handler) fiber.Handler { return func(context *fiber.Ctx) error { - switch IsAuthenticated(context) { - case true: + if !config.AuthEnabled || IsAuthenticated(context) { return handler(context) - default: - return fiber.ErrUnauthorized } + + return fiber.ErrUnauthorized } } diff --git a/utils/logger/logger.go b/utils/logger/logger.go index b1d1809..a08b68b 100644 --- a/utils/logger/logger.go +++ b/utils/logger/logger.go @@ -15,7 +15,7 @@ var ( atomicLevel zap.AtomicLevel ) -func Init() { +func init() { atomicLevel = zap.NewAtomicLevelAt(zapcore.InfoLevel) encoderConfig := zapcore.EncoderConfig{ diff --git a/utils/meta/title.go b/utils/meta/title.go new file mode 100644 index 0000000..f6514e7 --- /dev/null +++ b/utils/meta/title.go @@ -0,0 +1,7 @@ +package meta + +import "github.com/gofiber/fiber/v2" + +func SetPageTitle(context *fiber.Ctx, title string) { + context.Locals("PageTitle", title) +} |
