diff --git a/.air.toml b/.air.toml index ad5aa4c..8d71cf6 100644 --- a/.air.toml +++ b/.air.toml @@ -3,7 +3,7 @@ testdata_dir = "testdata" tmp_dir = "tmp" [build] - args_bin = ["--htmxlog"] + args_bin = ["--dev"] bin = "./tmp/main" cmd = "go build -o ./tmp/main ./cmd/oslstats" delay = 1000 @@ -14,7 +14,7 @@ tmp_dir = "tmp" follow_symlink = false full_bin = "" include_dir = [] - include_ext = ["go", "templ"] + include_ext = ["go", "templ", "js"] include_file = [] kill_delay = "0s" log = "build-errors.log" diff --git a/cmd/oslstats/httpserver.go b/cmd/oslstats/httpserver.go index f13927b..b01c16e 100644 --- a/cmd/oslstats/httpserver.go +++ b/cmd/oslstats/httpserver.go @@ -35,6 +35,12 @@ func setupHttpServer( ignoredPaths := []string{ "/static/css/output.css", "/static/favicon.ico", + "/static/js/popups.js", + "/static/js/theme.js", + "/static/vendored/htmx@2.0.8.min.js", + "/static/vendored/htmx-ext-ws.min.js", + "/static/vendored/alpinejs@3.15.4.min.js", + "/ws/notifications", } auth, err := setupAuth(cfg.HWSAuth, logger, bun, httpServer, ignoredPaths) @@ -62,7 +68,7 @@ func setupHttpServer( return nil, errors.Wrap(err, "addRoutes") } - err = addMiddleware(httpServer, auth, cfg.Flags) + err = addMiddleware(httpServer, auth, cfg) if err != nil { return nil, errors.Wrap(err, "addMiddleware") } diff --git a/cmd/oslstats/middleware.go b/cmd/oslstats/middleware.go index ff2c95c..8dd5cd8 100644 --- a/cmd/oslstats/middleware.go +++ b/cmd/oslstats/middleware.go @@ -3,6 +3,7 @@ package main import ( "context" "net/http" + "strconv" "git.haelnorr.com/h/golib/hws" "git.haelnorr.com/h/golib/hwsauth" @@ -17,12 +18,12 @@ import ( func addMiddleware( server *hws.Server, auth *hwsauth.Authenticator[*db.User, bun.Tx], - flags *config.Flags, + cfg *config.Config, ) error { err := server.AddMiddleware( auth.Authenticate(), - htmxLog(flags.HTMXLog), + devMode(cfg), ) if err != nil { return errors.Wrap(err, "server.AddMiddleware") @@ -30,12 +31,20 @@ func addMiddleware( return nil } -func htmxLog(htmxlog bool) hws.Middleware { +func devMode(cfg *config.Config) hws.Middleware { return func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - ctx := context.WithValue(r.Context(), contexts.HTMXLogKey, htmxlog) - req := r.WithContext(ctx) - next.ServeHTTP(w, req) + if cfg.Flags.DevMode { + devInfo := contexts.DevInfo{ + WebsocketBase: "ws://" + cfg.HWS.Host + ":" + strconv.FormatUint(cfg.HWS.Port, 10), + HTMXLog: true, + } + ctx := context.WithValue(r.Context(), contexts.DevModeKey, devInfo) + req := r.WithContext(ctx) + next.ServeHTTP(w, req) + return + } + next.ServeHTTP(w, r) }, ) } diff --git a/cmd/oslstats/routes.go b/cmd/oslstats/routes.go index 9a577a9..28b4696 100644 --- a/cmd/oslstats/routes.go +++ b/cmd/oslstats/routes.go @@ -16,7 +16,7 @@ import ( ) func addRoutes( - server *hws.Server, + s *hws.Server, staticFS *http.FileSystem, cfg *config.Config, conn *bun.DB, @@ -25,41 +25,42 @@ func addRoutes( discordAPI *discord.APIClient, ) error { // Create the routes - routes := []hws.Route{ + pageroutes := []hws.Route{ { Path: "/static/", Method: hws.MethodGET, - Handler: http.StripPrefix("/static/", handlers.StaticFS(staticFS, server)), + Handler: http.StripPrefix("/static/", handlers.StaticFS(staticFS, s)), }, { Path: "/", Method: hws.MethodGET, - Handler: handlers.Index(server), + Handler: handlers.Index(s), }, { Path: "/login", Method: hws.MethodGET, - Handler: auth.LogoutReq(handlers.Login(server, cfg, store, discordAPI)), + Handler: auth.LogoutReq(handlers.Login(s, cfg, store, discordAPI)), }, { Path: "/auth/callback", Method: hws.MethodGET, - Handler: auth.LogoutReq(handlers.Callback(server, auth, conn, cfg, store, discordAPI)), + Handler: auth.LogoutReq(handlers.Callback(s, auth, conn, cfg, store, discordAPI)), }, { Path: "/register", Methods: []hws.Method{hws.MethodGET, hws.MethodPOST}, - Handler: auth.LogoutReq(handlers.Register(server, auth, conn, cfg, store)), + Handler: auth.LogoutReq(handlers.Register(s, auth, conn, cfg, store)), }, { Path: "/logout", Methods: []hws.Method{hws.MethodGET, hws.MethodPOST}, - Handler: auth.LoginReq(handlers.Logout(server, auth, conn, discordAPI)), + Handler: auth.LoginReq(handlers.Logout(s, auth, conn, discordAPI)), }, { - Path: "/test", + Path: "/notification-tester", Methods: []hws.Method{hws.MethodGET, hws.MethodPOST}, - Handler: handlers.Test(server), + Handler: handlers.NotifyTester(s), + // TODO: add login protection }, } @@ -67,12 +68,23 @@ func addRoutes( { Path: "/htmx/isusernameunique", Method: hws.MethodPOST, - Handler: handlers.IsUsernameUnique(server, conn, cfg, store), + Handler: handlers.IsUsernameUnique(s, conn, cfg, store), }, } + wsRoutes := []hws.Route{ + { + Path: "/ws/notifications", + Method: hws.MethodGET, + Handler: handlers.NotificationWS(s, cfg), + }, + } + + routes := append(pageroutes, htmxRoutes...) + routes = append(routes, wsRoutes...) + // Register the routes with the server - err := server.AddRoutes(append(routes, htmxRoutes...)...) + err := s.AddRoutes(routes...) if err != nil { return errors.Wrap(err, "server.AddRoutes") } diff --git a/cmd/oslstats/run.go b/cmd/oslstats/run.go index 2d6f34f..83fc280 100644 --- a/cmd/oslstats/run.go +++ b/cmd/oslstats/run.go @@ -73,8 +73,9 @@ func run(ctx context.Context, w io.Writer, cfg *config.Config) error { wg.Go(func() { <-ctx.Done() shutdownCtx := context.Background() - shutdownCtx, cancel := context.WithTimeout(shutdownCtx, 10*time.Second) + shutdownCtx, cancel := context.WithTimeout(shutdownCtx, 60*time.Second) defer cancel() + logger.Info().Msg("Shut down requested, waiting 60 seconds...") err := httpServer.Shutdown(shutdownCtx) if err != nil { logger.Error().Err(err).Msg("Graceful shutdown failed") diff --git a/go.mod b/go.mod index efde9d7..6e20442 100644 --- a/go.mod +++ b/go.mod @@ -6,9 +6,11 @@ require ( git.haelnorr.com/h/golib/env v0.9.1 git.haelnorr.com/h/golib/ezconf v0.1.1 git.haelnorr.com/h/golib/hlog v0.10.4 - git.haelnorr.com/h/golib/hws v0.3.2 + git.haelnorr.com/h/golib/hws v0.4.0 git.haelnorr.com/h/golib/hwsauth v0.5.2 + git.haelnorr.com/h/golib/notify v0.1.0 github.com/a-h/templ v0.3.977 + github.com/coder/websocket v1.8.14 github.com/joho/godotenv v1.5.1 github.com/pkg/errors v0.9.1 github.com/uptrace/bun v1.2.16 @@ -18,7 +20,7 @@ require ( require ( github.com/gorilla/websocket v1.4.2 // indirect - golang.org/x/crypto v0.45.0 // indirect + golang.org/x/crypto v0.47.0 // indirect ) require ( diff --git a/go.sum b/go.sum index f226f23..cb5707d 100644 --- a/go.sum +++ b/go.sum @@ -6,18 +6,22 @@ git.haelnorr.com/h/golib/ezconf v0.1.1 h1:4euTSDb9jvuQQkVq+x5gHoYPYyUZPWxoOSlWCI git.haelnorr.com/h/golib/ezconf v0.1.1/go.mod h1:rETDcjpcEyyeBgCiZSU617wc0XycwZSC5+IAOtXmwP8= git.haelnorr.com/h/golib/hlog v0.10.4 h1:vpCsV/OddjIYx8F48U66WxojjmhEbeLGQAOBG4ViSRQ= git.haelnorr.com/h/golib/hlog v0.10.4/go.mod h1:+wJ8vecQY/JITTXKmI3JfkHiUGyMs7N6wooj2wuWZbc= -git.haelnorr.com/h/golib/hws v0.3.2 h1:OSwCwVxDerermyuoxrAYgt+i1zzwF/0Baoy8zmCxtuQ= -git.haelnorr.com/h/golib/hws v0.3.2/go.mod h1:6ZlRKnt8YMpv5XcMXmyBGmD1/euvBo3d1azEvHJjOLo= +git.haelnorr.com/h/golib/hws v0.4.0 h1:T2JfRz4zpgsNXj0Vyfzxdf/60Tee/7H30osFmr5jDh0= +git.haelnorr.com/h/golib/hws v0.4.0/go.mod h1:UqB83p9lGjidDkk0pWRqxxOFrCkg8t+9J6uGtBOjNLo= git.haelnorr.com/h/golib/hwsauth v0.5.2 h1:K4McXMEHtI5o4fAL3AZrmaMkwORNqSTV3MM6BExNKag= git.haelnorr.com/h/golib/hwsauth v0.5.2/go.mod h1:NOonrVU/lX8lzuV77eDEiTwBjn7RrzYVcSdXUJWeHmQ= git.haelnorr.com/h/golib/jwt v0.10.1 h1:1Adxt9H3Y4fWFvFjWpvg/vSFhbgCMDMxgiE3m7KvDMI= git.haelnorr.com/h/golib/jwt v0.10.1/go.mod h1:fbuPrfucT9lL0faV5+Q5Gk9WFJxPlwzRPpbMQKYZok4= +git.haelnorr.com/h/golib/notify v0.1.0 h1:xdf6zd21F6n+SuGTeJiuLNMf6zFXMvwpKD0gmNq8N10= +git.haelnorr.com/h/golib/notify v0.1.0/go.mod h1:ARqaRmCYb8LMURhDM75sG+qX+YpqXmUVeAtacwjHjBc= github.com/DATA-DOG/go-sqlmock v1.5.2 h1:OcvFkGmslmlZibjAjaHm3L//6LiuBgolP7OputlJIzU= github.com/DATA-DOG/go-sqlmock v1.5.2/go.mod h1:88MAG/4G7SMwSE3CeA0ZKzrT5CiOU3OJ+JlNzwDqpNU= github.com/a-h/templ v0.3.977 h1:kiKAPXTZE2Iaf8JbtM21r54A8bCNsncrfnokZZSrSDg= github.com/a-h/templ v0.3.977/go.mod h1:oCZcnKRf5jjsGpf2yELzQfodLphd2mwecwG4Crk5HBo= github.com/bwmarrin/discordgo v0.29.0 h1:FmWeXFaKUwrcL3Cx65c20bTRW+vOb6k8AnaP+EgjDno= github.com/bwmarrin/discordgo v0.29.0/go.mod h1:NJZpH+1AfhIcyQsPeuBKsUtYrRnjkyu0kIVMCHkZtRY= +github.com/coder/websocket v1.8.14 h1:9L0p0iKiNOibykf283eHkKUHHrpG7f65OE3BhhO7v9g= +github.com/coder/websocket v1.8.14/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg= github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= 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= @@ -71,8 +75,8 @@ go.opentelemetry.io/otel v1.38.0/go.mod h1:zcmtmQ1+YmQM9wrNsTGV/q/uyusom3P8RxwEx go.opentelemetry.io/otel/trace v1.38.0 h1:Fxk5bKrDZJUH+AMyyIXGcFAPah0oRcT+LuNtJrmcNLE= go.opentelemetry.io/otel/trace v1.38.0/go.mod h1:j1P9ivuFsTceSWe1oY+EeW3sc+Pp42sO++GHkg4wwhs= golang.org/x/crypto v0.0.0-20210421170649-83a5a9bb288b/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4= -golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q= -golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4= +golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8= +golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= diff --git a/internal/config/flags.go b/internal/config/flags.go index ff42621..27dfd91 100644 --- a/internal/config/flags.go +++ b/internal/config/flags.go @@ -12,7 +12,7 @@ type Flags struct { ShowEnv bool GenEnv string EnvFile string - HTMXLog bool + DevMode bool // Database reset (destructive) ResetDB bool @@ -34,7 +34,7 @@ func SetupFlags() (*Flags, error) { showEnv := flag.Bool("showenv", false, "Print all environment variable values and their documentation") genEnv := flag.String("genenv", "", "Generate a .env file with all environment variables (specify filename)") envfile := flag.String("envfile", ".env", "Specify a .env file to use for the configuration") - htmxlog := flag.Bool("htmxlog", false, "Run the server with all HTMX events logged") + devMode := flag.Bool("dev", false, "Run the server in dev mode") // Database reset (destructive) resetDB := flag.Bool("reset-db", false, "⚠️ DESTRUCTIVE: Drop and recreate all tables (dev only)") @@ -78,7 +78,7 @@ func SetupFlags() (*Flags, error) { ShowEnv: *showEnv, GenEnv: *genEnv, EnvFile: *envfile, - HTMXLog: *htmxlog, + DevMode: *devMode, ResetDB: *resetDB, MigrateUp: *migrateUp, MigrateRollback: *migrateRollback, diff --git a/internal/handlers/notifswebsocket.go b/internal/handlers/notifswebsocket.go new file mode 100644 index 0000000..694fde0 --- /dev/null +++ b/internal/handlers/notifswebsocket.go @@ -0,0 +1,120 @@ +package handlers + +import ( + "context" + "net/http" + "strconv" + + "git.haelnorr.com/h/golib/cookies" + "git.haelnorr.com/h/golib/hws" + "git.haelnorr.com/h/golib/notify" + "git.haelnorr.com/h/oslstats/internal/config" + "git.haelnorr.com/h/oslstats/internal/db" + "git.haelnorr.com/h/oslstats/internal/view/component/popup" + + "github.com/coder/websocket" + "github.com/pkg/errors" +) + +func NotificationWS( + s *hws.Server, + cfg *config.Config, +) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Header.Get("Upgrade") != "websocket" { + throwNotFound(s, w, r, r.URL.Path) + return + } + nc, err := setupClient(s, w, r) + if err != nil { + s.LogError(hws.HWSError{ + Message: "Failed to get notification client", + Error: err, + Level: hws.ErrorERROR, + StatusCode: http.StatusInternalServerError, + }) + return + } + ws, err := websocket.Accept(w, r, &websocket.AcceptOptions{ + OriginPatterns: []string{cfg.HWSAuth.TrustedHost}, + }) + if err != nil { + s.LogError(hws.HWSError{ + Message: "Failed to open websocket", + Error: err, + Level: hws.ErrorERROR, + StatusCode: http.StatusInternalServerError, + }) + return + } + defer ws.CloseNow() + ctx := ws.CloseRead(r.Context()) + err = notifyLoop(ctx, nc, ws) + if err != nil { + s.LogError(hws.HWSError{ + Message: "Notification error", + Error: err, + Level: hws.ErrorERROR, + StatusCode: http.StatusInternalServerError, + }) + } + }, + ) +} + +func setupClient(s *hws.Server, w http.ResponseWriter, r *http.Request) (*hws.Client, error) { + user := db.CurrentUser(r.Context()) + altID := "" + if user != nil { + altID = strconv.Itoa(user.ID) + } + subCookie, err := r.Cookie("ws_sub_id") + subID := "" + if err == nil { + subID = subCookie.Value + } + nc, err := s.GetClient(subID, altID) + if err != nil { + return nil, errors.Wrap(err, "s.GetClient") + } + cookies.SetCookie(w, "ws_sub_id", "/", nc.ID(), 0) + return nc, nil +} + +func notifyLoop(ctx context.Context, c *hws.Client, ws *websocket.Conn) error { + notifs, stop := c.Listen() + defer close(stop) + count := 0 + for { + select { + case <-ctx.Done(): + return nil + case nt, ok := <-notifs: + count++ + if !ok { + return nil + } + w, err := ws.Writer(ctx, websocket.MessageText) + if err != nil { + return errors.Wrap(err, "ws.Writer") + } + switch nt.Level { + case hws.LevelShutdown: + err = popup.Toast(nt, count, 30000).Render(ctx, w) + case notify.LevelWarn: + err = popup.Toast(nt, count, 10000).Render(ctx, w) + case notify.LevelError: + // do error modal + default: + err = popup.Toast(nt, count, 6000).Render(ctx, w) + } + if err != nil { + return errors.Wrap(err, "popup.Toast") + } + err = w.Close() + if err != nil { + return errors.Wrap(err, "w.Close") + } + } + } +} diff --git a/internal/handlers/test.go b/internal/handlers/test.go index efa3eb1..f9091e9 100644 --- a/internal/handlers/test.go +++ b/internal/handlers/test.go @@ -7,36 +7,45 @@ import ( "github.com/pkg/errors" "git.haelnorr.com/h/golib/hws" + "git.haelnorr.com/h/golib/notify" ) // Handles responses to the / path. Also serves a 404 Page for paths that // don't have explicit handlers -func Test(server *hws.Server) http.Handler { +func NotifyTester(server *hws.Server) http.Handler { return http.HandlerFunc( func(w http.ResponseWriter, r *http.Request) { if r.Method == "GET" { page.Test().Render(r.Context(), w) } else { r.ParseForm() - notifytype := r.Form.Get("type") + target := r.Form.Get("target") title := r.Form.Get("title") + level := map[string]notify.Level{ + "info": notify.LevelInfo, + "success": notify.LevelSuccess, + "warn": notify.LevelWarn, + "error": notify.LevelError, + }[r.Form.Get("type")] + error := errors.New("This is a stack trace. No really i swear. Just pretend ok? Thanks") message := r.Form.Get("message") - switch notifytype { - case "error": - err := errors.New(message) - notifyInternalServiceError(server, w, title, err) - return - case "warn": - notifyWarning(w, title, message) - return - case "info": - notifyInfo(w, title, message) - return - case "success": - notifySuccess(w, title, message) - return + nt := notify.Notification{ + Target: notify.Target(target), + Title: title, + Message: message, + Level: level, + Details: error.Error(), + } + if target == "" { + server.NotifyAll(nt) + } else { + server.NotifySub(nt) } } }, ) } + +func notifyInfoWS() { + +} diff --git a/internal/view/component/popup/toast.templ b/internal/view/component/popup/toast.templ new file mode 100644 index 0000000..236f47c --- /dev/null +++ b/internal/view/component/popup/toast.templ @@ -0,0 +1,110 @@ +package popup + +import "fmt" +import "git.haelnorr.com/h/golib/notify" +import "git.haelnorr.com/h/golib/hws" + +// ToastNotificationWS creates a toast notification sent via WebSocket with OOB swap +// Backend should pass: notifType ("success"|"warning"|"info"), title, message +templ Toast(nt notify.Notification, id, duration int) { + {{ + // Determine classes server-side based on notifType + color := map[notify.Level]string{ + notify.LevelSuccess: "green", + notify.LevelWarn: "yellow", + notify.LevelInfo: "blue", + hws.LevelShutdown: "yellow", + } + containerClass := fmt.Sprintf("bg-dark-%s border-2 border-%s", color[nt.Level], color[nt.Level]) + textClass := fmt.Sprintf("text-%s", color[nt.Level]) + progressClass := fmt.Sprintf("bg-%s", color[nt.Level]) + }} +
+ +
+} diff --git a/internal/view/component/popup/toastContainer.templ b/internal/view/component/popup/toastContainer.templ index 4385dc3..9c660a1 100644 --- a/internal/view/component/popup/toastContainer.templ +++ b/internal/view/component/popup/toastContainer.templ @@ -1,10 +1,9 @@ package popup // ToastContainer displays stacked toast notifications (success, warning, info) +// Supports both regular toasts (AlpineJS) and WebSocket toasts (HTMX OOB swaps) templ ToastContainer() {
- +
} diff --git a/internal/view/component/popup/toastNotification.templ b/internal/view/component/popup/toastNotification.templ deleted file mode 100644 index bcdcc14..0000000 --- a/internal/view/component/popup/toastNotification.templ +++ /dev/null @@ -1,114 +0,0 @@ -package popup - -// ToastNotification displays an individual toast notification -templ ToastNotification() { - -} diff --git a/internal/view/layout/global.templ b/internal/view/layout/global.templ index 6a4d774..f428b51 100644 --- a/internal/view/layout/global.templ +++ b/internal/view/layout/global.templ @@ -8,13 +8,11 @@ import "git.haelnorr.com/h/oslstats/pkg/contexts" // Global page layout. Includes HTML document settings, header tags // navbar and footer templ Global(title string) { - {{ htmxLogging := contexts.HTMXLog(ctx) }} + {{ devInfo := contexts.DevMode(ctx) }} { title } - - - - if htmxLogging { + + + + + if devInfo.HTMXLog { } - - - @popup.ErrorModal() - @popup.ToastContainer() + + + @popup.ToastContainer()
- - - + + +
+ +
+ + +