From 499136bcb87e3d198e22b5bbbb03d7aff1a4a51e Mon Sep 17 00:00:00 2001 From: Haelnorr Date: Wed, 21 Jan 2026 20:03:02 +1100 Subject: [PATCH] initial commit --- .air.toml | 52 + .gitignore | 9 + Makefile | 38 + cmd/oslstats/auth.go | 44 + cmd/oslstats/db.go | 53 + cmd/oslstats/httpserver.go | 67 ++ cmd/oslstats/main.go | 36 + cmd/oslstats/middleware.go | 24 + cmd/oslstats/routes.go | 45 + cmd/oslstats/run.go | 72 ++ go.mod | 41 + go.sum | 85 ++ internal/config/config.go | 77 ++ internal/config/flags.go | 32 + internal/db/config.go | 55 + internal/db/ezconf.go | 41 + internal/db/user.go | 163 +++ internal/handlers/errorpage.go | 28 + internal/handlers/index.go | 49 + internal/handlers/static.go | 70 ++ internal/view/component/footer/footer.templ | 116 ++ internal/view/component/nav/navbar.templ | 36 + internal/view/component/nav/navbarleft.templ | 19 + internal/view/component/nav/navbarright.templ | 115 ++ internal/view/component/nav/sidenav.templ | 45 + .../view/component/popup/error500Popup.templ | 63 + .../view/component/popup/error503Popup.templ | 63 + internal/view/layout/global.templ | 97 ++ internal/view/page/error.templ | 34 + internal/view/page/index.templ | 13 + pkg/contexts/currentuser.go | 8 + pkg/contexts/keys.go | 7 + pkg/embedfs/embedfs.go | 20 + pkg/embedfs/files/assets/error.png | Bin 0 -> 21893 bytes pkg/embedfs/files/css/input.css | 127 ++ pkg/embedfs/files/css/output.css | 1040 +++++++++++++++++ pkg/embedfs/files/favicon.ico | Bin 0 -> 834 bytes pkg/embedfs/files/js/popups.js | 0 pkg/embedfs/files/js/theme.js | 13 + scripts/README.md | 32 + scripts/generate-css-sources.sh | 140 +++ 41 files changed, 3069 insertions(+) create mode 100644 .air.toml create mode 100644 .gitignore create mode 100644 Makefile create mode 100644 cmd/oslstats/auth.go create mode 100644 cmd/oslstats/db.go create mode 100644 cmd/oslstats/httpserver.go create mode 100644 cmd/oslstats/main.go create mode 100644 cmd/oslstats/middleware.go create mode 100644 cmd/oslstats/routes.go create mode 100644 cmd/oslstats/run.go create mode 100644 go.mod create mode 100644 go.sum create mode 100644 internal/config/config.go create mode 100644 internal/config/flags.go create mode 100644 internal/db/config.go create mode 100644 internal/db/ezconf.go create mode 100644 internal/db/user.go create mode 100644 internal/handlers/errorpage.go create mode 100644 internal/handlers/index.go create mode 100644 internal/handlers/static.go create mode 100644 internal/view/component/footer/footer.templ create mode 100644 internal/view/component/nav/navbar.templ create mode 100644 internal/view/component/nav/navbarleft.templ create mode 100644 internal/view/component/nav/navbarright.templ create mode 100644 internal/view/component/nav/sidenav.templ create mode 100644 internal/view/component/popup/error500Popup.templ create mode 100644 internal/view/component/popup/error503Popup.templ create mode 100644 internal/view/layout/global.templ create mode 100644 internal/view/page/error.templ create mode 100644 internal/view/page/index.templ create mode 100644 pkg/contexts/currentuser.go create mode 100644 pkg/contexts/keys.go create mode 100644 pkg/embedfs/embedfs.go create mode 100644 pkg/embedfs/files/assets/error.png create mode 100644 pkg/embedfs/files/css/input.css create mode 100644 pkg/embedfs/files/css/output.css create mode 100644 pkg/embedfs/files/favicon.ico create mode 100644 pkg/embedfs/files/js/popups.js create mode 100644 pkg/embedfs/files/js/theme.js create mode 100644 scripts/README.md create mode 100755 scripts/generate-css-sources.sh diff --git a/.air.toml b/.air.toml new file mode 100644 index 0000000..ad9f38b --- /dev/null +++ b/.air.toml @@ -0,0 +1,52 @@ +root = "." +testdata_dir = "testdata" +tmp_dir = "tmp" + +[build] + args_bin = [] + bin = "./tmp/main" + cmd = "go build -o ./tmp/main ./cmd/oslstats" + delay = 1000 + exclude_dir = [] + exclude_file = [] + exclude_regex = ["_test.go"] + exclude_unchanged = false + follow_symlink = false + full_bin = "" + include_dir = [] + include_ext = ["go", "templ"] + include_file = [] + kill_delay = "0s" + log = "build-errors.log" + poll = false + poll_interval = 0 + post_cmd = [] + pre_cmd = [] + rerun = false + rerun_delay = 500 + send_interrupt = false + stop_on_error = false + +[color] + app = "" + build = "yellow" + main = "magenta" + runner = "green" + watcher = "cyan" + +[log] + main_only = false + silent = false + time = false + +[misc] + clean_on_exit = false + +[proxy] + app_port = 3333 + enabled = true + proxy_port = 3000 + +[screen] + clear_on_rebuild = false + keep_scroll = true diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..33692cc --- /dev/null +++ b/.gitignore @@ -0,0 +1,9 @@ +.env +*.db* +.logs/ +server.log +bin/ +tmp/ +static/css/output.css +internal/view/**/*_templ.go +internal/view/**/*_templ.txt diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..f3f2a8b --- /dev/null +++ b/Makefile @@ -0,0 +1,38 @@ +# Makefile +.PHONY: build + +BINARY_NAME=oslstats + +build: + ./scripts/generate-css-sources.sh && \ + tailwindcss -i ./pkg/embedfs/files/css/input.css -o ./pkg/embedfs/files/css/output.css && \ + go mod tidy && \ + templ generate && \ + go generate ./cmd/${BINARY_NAME} && \ + go build -ldflags="-w -s" -o ./bin/${BINARY_NAME}${SUFFIX} ./cmd/${BINARY_NAME} + +run: + make build + ./bin/${BINARY_NAME}${SUFFIX} + +dev: + ./scripts/generate-css-sources.sh && \ + templ generate --watch &\ + air &\ + tailwindcss -i ./pkg/embedfs/files/css/input.css -o ./pkg/embedfs/files/css/output.css --watch + +clean: + go clean + +genenv: + make build + ./bin/${BINARY_NAME} --genenv ${OUT} + +envdoc: + make build + ./bin/${BINARY_NAME} --envdoc + +showenv: + make build + ./bin/${BINARY_NAME} --showenv + diff --git a/cmd/oslstats/auth.go b/cmd/oslstats/auth.go new file mode 100644 index 0000000..4f57359 --- /dev/null +++ b/cmd/oslstats/auth.go @@ -0,0 +1,44 @@ +package main + +import ( + "context" + + "git.haelnorr.com/h/golib/hlog" + "git.haelnorr.com/h/golib/hws" + "git.haelnorr.com/h/golib/hwsauth" + "git.haelnorr.com/h/oslstats/internal/db" + "git.haelnorr.com/h/oslstats/internal/handlers" + "git.haelnorr.com/h/oslstats/pkg/contexts" + "github.com/pkg/errors" + "github.com/uptrace/bun" +) + +func setupAuth( + config *hwsauth.Config, + logger *hlog.Logger, + conn *bun.DB, + server *hws.Server, + ignoredPaths []string, +) (*hwsauth.Authenticator[*db.User, bun.Tx], error) { + beginTx := func(ctx context.Context) (hwsauth.DBTransaction, error) { + tx, err := conn.BeginTx(ctx, nil) + return tx, err + } + auth, err := hwsauth.NewAuthenticator( + config, + db.GetUserByID, + server, + beginTx, + logger, + handlers.ErrorPage, + ) + if err != nil { + return nil, errors.Wrap(err, "hwsauth.NewAuthenticator") + } + + auth.IgnorePaths(ignoredPaths...) + + contexts.CurrentUser = auth.CurrentModel + + return auth, nil +} diff --git a/cmd/oslstats/db.go b/cmd/oslstats/db.go new file mode 100644 index 0000000..1df8f57 --- /dev/null +++ b/cmd/oslstats/db.go @@ -0,0 +1,53 @@ +package main + +import ( + "context" + "database/sql" + "fmt" + + "git.haelnorr.com/h/oslstats/internal/config" + "git.haelnorr.com/h/oslstats/internal/db" + "github.com/pkg/errors" + "github.com/uptrace/bun" + "github.com/uptrace/bun/dialect/pgdialect" + "github.com/uptrace/bun/driver/pgdriver" +) + +func setupBun(ctx context.Context, cfg *config.Config) (conn *bun.DB, close func() error, err error) { + dsn := fmt.Sprintf("postgres://%s:%s@%s:%v/%s?sslmode=%s", + cfg.DB.User, cfg.DB.Password, cfg.DB.Host, cfg.DB.Port, cfg.DB.DB, cfg.DB.SSL) + sqldb := sql.OpenDB(pgdriver.NewConnector(pgdriver.WithDSN(dsn))) + conn = bun.NewDB(sqldb, pgdialect.New()) + close = sqldb.Close + + err = loadModels(ctx, conn, cfg.Flags.ResetDB) + if err != nil { + return nil, nil, errors.Wrap(err, "loadModels") + } + + return conn, close, nil +} + +func loadModels(ctx context.Context, conn *bun.DB, resetDB bool) error { + models := []any{ + (*db.User)(nil), + } + + for _, model := range models { + _, err := conn.NewCreateTable(). + Model(model). + IfNotExists(). + Exec(ctx) + if err != nil { + return errors.Wrap(err, "db.NewCreateTable") + } + if resetDB { + err = conn.ResetModel(ctx, model) + if err != nil { + return errors.Wrap(err, "db.ResetModel") + } + } + } + + return nil +} diff --git a/cmd/oslstats/httpserver.go b/cmd/oslstats/httpserver.go new file mode 100644 index 0000000..fa0461d --- /dev/null +++ b/cmd/oslstats/httpserver.go @@ -0,0 +1,67 @@ +package main + +import ( + "io/fs" + "net/http" + + "git.haelnorr.com/h/golib/hws" + "git.haelnorr.com/h/oslstats/internal/config" + "git.haelnorr.com/h/oslstats/internal/handlers" + + "git.haelnorr.com/h/golib/hlog" + "github.com/pkg/errors" + "github.com/uptrace/bun" +) + +func setupHttpServer( + staticFS *fs.FS, + config *config.Config, + logger *hlog.Logger, + bun *bun.DB, +) (server *hws.Server, err error) { + if staticFS == nil { + return nil, errors.New("No filesystem provided") + } + fs := http.FS(*staticFS) + httpServer, err := hws.NewServer(config.HWS) + if err != nil { + return nil, errors.Wrap(err, "hws.NewServer") + } + + ignoredPaths := []string{ + "/static/css/output.css", + "/static/favicon.ico", + } + + auth, err := setupAuth(config.HWSAuth, logger, bun, httpServer, ignoredPaths) + if err != nil { + return nil, errors.Wrap(err, "setupAuth") + } + + err = httpServer.AddErrorPage(handlers.ErrorPage) + if err != nil { + return nil, errors.Wrap(err, "httpServer.AddErrorPage") + } + + err = httpServer.AddLogger(logger) + if err != nil { + return nil, errors.Wrap(err, "httpServer.AddLogger") + } + + err = httpServer.LoggerIgnorePaths(ignoredPaths...) + if err != nil { + return nil, errors.Wrap(err, "httpServer.LoggerIgnorePaths") + } + + err = addRoutes(httpServer, &fs, config, logger, bun, auth) + if err != nil { + return nil, errors.Wrap(err, "addRoutes") + } + + err = addMiddleware(httpServer, auth) + if err != nil { + return nil, errors.Wrap(err, "httpServer.AddMiddleware") + } + + return httpServer, nil +} diff --git a/cmd/oslstats/main.go b/cmd/oslstats/main.go new file mode 100644 index 0000000..e92d126 --- /dev/null +++ b/cmd/oslstats/main.go @@ -0,0 +1,36 @@ +package main + +import ( + "context" + "fmt" + "os" + + "git.haelnorr.com/h/oslstats/internal/config" + "github.com/pkg/errors" +) + +func main() { + flags := config.SetupFlags() + ctx := context.Background() + + cfg, loader, err := config.GetConfig(flags) + if err != nil { + fmt.Fprintf(os.Stderr, "%s\n", errors.Wrap(err, "Failed to load config")) + os.Exit(1) + } + + if flags.EnvDoc || flags.ShowEnv { + loader.PrintEnvVarsStdout(flags.ShowEnv) + return + } + + if flags.GenEnv != "" { + loader.GenerateEnvFile(flags.GenEnv, true) + return + } + + if err := run(ctx, os.Stdout, cfg); err != nil { + fmt.Fprintf(os.Stderr, "%s\n", err) + os.Exit(1) + } +} diff --git a/cmd/oslstats/middleware.go b/cmd/oslstats/middleware.go new file mode 100644 index 0000000..cd4ea78 --- /dev/null +++ b/cmd/oslstats/middleware.go @@ -0,0 +1,24 @@ +package main + +import ( + "git.haelnorr.com/h/golib/hws" + "git.haelnorr.com/h/golib/hwsauth" + "git.haelnorr.com/h/oslstats/internal/db" + + "github.com/pkg/errors" + "github.com/uptrace/bun" +) + +func addMiddleware( + server *hws.Server, + auth *hwsauth.Authenticator[*db.User, bun.Tx], +) error { + + err := server.AddMiddleware( + auth.Authenticate(), + ) + if err != nil { + return errors.Wrap(err, "server.AddMiddleware") + } + return nil +} diff --git a/cmd/oslstats/routes.go b/cmd/oslstats/routes.go new file mode 100644 index 0000000..efea343 --- /dev/null +++ b/cmd/oslstats/routes.go @@ -0,0 +1,45 @@ +package main + +import ( + "net/http" + + "git.haelnorr.com/h/golib/hws" + "git.haelnorr.com/h/golib/hwsauth" + "git.haelnorr.com/h/oslstats/internal/config" + "git.haelnorr.com/h/oslstats/internal/db" + "git.haelnorr.com/h/oslstats/internal/handlers" + + "git.haelnorr.com/h/golib/hlog" + "github.com/pkg/errors" + "github.com/uptrace/bun" +) + +func addRoutes( + server *hws.Server, + staticFS *http.FileSystem, + config *config.Config, + logger *hlog.Logger, + conn *bun.DB, + auth *hwsauth.Authenticator[*db.User, bun.Tx], +) error { + // Create the routes + routes := []hws.Route{ + { + Path: "/static/", + Method: hws.MethodGET, + Handler: http.StripPrefix("/static/", handlers.StaticFS(staticFS, server)), + }, + { + Path: "/", + Method: hws.MethodGET, + Handler: handlers.Index(server), + }, + } + + // Register the routes with the server + err := server.AddRoutes(routes...) + if err != nil { + return errors.Wrap(err, "server.AddRoutes") + } + return nil +} diff --git a/cmd/oslstats/run.go b/cmd/oslstats/run.go new file mode 100644 index 0000000..7906c26 --- /dev/null +++ b/cmd/oslstats/run.go @@ -0,0 +1,72 @@ +package main + +import ( + "context" + "io" + "os" + "os/signal" + "sync" + "time" + + "git.haelnorr.com/h/golib/hlog" + "git.haelnorr.com/h/oslstats/internal/config" + "git.haelnorr.com/h/oslstats/pkg/embedfs" + "github.com/pkg/errors" +) + +// Initializes and runs the server +func run(ctx context.Context, w io.Writer, config *config.Config) error { + ctx, cancel := signal.NotifyContext(ctx, os.Interrupt) + defer cancel() + + // Setup the logger + logger, err := hlog.NewLogger(config.HLOG, w) + if err != nil { + return errors.Wrap(err, "hlog.NewLogger") + } + + // Setup the database connection + logger.Debug().Msg("Config loaded and logger started") + logger.Debug().Msg("Connecting to database") + bun, closedb, err := setupBun(ctx, config) + if err != nil { + return errors.Wrap(err, "setupDBConn") + } + defer closedb() + + // Setup embedded files + logger.Debug().Msg("Getting embedded files") + staticFS, err := embedfs.GetEmbeddedFS() + if err != nil { + return errors.Wrap(err, "getStaticFiles") + } + + logger.Debug().Msg("Setting up HTTP server") + httpServer, err := setupHttpServer(&staticFS, config, logger, bun) + if err != nil { + return errors.Wrap(err, "setupHttpServer") + } + + // Runs the http server + logger.Debug().Msg("Starting up the HTTP server") + err = httpServer.Start(ctx) + if err != nil { + return errors.Wrap(err, "httpServer.Start") + } + + // Handles graceful shutdown + var wg sync.WaitGroup + wg.Go(func() { + <-ctx.Done() + shutdownCtx := context.Background() + shutdownCtx, cancel := context.WithTimeout(shutdownCtx, 10*time.Second) + defer cancel() + err := httpServer.Shutdown(shutdownCtx) + if err != nil { + logger.Error().Err(err).Msg("Graceful shutdown failed") + } + }) + wg.Wait() + logger.Info().Msg("Shutting down") + return nil +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..26e14e9 --- /dev/null +++ b/go.mod @@ -0,0 +1,41 @@ +module git.haelnorr.com/h/oslstats + +go 1.25.5 + +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.2.3 + git.haelnorr.com/h/golib/hwsauth v0.3.4 + github.com/a-h/templ v0.3.977 + github.com/joho/godotenv v1.5.1 + github.com/pkg/errors v0.9.1 + github.com/uptrace/bun v1.2.16 + github.com/uptrace/bun/dialect/pgdialect v1.2.16 + github.com/uptrace/bun/driver/pgdriver v1.2.16 + golang.org/x/crypto v0.45.0 +) + +require ( + git.haelnorr.com/h/golib/cookies v0.9.0 // indirect + git.haelnorr.com/h/golib/jwt v0.10.0 // indirect + github.com/go-logr/logr v1.4.3 // indirect + github.com/golang-jwt/jwt v3.2.2+incompatible // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/jinzhu/inflection v1.0.0 // indirect + github.com/mattn/go-colorable v0.1.14 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/puzpuzpuz/xsync/v3 v3.5.1 // indirect + github.com/rs/zerolog v1.34.0 // indirect + github.com/tmthrgd/go-hex v0.0.0-20190904060850-447a3041c3bc // indirect + github.com/vmihailenco/msgpack/v5 v5.4.1 // indirect + github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect + go.opentelemetry.io/otel v1.38.0 // indirect + go.opentelemetry.io/otel/trace v1.38.0 // indirect + golang.org/x/sys v0.40.0 // indirect + k8s.io/apimachinery v0.35.0 // indirect + k8s.io/klog/v2 v2.130.1 // indirect + k8s.io/utils v0.0.0-20260108192941-914a6e750570 // indirect + mellium.im/sasl v0.3.2 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..78c8ff7 --- /dev/null +++ b/go.sum @@ -0,0 +1,85 @@ +git.haelnorr.com/h/golib/cookies v0.9.0 h1:Vf+eX1prHkKuGrQon1BHY87yaPc1H+HJFRXDOV/AuWs= +git.haelnorr.com/h/golib/cookies v0.9.0/go.mod h1:y1385YExI9gLwckCVDCYVcsFXr6N7T3brJjnJD2QIuo= +git.haelnorr.com/h/golib/env v0.9.1 h1:2Vsj+mJKnO5f1Md1GO5v9ggLN5zWa0baCewcSHTjoNY= +git.haelnorr.com/h/golib/env v0.9.1/go.mod h1:glUQVdA1HMKX1avTDyTyuhcr36SSxZtlJxKDT5KTztg= +git.haelnorr.com/h/golib/ezconf v0.1.1 h1:4euTSDb9jvuQQkVq+x5gHoYPYyUZPWxoOSlWCIxTZOs= +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.2.3 h1:gZQkBciXKh3jYw05vZncSR2lvIqi0H2MVfIWySySsmw= +git.haelnorr.com/h/golib/hws v0.2.3/go.mod h1:6ZlRKnt8YMpv5XcMXmyBGmD1/euvBo3d1azEvHJjOLo= +git.haelnorr.com/h/golib/hwsauth v0.3.4 h1:wwYBb6cQQ+x9hxmYuZBF4mVmCv/n4PjJV//e1+SgPOo= +git.haelnorr.com/h/golib/hwsauth v0.3.4/go.mod h1:LI7Qz68GPNIW8732Zwptb//ybjiFJOoXf4tgUuUEqHI= +git.haelnorr.com/h/golib/jwt v0.10.0 h1:8cI8mSnb8X+EmJtrBO/5UZwuBMtib0IE9dv85gkm94E= +git.haelnorr.com/h/golib/jwt v0.10.0/go.mod h1:fbuPrfucT9lL0faV5+Q5Gk9WFJxPlwzRPpbMQKYZok4= +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/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= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= +github.com/golang-jwt/jwt v3.2.2+incompatible h1:IfV12K8xAKAnZqdXVzCZ+TOjboZ2keLg81eXfW3O+oY= +github.com/golang-jwt/jwt v3.2.2+incompatible/go.mod h1:8pz2t5EyA70fFQQSrl6XZXzqecmYZeUEB8OUGHkxJ+I= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +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/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/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= +github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= +github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= +github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= +github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= +github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= +github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/puzpuzpuz/xsync/v3 v3.5.1 h1:GJYJZwO6IdxN/IKbneznS6yPkVC+c3zyY/j19c++5Fg= +github.com/puzpuzpuz/xsync/v3 v3.5.1/go.mod h1:VjzYrABPabuM4KyBh1Ftq6u8nhwY5tBPKP9jpmh0nnA= +github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0= +github.com/rs/zerolog v1.34.0 h1:k43nTLIwcTVQAncfCw4KZ2VY6ukYoZaBPNOE8txlOeY= +github.com/rs/zerolog v1.34.0/go.mod h1:bJsvje4Z08ROH4Nhs5iH600c3IkWhwp44iRc54W6wYQ= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/tmthrgd/go-hex v0.0.0-20190904060850-447a3041c3bc h1:9lRDQMhESg+zvGYmW5DyG0UqvY96Bu5QYsTLvCHdrgo= +github.com/tmthrgd/go-hex v0.0.0-20190904060850-447a3041c3bc/go.mod h1:bciPuU6GHm1iF1pBvUfxfsH0Wmnc2VbpgvbI9ZWuIRs= +github.com/uptrace/bun v1.2.16 h1:QlObi6ZIK5Ao7kAALnh91HWYNZUBbVwye52fmlQM9kc= +github.com/uptrace/bun v1.2.16/go.mod h1:jMoNg2n56ckaawi/O/J92BHaECmrz6IRjuMWqlMaMTM= +github.com/uptrace/bun/dialect/pgdialect v1.2.16 h1:KFNZ0LxAyczKNfK/IJWMyaleO6eI9/Z5tUv3DE1NVL4= +github.com/uptrace/bun/dialect/pgdialect v1.2.16/go.mod h1:IJdMeV4sLfh0LDUZl7TIxLI0LipF1vwTK3hBC7p5qLo= +github.com/uptrace/bun/driver/pgdriver v1.2.16 h1:b1kpXKUxtTSGYow5Vlsb+dKV3z0R7aSAJNfMfKp61ZU= +github.com/uptrace/bun/driver/pgdriver v1.2.16/go.mod h1:H6lUZ9CBfp1X5Vq62YGSV7q96/v94ja9AYFjKvdoTk0= +github.com/vmihailenco/msgpack/v5 v5.4.1 h1:cQriyiUvjTwOHg8QZaPihLWeRAAVoCpE00IUPn0Bjt8= +github.com/vmihailenco/msgpack/v5 v5.4.1/go.mod h1:GaZTsDaehaPpQVyxrf5mtQlH+pc21PIudVV/E3rRQok= +github.com/vmihailenco/tagparser/v2 v2.0.0 h1:y09buUbR+b5aycVFQs/g70pqKVZNBmxwAhO7/IwNM9g= +github.com/vmihailenco/tagparser/v2 v2.0.0/go.mod h1:Wri+At7QHww0WTrCBeu4J6bNtoV6mEfg5OIWRZA9qds= +go.opentelemetry.io/otel v1.38.0 h1:RkfdswUDRimDg0m2Az18RKOsnI8UDzppJAtj01/Ymk8= +go.opentelemetry.io/otel v1.38.0/go.mod h1:zcmtmQ1+YmQM9wrNsTGV/q/uyusom3P8RxwExxkZhjM= +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.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q= +golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4= +golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ= +golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +k8s.io/apimachinery v0.35.0 h1:Z2L3IHvPVv/MJ7xRxHEtk6GoJElaAqDCCU0S6ncYok8= +k8s.io/apimachinery v0.35.0/go.mod h1:jQCgFZFR1F4Ik7hvr2g84RTJSZegBc8yHgFWKn//hns= +k8s.io/klog/v2 v2.130.1 h1:n9Xl7H1Xvksem4KFG4PYbdQCQxqc/tTUyrgXaOhHSzk= +k8s.io/klog/v2 v2.130.1/go.mod h1:3Jpz1GvMt720eyJH1ckRHK1EDfpxISzJ7I9OYgaDtPE= +k8s.io/utils v0.0.0-20260108192941-914a6e750570 h1:JT4W8lsdrGENg9W+YwwdLJxklIuKWdRm+BC+xt33FOY= +k8s.io/utils v0.0.0-20260108192941-914a6e750570/go.mod h1:xDxuJ0whA3d0I4mf/C4ppKHxXynQ+fxnkmQH0vTHnuk= +mellium.im/sasl v0.3.2 h1:PT6Xp7ccn9XaXAnJ03FcEjmAn7kK1x7aoXV6F+Vmrl0= +mellium.im/sasl v0.3.2/go.mod h1:NKXDi1zkr+BlMHLQjY3ofYuU4KSPFxknb8mfEu6SveY= diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..87b9f15 --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,77 @@ +package config + +import ( + "git.haelnorr.com/h/golib/ezconf" + "git.haelnorr.com/h/golib/hlog" + "git.haelnorr.com/h/golib/hws" + "git.haelnorr.com/h/golib/hwsauth" + "git.haelnorr.com/h/oslstats/internal/db" + "github.com/joho/godotenv" + "github.com/pkg/errors" +) + +type Config struct { + DB *db.Config + HWS *hws.Config + HWSAuth *hwsauth.Config + HLOG *hlog.Config + Flags *Flags +} + +// Load the application configuration and get a pointer to the Config object +// If doconly is specified, only the loader will be returned +func GetConfig(flags *Flags) (*Config, *ezconf.ConfigLoader, error) { + err := godotenv.Load(flags.EnvFile) + if err != nil && flags.GenEnv == "" && !flags.EnvDoc { + return nil, nil, errors.Wrap(err, "gotgodotenv.Load") + } + + loader := ezconf.New() + loader.RegisterIntegrations( + hlog.NewEZConfIntegration(), + hws.NewEZConfIntegration(), + hwsauth.NewEZConfIntegration(), + db.NewEZConfIntegration(), + ) + if err := loader.ParseEnvVars(); err != nil { + return nil, nil, errors.Wrap(err, "loader.ParseEnvVars") + } + + if flags.GenEnv != "" || flags.EnvDoc { + return nil, loader, nil + } + + if err := loader.LoadConfigs(); err != nil { + return nil, nil, errors.Wrap(err, "loader.LoadConfigs") + } + + hwscfg, ok := loader.GetConfig("hws") + if !ok { + return nil, nil, errors.New("HWS Config not loaded") + } + + hwsauthcfg, ok := loader.GetConfig("hwsauth") + if !ok { + return nil, nil, errors.New("HWSAuth Config not loaded") + } + + hlogcfg, ok := loader.GetConfig("hlog") + if !ok { + return nil, nil, errors.New("HLog Config not loaded") + } + + dbcfg, ok := loader.GetConfig("db") + if !ok { + return nil, nil, errors.New("DB Config not loaded") + } + + config := &Config{ + DB: dbcfg.(*db.Config), + HWS: hwscfg.(*hws.Config), + HWSAuth: hwsauthcfg.(*hwsauth.Config), + HLOG: hlogcfg.(*hlog.Config), + Flags: flags, + } + + return config, loader, nil +} diff --git a/internal/config/flags.go b/internal/config/flags.go new file mode 100644 index 0000000..7d89108 --- /dev/null +++ b/internal/config/flags.go @@ -0,0 +1,32 @@ +package config + +import ( + "flag" +) + +type Flags struct { + ResetDB bool + EnvDoc bool + ShowEnv bool + GenEnv string + EnvFile string +} + +func SetupFlags() *Flags { + // Parse commandline args + resetDB := flag.Bool("resetdb", false, "Reset all the database tables with the updated models") + envDoc := flag.Bool("envdoc", false, "Print all environment variables and their documentation") + 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") + flag.Parse() + + flags := &Flags{ + ResetDB: *resetDB, + EnvDoc: *envDoc, + ShowEnv: *showEnv, + GenEnv: *genEnv, + EnvFile: *envfile, + } + return flags +} diff --git a/internal/db/config.go b/internal/db/config.go new file mode 100644 index 0000000..bc50be7 --- /dev/null +++ b/internal/db/config.go @@ -0,0 +1,55 @@ +package db + +import ( + "git.haelnorr.com/h/golib/env" + "github.com/pkg/errors" +) + +type Config struct { + User string // ENV DB_USER: Database user for authentication (required) + Password string // ENV DB_PASSWORD: Database password for authentication (required) + Host string // ENV DB_HOST: Database host address (required) + Port uint16 // ENV DB_PORT: Database port (default: 5432) + DB string // ENV DB_NAME: Database name to connect to (required) + SSL string // ENV DB_SSL: SSL mode for connection (default: disable) +} + +func ConfigFromEnv() (any, error) { + cfg := &Config{ + User: env.String("DB_USER", ""), + Password: env.String("DB_PASSWORD", ""), + Host: env.String("DB_HOST", ""), + Port: env.UInt16("DB_PORT", 5432), + DB: env.String("DB_NAME", ""), + SSL: env.String("DB_SSL", "disable"), + } + + // Validate SSL mode + validSSLModes := map[string]bool{ + "disable": true, + "require": true, + "verify-ca": true, + "verify-full": true, + "allow": true, + "prefer": true, + } + if !validSSLModes[cfg.SSL] { + return nil, errors.Errorf("Invalid DB_SSL value: %s. Must be one of: disable, allow, prefer, require, verify-ca, verify-full", cfg.SSL) + } + + // Check required fields + if cfg.User == "" { + return nil, errors.New("Envar not set: DB_USER") + } + if cfg.Password == "" { + return nil, errors.New("Envar not set: DB_PASSWORD") + } + if cfg.Host == "" { + return nil, errors.New("Envar not set: DB_HOST") + } + if cfg.DB == "" { + return nil, errors.New("Envar not set: DB_NAME") + } + + return cfg, nil +} diff --git a/internal/db/ezconf.go b/internal/db/ezconf.go new file mode 100644 index 0000000..db4eac9 --- /dev/null +++ b/internal/db/ezconf.go @@ -0,0 +1,41 @@ +package db + +import ( + "runtime" + "strings" +) + +// EZConfIntegration provides integration with ezconf for automatic configuration +type EZConfIntegration struct { + configFunc func() (any, error) + name string +} + +// PackagePath returns the path to the config package for source parsing +func (e EZConfIntegration) PackagePath() string { + _, filename, _, _ := runtime.Caller(0) + // Return directory of this file + return filename[:len(filename)-len("/ezconf.go")] +} + +// ConfigFunc returns the ConfigFromEnv function for ezconf +func (e EZConfIntegration) ConfigFunc() func() (any, error) { + return func() (any, error) { + return e.configFunc() + } +} + +// Name returns the name to use when registering with ezconf +func (e EZConfIntegration) Name() string { + return strings.ToLower(e.name) +} + +// GroupName returns the display name for grouping environment variables +func (e EZConfIntegration) GroupName() string { + return e.name +} + +// NewEZConfIntegration creates a new EZConf integration helper +func NewEZConfIntegration() EZConfIntegration { + return EZConfIntegration{name: "db", configFunc: ConfigFromEnv} +} diff --git a/internal/db/user.go b/internal/db/user.go new file mode 100644 index 0000000..01764eb --- /dev/null +++ b/internal/db/user.go @@ -0,0 +1,163 @@ +package db + +import ( + "context" + + "github.com/pkg/errors" + "github.com/uptrace/bun" + "golang.org/x/crypto/bcrypt" +) + +type User struct { + bun.BaseModel `bun:"table:users,alias:u"` + + ID int `bun:"id,pk,autoincrement"` // Integer ID (index primary key) + Username string `bun:"username,unique"` // Username (unique) + PasswordHash string `bun:"password_hash,nullzero"` // Bcrypt hashed password (not exported in JSON) + CreatedAt int64 `bun:"created_at"` // Epoch timestamp when the user was added to the database + Bio string `bun:"bio"` // Short byline set by the user +} + +func (user *User) GetID() int { + return user.ID +} + +// Uses bcrypt to set the users password_hash from the given password +func (user *User) SetPassword(ctx context.Context, tx bun.Tx, password string) error { + hashedPassword, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) + if err != nil { + return errors.Wrap(err, "bcrypt.GenerateFromPassword") + } + newPassword := string(hashedPassword) + + _, err = tx.NewUpdate(). + Model(user). + Set("password_hash = ?", newPassword). + Where("id = ?", user.ID). + Exec(ctx) + if err != nil { + return errors.Wrap(err, "tx.Update") + } + return nil +} + +// Uses bcrypt to check if the given password matches the users password_hash +func (user *User) CheckPassword(ctx context.Context, tx bun.Tx, password string) error { + var hashedPassword string + err := tx.NewSelect(). + Table("users"). + Column("password_hash"). + Where("id = ?", user.ID). + Limit(1). + Scan(ctx, &hashedPassword) + if err != nil { + return errors.Wrap(err, "tx.Select") + } + + err = bcrypt.CompareHashAndPassword([]byte(hashedPassword), []byte(password)) + if err != nil { + return errors.Wrap(err, "Username or password incorrect") + } + return nil +} + +// Change the user's username +func (user *User) ChangeUsername(ctx context.Context, tx bun.Tx, newUsername string) error { + _, err := tx.NewUpdate(). + Model(user). + Set("username = ?", newUsername). + Where("id = ?", user.ID). + Exec(ctx) + if err != nil { + return errors.Wrap(err, "tx.Update") + } + user.Username = newUsername + return nil +} + +// Change the user's bio +func (user *User) ChangeBio(ctx context.Context, tx bun.Tx, newBio string) error { + _, err := tx.NewUpdate(). + Model(user). + Set("bio = ?", newBio). + Where("id = ?", user.ID). + Exec(ctx) + if err != nil { + return errors.Wrap(err, "tx.Update") + } + user.Bio = newBio + return nil +} + +// CreateUser creates a new user with the given username and password +func CreateUser(ctx context.Context, tx bun.Tx, username, password string) (*User, error) { + hashedPassword, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) + if err != nil { + return nil, errors.Wrap(err, "bcrypt.GenerateFromPassword") + } + + user := &User{ + Username: username, + PasswordHash: string(hashedPassword), + CreatedAt: 0, // You may want to set this to time.Now().Unix() + Bio: "", + } + + _, err = tx.NewInsert(). + Model(user). + Exec(ctx) + if err != nil { + return nil, errors.Wrap(err, "tx.Insert") + } + + return user, nil +} + +// GetUserByID queries the database for a user matching the given ID +// Returns nil, nil if no user is found +func GetUserByID(ctx context.Context, tx bun.Tx, id int) (*User, error) { + user := new(User) + err := tx.NewSelect(). + Model(user). + Where("id = ?", id). + Limit(1). + Scan(ctx) + if err != nil { + if err.Error() == "sql: no rows in result set" { + return nil, nil + } + return nil, errors.Wrap(err, "tx.Select") + } + return user, nil +} + +// GetUserByUsername queries the database for a user matching the given username +// Returns nil, nil if no user is found +func GetUserByUsername(ctx context.Context, tx bun.Tx, username string) (*User, error) { + user := new(User) + err := tx.NewSelect(). + Model(user). + Where("username = ?", username). + Limit(1). + Scan(ctx) + if err != nil { + if err.Error() == "sql: no rows in result set" { + return nil, nil + } + return nil, errors.Wrap(err, "tx.Select") + } + return user, nil +} + +// IsUsernameUnique checks if the given username is unique (not already taken) +// Returns true if the username is available, false if it's taken +func IsUsernameUnique(ctx context.Context, tx bun.Tx, username string) (bool, error) { + count, err := tx.NewSelect(). + Model((*User)(nil)). + Where("username = ?", username). + Count(ctx) + if err != nil { + return false, errors.Wrap(err, "tx.Count") + } + return count == 0, nil +} diff --git a/internal/handlers/errorpage.go b/internal/handlers/errorpage.go new file mode 100644 index 0000000..2fa7699 --- /dev/null +++ b/internal/handlers/errorpage.go @@ -0,0 +1,28 @@ +package handlers + +import ( + "net/http" + + "git.haelnorr.com/h/golib/hws" + "git.haelnorr.com/h/oslstats/internal/view/page" + "github.com/pkg/errors" +) + +func ErrorPage( + errorCode int, +) (hws.ErrorPage, error) { + messages := map[int]string{ + 400: "The request you made was malformed or unexpected.", + 401: "You need to login to view this page.", + 403: "You do not have permission to view this page.", + 404: "The page or resource you have requested does not exist.", + 500: `An error occured on the server. Please try again, and if this + continues to happen contact an administrator.`, + 503: "The server is currently down for maintenance and should be back soon. =)", + } + msg, exists := messages[errorCode] + if !exists { + return nil, errors.New("No valid message for the given code") + } + return page.Error(errorCode, http.StatusText(errorCode), msg), nil +} diff --git a/internal/handlers/index.go b/internal/handlers/index.go new file mode 100644 index 0000000..3274302 --- /dev/null +++ b/internal/handlers/index.go @@ -0,0 +1,49 @@ +package handlers + +import ( + "net/http" + + "git.haelnorr.com/h/oslstats/internal/view/page" + + "git.haelnorr.com/h/golib/hws" +) + +// Handles responses to the / path. Also serves a 404 Page for paths that +// don't have explicit handlers +func Index(server *hws.Server) http.Handler { + return http.HandlerFunc( + func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/" { + page, err := ErrorPage(http.StatusNotFound) + if err != nil { + err = server.ThrowError(w, r, hws.HWSError{ + StatusCode: http.StatusInternalServerError, + Message: "An error occured trying to generate the error page", + Error: err, + Level: hws.ErrorLevel("error"), + RenderErrorPage: false, + }) + if err != nil { + server.ThrowFatal(w, err) + } + return + } + err = page.Render(r.Context(), w) + if err != nil { + err = server.ThrowError(w, r, hws.HWSError{ + StatusCode: http.StatusInternalServerError, + Message: "An error occured trying to render the error page", + Error: err, + Level: hws.ErrorLevel("error"), + RenderErrorPage: false, + }) + if err != nil { + server.ThrowFatal(w, err) + } + return + } + } + page.Index().Render(r.Context(), w) + }, + ) +} diff --git a/internal/handlers/static.go b/internal/handlers/static.go new file mode 100644 index 0000000..fb444c9 --- /dev/null +++ b/internal/handlers/static.go @@ -0,0 +1,70 @@ +package handlers + +import ( + "git.haelnorr.com/h/golib/hws" + "net/http" + "path/filepath" + "strings" +) + +// Handles requests for static files, without allowing access to the +// directory viewer and returning 404 if an exact file is not found +func StaticFS(staticFS *http.FileSystem, server *hws.Server) http.Handler { + // Create the file server once, not on every request + fs, err := hws.SafeFileServer(staticFS) + if err != nil { + // If we can't create the file server, return a handler that always errors + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + err = server.ThrowError(w, r, hws.HWSError{ + StatusCode: http.StatusInternalServerError, + Message: "An error occured trying to load the file system", + Error: err, + Level: hws.ErrorLevel("error"), + RenderErrorPage: true, + }) + if err != nil { + server.ThrowFatal(w, err) + } + }) + } + + return http.HandlerFunc( + func(w http.ResponseWriter, r *http.Request) { + // Explicitly set Content-Type for CSS files + if strings.HasSuffix(r.URL.Path, ".css") { + w.Header().Set("Content-Type", "text/css; charset=utf-8") + } else if strings.HasSuffix(r.URL.Path, ".js") { + w.Header().Set("Content-Type", "application/javascript; charset=utf-8") + } else if strings.HasSuffix(r.URL.Path, ".ico") { + w.Header().Set("Content-Type", "image/x-icon") + } else { + // Let Go detect the content type for other files + ext := filepath.Ext(r.URL.Path) + if contentType := mimeTypes[ext]; contentType != "" { + w.Header().Set("Content-Type", contentType) + } + } + fs.ServeHTTP(w, r) + }, + ) +} + +// Common MIME types for static files +var mimeTypes = map[string]string{ + ".html": "text/html; charset=utf-8", + ".css": "text/css; charset=utf-8", + ".js": "application/javascript; charset=utf-8", + ".json": "application/json; charset=utf-8", + ".xml": "application/xml; charset=utf-8", + ".png": "image/png", + ".jpg": "image/jpeg", + ".jpeg": "image/jpeg", + ".gif": "image/gif", + ".svg": "image/svg+xml", + ".ico": "image/x-icon", + ".webp": "image/webp", + ".woff": "font/woff", + ".woff2": "font/woff2", + ".ttf": "font/ttf", + ".eot": "application/vnd.ms-fontobject", +} diff --git a/internal/view/component/footer/footer.templ b/internal/view/component/footer/footer.templ new file mode 100644 index 0000000..59a19a1 --- /dev/null +++ b/internal/view/component/footer/footer.templ @@ -0,0 +1,116 @@ +package footer + +type FooterItem struct { + name string + href string +} + +// Specify the links to show in the footer +func getFooterItems() []FooterItem { + return []FooterItem{ + { + name: "About", + href: "/about", + }, + } +} + +// Returns the template fragment for the Footer +templ Footer() { + +} diff --git a/internal/view/component/nav/navbar.templ b/internal/view/component/nav/navbar.templ new file mode 100644 index 0000000..e9471a0 --- /dev/null +++ b/internal/view/component/nav/navbar.templ @@ -0,0 +1,36 @@ +package nav + +type NavItem struct { + name string // Label to display + href string // Link reference +} + +// Return the list of navbar links +func getNavItems() []NavItem { + return []NavItem{} +} + +// Returns the navbar template fragment +templ Navbar() { + {{ navItems := getNavItems() }} +
+
+
+ + + + OSL Stats + + +
+ @navLeft(navItems) + @navRight() +
+
+
+ @sideNav(navItems) +
+} diff --git a/internal/view/component/nav/navbarleft.templ b/internal/view/component/nav/navbarleft.templ new file mode 100644 index 0000000..fec9443 --- /dev/null +++ b/internal/view/component/nav/navbarleft.templ @@ -0,0 +1,19 @@ +package nav + +// Returns the left portion of the navbar +templ navLeft(navItems []NavItem) { + +} diff --git a/internal/view/component/nav/navbarright.templ b/internal/view/component/nav/navbarright.templ new file mode 100644 index 0000000..5f1fc22 --- /dev/null +++ b/internal/view/component/nav/navbarright.templ @@ -0,0 +1,115 @@ +package nav + +import "git.haelnorr.com/h/oslstats/pkg/contexts" + +type ProfileItem struct { + name string // Label to display + href string // Link reference +} + +// Return the list of profile links +func getProfileItems() []ProfileItem { + return []ProfileItem{ + { + name: "Profile", + href: "/profile", + }, + { + name: "Account", + href: "/account", + }, + } +} + +// Returns the right portion of the navbar +templ navRight() { + {{ user := contexts.CurrentUser(ctx) }} + {{ items := getProfileItems() }} +
+
+ if user != nil { +
+
+ +
+ +
+ } else { + + } +
+ +
+} diff --git a/internal/view/component/nav/sidenav.templ b/internal/view/component/nav/sidenav.templ new file mode 100644 index 0000000..23fb9f0 --- /dev/null +++ b/internal/view/component/nav/sidenav.templ @@ -0,0 +1,45 @@ +package nav + +import "git.haelnorr.com/h/oslstats/pkg/contexts" + +// Returns the mobile version of the navbar thats only visible when activated +templ sideNav(navItems []NavItem) { + {{ user := contexts.CurrentUser(ctx) }} +
+
+ +
+ if user == nil { +
+ +
+ } +
+} diff --git a/internal/view/component/popup/error500Popup.templ b/internal/view/component/popup/error500Popup.templ new file mode 100644 index 0000000..45573e0 --- /dev/null +++ b/internal/view/component/popup/error500Popup.templ @@ -0,0 +1,63 @@ +package popup + +templ Error500Popup() { +
+ +
+} diff --git a/internal/view/component/popup/error503Popup.templ b/internal/view/component/popup/error503Popup.templ new file mode 100644 index 0000000..254659d --- /dev/null +++ b/internal/view/component/popup/error503Popup.templ @@ -0,0 +1,63 @@ +package popup + +templ Error503Popup() { +
+ +
+} diff --git a/internal/view/layout/global.templ b/internal/view/layout/global.templ new file mode 100644 index 0000000..1750010 --- /dev/null +++ b/internal/view/layout/global.templ @@ -0,0 +1,97 @@ +package layout + +import "git.haelnorr.com/h/oslstats/internal/view/component/popup" +import "git.haelnorr.com/h/oslstats/internal/view/component/nav" +import "git.haelnorr.com/h/oslstats/internal/view/component/footer" + +// Global page layout. Includes HTML document settings, header tags +// navbar and footer +templ Global(title string) { + + + + + + + { title } + + + + + + + + + + + + + @popup.Error500Popup() + @popup.Error503Popup() +
+ @nav.Navbar() +
+ { children... } +
+ @footer.Footer() +
+ + +} diff --git a/internal/view/page/error.templ b/internal/view/page/error.templ new file mode 100644 index 0000000..1561478 --- /dev/null +++ b/internal/view/page/error.templ @@ -0,0 +1,34 @@ +package page + +import "git.haelnorr.com/h/oslstats/internal/view/layout" +import "strconv" + +// Page template for Error pages. Error code should be a HTTP status code as +// a string, and err should be the corresponding response title. +// Message is a custom error message displayed below the code and error. +templ Error(code int, err string, message string) { + @layout.Global(err) { +
+
+

{ strconv.Itoa(code) }

+

{ err }

+

{ message }

+ Go to homepage +
+
+ } +} diff --git a/internal/view/page/index.templ b/internal/view/page/index.templ new file mode 100644 index 0000000..c18d5ba --- /dev/null +++ b/internal/view/page/index.templ @@ -0,0 +1,13 @@ +package page + +import "git.haelnorr.com/h/oslstats/internal/view/layout" + +// Page content for the index page +templ Index() { + @layout.Global("OSL Stats") { +
+
OSL Stats
+
Placeholder text
+
+ } +} diff --git a/pkg/contexts/currentuser.go b/pkg/contexts/currentuser.go new file mode 100644 index 0000000..f522563 --- /dev/null +++ b/pkg/contexts/currentuser.go @@ -0,0 +1,8 @@ +package contexts + +import ( + "git.haelnorr.com/h/golib/hwsauth" + "git.haelnorr.com/h/oslstats/internal/db" +) + +var CurrentUser hwsauth.ContextLoader[*db.User] diff --git a/pkg/contexts/keys.go b/pkg/contexts/keys.go new file mode 100644 index 0000000..996cba7 --- /dev/null +++ b/pkg/contexts/keys.go @@ -0,0 +1,7 @@ +package contexts + +type contextKey string + +func (c contextKey) String() string { + return "oslstats context key " + string(c) +} diff --git a/pkg/embedfs/embedfs.go b/pkg/embedfs/embedfs.go new file mode 100644 index 0000000..e730359 --- /dev/null +++ b/pkg/embedfs/embedfs.go @@ -0,0 +1,20 @@ +package embedfs + +import ( + "embed" + "io/fs" + + "github.com/pkg/errors" +) + +//go:embed files/* +var embeddedFiles embed.FS + +// Gets the embedded files +func GetEmbeddedFS() (fs.FS, error) { + subFS, err := fs.Sub(embeddedFiles, "files") + if err != nil { + return nil, errors.Wrap(err, "fs.Sub") + } + return subFS, nil +} diff --git a/pkg/embedfs/files/assets/error.png b/pkg/embedfs/files/assets/error.png new file mode 100644 index 0000000000000000000000000000000000000000..d1ffa7daa50f584ab95aecbdfa1baeab58044be0 GIT binary patch literal 21893 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 :not(:last-child)) { + --tw-space-y-reverse: 0; + margin-block-start: calc(calc(var(--spacing) * 1) * var(--tw-space-y-reverse)); + margin-block-end: calc(calc(var(--spacing) * 1) * calc(1 - var(--tw-space-y-reverse))); + } + } + .divide-y { + :where(& > :not(:last-child)) { + --tw-divide-y-reverse: 0; + border-bottom-style: var(--tw-border-style); + border-top-style: var(--tw-border-style); + border-top-width: calc(1px * var(--tw-divide-y-reverse)); + border-bottom-width: calc(1px * calc(1 - var(--tw-divide-y-reverse))); + } + } + .divide-surface2 { + :where(& > :not(:last-child)) { + border-color: var(--surface2); + } + } + .overflow-hidden { + overflow: hidden; + } + .overflow-x-hidden { + overflow-x: hidden; + } + .rounded-full { + border-radius: calc(infinity * 1px); + } + .rounded-lg { + border-radius: var(--radius-lg); + } + .rounded-sm { + border-radius: var(--radius-sm); + } + .border { + border-style: var(--tw-border-style); + border-width: 1px; + } + .border-surface1 { + border-color: var(--surface1); + } + .bg-base { + background-color: var(--base); + } + .bg-blue { + background-color: var(--blue); + } + .bg-crust { + background-color: var(--crust); + } + .bg-dark-red { + background-color: var(--dark-red); + } + .bg-green { + background-color: var(--green); + } + .bg-mantle { + background-color: var(--mantle); + } + .bg-mauve { + background-color: var(--mauve); + } + .bg-sapphire { + background-color: var(--sapphire); + } + .bg-surface0 { + background-color: var(--surface0); + } + .bg-teal { + background-color: var(--teal); + } + .p-2 { + padding: calc(var(--spacing) * 2); + } + .p-2\.5 { + padding: calc(var(--spacing) * 2.5); + } + .p-4 { + padding: calc(var(--spacing) * 4); + } + .px-4 { + padding-inline: calc(var(--spacing) * 4); + } + .px-5 { + padding-inline: calc(var(--spacing) * 5); + } + .py-2 { + padding-block: calc(var(--spacing) * 2); + } + .py-3 { + padding-block: calc(var(--spacing) * 3); + } + .py-6 { + padding-block: calc(var(--spacing) * 6); + } + .py-8 { + padding-block: calc(var(--spacing) * 8); + } + .pb-6 { + padding-bottom: calc(var(--spacing) * 6); + } + .text-center { + text-align: center; + } + .text-2xl { + font-size: var(--text-2xl); + line-height: var(--tw-leading, var(--text-2xl--line-height)); + } + .text-3xl { + font-size: var(--text-3xl); + line-height: var(--tw-leading, var(--text-3xl--line-height)); + } + .text-4xl { + font-size: var(--text-4xl); + line-height: var(--tw-leading, var(--text-4xl--line-height)); + } + .text-9xl { + font-size: var(--text-9xl); + line-height: var(--tw-leading, var(--text-9xl--line-height)); + } + .text-lg { + font-size: var(--text-lg); + line-height: var(--tw-leading, var(--text-lg--line-height)); + } + .text-sm { + font-size: var(--text-sm); + line-height: var(--tw-leading, var(--text-sm--line-height)); + } + .text-xl { + font-size: var(--text-xl); + line-height: var(--tw-leading, var(--text-xl--line-height)); + } + .leading-relaxed { + --tw-leading: var(--leading-relaxed); + line-height: var(--leading-relaxed); + } + .font-bold { + --tw-font-weight: var(--font-weight-bold); + font-weight: var(--font-weight-bold); + } + .font-medium { + --tw-font-weight: var(--font-weight-medium); + font-weight: var(--font-weight-medium); + } + .tracking-tight { + --tw-tracking: var(--tracking-tight); + letter-spacing: var(--tracking-tight); + } + .text-crust { + color: var(--crust); + } + .text-mantle { + color: var(--mantle); + } + .text-overlay0 { + color: var(--overlay0); + } + .text-red { + color: var(--red); + } + .text-subtext0 { + color: var(--subtext0); + } + .text-subtext1 { + color: var(--subtext1); + } + .text-text { + color: var(--text); + } + .opacity-0 { + opacity: 0%; + } + .opacity-100 { + opacity: 100%; + } + .shadow-lg { + --tw-shadow: 0 10px 15px -3px var(--tw-shadow-color, rgb(0 0 0 / 0.1)), 0 4px 6px -4px var(--tw-shadow-color, rgb(0 0 0 / 0.1)); + box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow); + } + .shadow-sm { + --tw-shadow: 0 1px 3px 0 var(--tw-shadow-color, rgb(0 0 0 / 0.1)), 0 1px 2px -1px var(--tw-shadow-color, rgb(0 0 0 / 0.1)); + box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow); + } + .transition { + transition-property: color, background-color, border-color, outline-color, text-decoration-color, fill, stroke, --tw-gradient-from, --tw-gradient-via, --tw-gradient-to, opacity, box-shadow, transform, translate, scale, rotate, filter, -webkit-backdrop-filter, backdrop-filter, display, content-visibility, overlay, pointer-events; + transition-timing-function: var(--tw-ease, var(--default-transition-timing-function)); + transition-duration: var(--tw-duration, var(--default-transition-duration)); + } + .duration-200 { + --tw-duration: 200ms; + transition-duration: 200ms; + } + .hover\:cursor-pointer { + &:hover { + @media (hover: hover) { + cursor: pointer; + } + } + } + .hover\:bg-blue\/75 { + &:hover { + @media (hover: hover) { + background-color: var(--blue); + @supports (color: color-mix(in lab, red, red)) { + background-color: color-mix(in oklab, var(--blue) 75%, transparent); + } + } + } + } + .hover\:bg-crust { + &:hover { + @media (hover: hover) { + background-color: var(--crust); + } + } + } + .hover\:bg-green\/75 { + &:hover { + @media (hover: hover) { + background-color: var(--green); + @supports (color: color-mix(in lab, red, red)) { + background-color: color-mix(in oklab, var(--green) 75%, transparent); + } + } + } + } + .hover\:bg-mauve\/75 { + &:hover { + @media (hover: hover) { + background-color: var(--mauve); + @supports (color: color-mix(in lab, red, red)) { + background-color: color-mix(in oklab, var(--mauve) 75%, transparent); + } + } + } + } + .hover\:bg-red\/25 { + &:hover { + @media (hover: hover) { + background-color: var(--red); + @supports (color: color-mix(in lab, red, red)) { + background-color: color-mix(in oklab, var(--red) 25%, transparent); + } + } + } + } + .hover\:bg-sapphire\/75 { + &:hover { + @media (hover: hover) { + background-color: var(--sapphire); + @supports (color: color-mix(in lab, red, red)) { + background-color: color-mix(in oklab, var(--sapphire) 75%, transparent); + } + } + } + } + .hover\:bg-surface2 { + &:hover { + @media (hover: hover) { + background-color: var(--surface2); + } + } + } + .hover\:bg-teal\/75 { + &:hover { + @media (hover: hover) { + background-color: var(--teal); + @supports (color: color-mix(in lab, red, red)) { + background-color: color-mix(in oklab, var(--teal) 75%, transparent); + } + } + } + } + .hover\:text-green { + &:hover { + @media (hover: hover) { + color: var(--green); + } + } + } + .hover\:text-overlay2\/75 { + &:hover { + @media (hover: hover) { + color: var(--overlay2); + @supports (color: color-mix(in lab, red, red)) { + color: color-mix(in oklab, var(--overlay2) 75%, transparent); + } + } + } + } + .hover\:text-subtext1 { + &:hover { + @media (hover: hover) { + color: var(--subtext1); + } + } + } + .sm\:end-6 { + @media (width >= 40rem) { + inset-inline-end: calc(var(--spacing) * 6); + } + } + .sm\:block { + @media (width >= 40rem) { + display: block; + } + } + .sm\:flex { + @media (width >= 40rem) { + display: flex; + } + } + .sm\:hidden { + @media (width >= 40rem) { + display: none; + } + } + .sm\:inline { + @media (width >= 40rem) { + display: inline; + } + } + .sm\:justify-between { + @media (width >= 40rem) { + justify-content: space-between; + } + } + .sm\:gap-2 { + @media (width >= 40rem) { + gap: calc(var(--spacing) * 2); + } + } + .sm\:px-6 { + @media (width >= 40rem) { + padding-inline: calc(var(--spacing) * 6); + } + } + .sm\:text-4xl { + @media (width >= 40rem) { + font-size: var(--text-4xl); + line-height: var(--tw-leading, var(--text-4xl--line-height)); + } + } + .md\:gap-8 { + @media (width >= 48rem) { + gap: calc(var(--spacing) * 8); + } + } + .md\:px-5 { + @media (width >= 48rem) { + padding-inline: calc(var(--spacing) * 5); + } + } + .md\:pt-5 { + @media (width >= 48rem) { + padding-top: calc(var(--spacing) * 5); + } + } + .lg\:end-8 { + @media (width >= 64rem) { + inset-inline-end: calc(var(--spacing) * 8); + } + } + .lg\:mt-0 { + @media (width >= 64rem) { + margin-top: calc(var(--spacing) * 0); + } + } + .lg\:flex { + @media (width >= 64rem) { + display: flex; + } + } + .lg\:inline { + @media (width >= 64rem) { + display: inline; + } + } + .lg\:items-end { + @media (width >= 64rem) { + align-items: flex-end; + } + } + .lg\:justify-between { + @media (width >= 64rem) { + justify-content: space-between; + } + } + .lg\:justify-end { + @media (width >= 64rem) { + justify-content: flex-end; + } + } + .lg\:justify-start { + @media (width >= 64rem) { + justify-content: flex-start; + } + } + .lg\:gap-12 { + @media (width >= 64rem) { + gap: calc(var(--spacing) * 12); + } + } + .lg\:px-8 { + @media (width >= 64rem) { + padding-inline: calc(var(--spacing) * 8); + } + } + .lg\:text-6xl { + @media (width >= 64rem) { + font-size: var(--text-6xl); + line-height: var(--tw-leading, var(--text-6xl--line-height)); + } + } +} +[x-cloak] { + display: none !important; +} +:root { + --rosewater: hsl(11, 59%, 67%); + --flamingo: hsl(0, 60%, 67%); + --pink: hsl(316, 73%, 69%); + --mauve: hsl(266, 85%, 58%); + --red: hsl(347, 87%, 44%); + --dark-red: hsl(343, 50%, 82%); + --maroon: hsl(355, 76%, 59%); + --peach: hsl(22, 99%, 52%); + --yellow: hsl(35, 77%, 49%); + --green: hsl(109, 58%, 40%); + --teal: hsl(183, 74%, 35%); + --sky: hsl(197, 97%, 46%); + --sapphire: hsl(189, 70%, 42%); + --blue: hsl(220, 91%, 54%); + --lavender: hsl(231, 97%, 72%); + --text: hsl(234, 16%, 35%); + --subtext1: hsl(233, 13%, 41%); + --subtext0: hsl(233, 10%, 47%); + --overlay2: hsl(232, 10%, 53%); + --overlay1: hsl(231, 10%, 59%); + --overlay0: hsl(228, 11%, 65%); + --surface2: hsl(227, 12%, 71%); + --surface1: hsl(225, 14%, 77%); + --surface0: hsl(223, 16%, 83%); + --base: hsl(220, 23%, 95%); + --mantle: hsl(220, 22%, 92%); + --crust: hsl(220, 21%, 89%); +} +.dark { + --rosewater: hsl(10, 56%, 91%); + --flamingo: hsl(0, 59%, 88%); + --pink: hsl(316, 72%, 86%); + --mauve: hsl(267, 84%, 81%); + --red: hsl(343, 81%, 75%); + --dark-red: hsl(316, 19%, 27%); + --maroon: hsl(350, 65%, 77%); + --peach: hsl(23, 92%, 75%); + --yellow: hsl(41, 86%, 83%); + --green: hsl(115, 54%, 76%); + --teal: hsl(170, 57%, 73%); + --sky: hsl(189, 71%, 73%); + --sapphire: hsl(199, 76%, 69%); + --blue: hsl(217, 92%, 76%); + --lavender: hsl(232, 97%, 85%); + --text: hsl(226, 64%, 88%); + --subtext1: hsl(227, 35%, 80%); + --subtext0: hsl(228, 24%, 72%); + --overlay2: hsl(228, 17%, 64%); + --overlay1: hsl(230, 13%, 55%); + --overlay0: hsl(231, 11%, 47%); + --surface2: hsl(233, 12%, 39%); + --surface1: hsl(234, 13%, 31%); + --surface0: hsl(237, 16%, 23%); + --base: hsl(240, 21%, 15%); + --mantle: hsl(240, 21%, 12%); + --crust: hsl(240, 23%, 9%); +} +.ubuntu-mono-regular { + font-family: "Ubuntu Mono", serif; + font-weight: 400; + font-style: normal; +} +.ubuntu-mono-bold { + font-family: "Ubuntu Mono", serif; + font-weight: 700; + font-style: normal; +} +.ubuntu-mono-regular-italic { + font-family: "Ubuntu Mono", serif; + font-weight: 400; + font-style: italic; +} +.ubuntu-mono-bold-italic { + font-family: "Ubuntu Mono", serif; + font-weight: 700; + font-style: italic; +} +@property --tw-translate-x { + syntax: "*"; + inherits: false; + initial-value: 0; +} +@property --tw-translate-y { + syntax: "*"; + inherits: false; + initial-value: 0; +} +@property --tw-translate-z { + syntax: "*"; + inherits: false; + initial-value: 0; +} +@property --tw-rotate-x { + syntax: "*"; + inherits: false; +} +@property --tw-rotate-y { + syntax: "*"; + inherits: false; +} +@property --tw-rotate-z { + syntax: "*"; + inherits: false; +} +@property --tw-skew-x { + syntax: "*"; + inherits: false; +} +@property --tw-skew-y { + syntax: "*"; + inherits: false; +} +@property --tw-space-y-reverse { + syntax: "*"; + inherits: false; + initial-value: 0; +} +@property --tw-divide-y-reverse { + syntax: "*"; + inherits: false; + initial-value: 0; +} +@property --tw-border-style { + syntax: "*"; + inherits: false; + initial-value: solid; +} +@property --tw-leading { + syntax: "*"; + inherits: false; +} +@property --tw-font-weight { + syntax: "*"; + inherits: false; +} +@property --tw-tracking { + syntax: "*"; + inherits: false; +} +@property --tw-shadow { + syntax: "*"; + inherits: false; + initial-value: 0 0 #0000; +} +@property --tw-shadow-color { + syntax: "*"; + inherits: false; +} +@property --tw-shadow-alpha { + syntax: ""; + inherits: false; + initial-value: 100%; +} +@property --tw-inset-shadow { + syntax: "*"; + inherits: false; + initial-value: 0 0 #0000; +} +@property --tw-inset-shadow-color { + syntax: "*"; + inherits: false; +} +@property --tw-inset-shadow-alpha { + syntax: ""; + inherits: false; + initial-value: 100%; +} +@property --tw-ring-color { + syntax: "*"; + inherits: false; +} +@property --tw-ring-shadow { + syntax: "*"; + inherits: false; + initial-value: 0 0 #0000; +} +@property --tw-inset-ring-color { + syntax: "*"; + inherits: false; +} +@property --tw-inset-ring-shadow { + syntax: "*"; + inherits: false; + initial-value: 0 0 #0000; +} +@property --tw-ring-inset { + syntax: "*"; + inherits: false; +} +@property --tw-ring-offset-width { + syntax: ""; + inherits: false; + initial-value: 0px; +} +@property --tw-ring-offset-color { + syntax: "*"; + inherits: false; + initial-value: #fff; +} +@property --tw-ring-offset-shadow { + syntax: "*"; + inherits: false; + initial-value: 0 0 #0000; +} +@property --tw-duration { + syntax: "*"; + inherits: false; +} +@layer properties { + @supports ((-webkit-hyphens: none) and (not (margin-trim: inline))) or ((-moz-orient: inline) and (not (color:rgb(from red r g b)))) { + *, ::before, ::after, ::backdrop { + --tw-translate-x: 0; + --tw-translate-y: 0; + --tw-translate-z: 0; + --tw-rotate-x: initial; + --tw-rotate-y: initial; + --tw-rotate-z: initial; + --tw-skew-x: initial; + --tw-skew-y: initial; + --tw-space-y-reverse: 0; + --tw-divide-y-reverse: 0; + --tw-border-style: solid; + --tw-leading: initial; + --tw-font-weight: initial; + --tw-tracking: initial; + --tw-shadow: 0 0 #0000; + --tw-shadow-color: initial; + --tw-shadow-alpha: 100%; + --tw-inset-shadow: 0 0 #0000; + --tw-inset-shadow-color: initial; + --tw-inset-shadow-alpha: 100%; + --tw-ring-color: initial; + --tw-ring-shadow: 0 0 #0000; + --tw-inset-ring-color: initial; + --tw-inset-ring-shadow: 0 0 #0000; + --tw-ring-inset: initial; + --tw-ring-offset-width: 0px; + --tw-ring-offset-color: #fff; + --tw-ring-offset-shadow: 0 0 #0000; + --tw-duration: initial; + } + } +} diff --git a/pkg/embedfs/files/favicon.ico b/pkg/embedfs/files/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..f052478ecf7f97130fc4544300f1d43b16912fde GIT binary patch literal 834 zcmV-I1HJr-P)q(n>kO!KUmw{ z%xSxKGo5$TX;wn&?kz;A>|R0|l@ZdPhs2jKF#3ZJVMh7jlSR-&R+QL75IvNsOw8u$ zdfc71=h}-u__D*ykKcUe_xsJTg0OB`tRz79crdMmm>dvAF)y8uPYi8=_;@K79}BBt zUKH64Fnt9vWtIRUQvefe`4mgiCWy#2fa&a_ADMxeUgc}fH}}i=0MoDb&6j}8LqJ3Z zGW*c`jsXB7H!}scG64}O&*sDB4G`!E0Af061&OPjY-soc>*^g$-C)cbSCIzx;PE&c zcr?x`X@d=vUUrrNOnzBGx_bxN$mkRs8lFhW0{w1{>zX@EAFk-Rc6%A_I?Di}udN_r z^sIUMlS(obpl>17!3b;X?qh9T_sp@aR0*dUIFq#pFd6aIkX;aw-&vY)+Ji$o9RU;< zYa0O~4>JUx^!RDvUr-tah|HyF(m5}t5l5TbAt0is0Vd=8G!WCz0Mi~22ya_bt^hp* zF?k9x`N0J+ec}m{gF-G=qZwNz)p#qb26HBTR#uD~>0+1o>k5_ykQJla`R0od_LyEw z_d`VQ@RxFVfP^mQ4qz}%(>Cx23n2R3-Xl2y9ErEEib!KxrTPL)-luEioB%2!jTx_J zs&6L0bpRok65yuu%-@(ut2HTs{138Uz5fCr*d8B+ll}}%B6jhV$9_T@Fq2` zFBsg+pLby;c1+XvnwgkaIjO&0D#c#-q}a={aQtIgxc*aFDE^MmaUHI2ZdveGjOtMH z#nt&awG3j~pZyn%h=QBuLE)mRSkrZ@ zL9(5`R@IB_YqF~MxSj<`)jMUy=$BRFLh72_n#?!XxDvX%H^D9Y14dl;BI9vo!T "$INPUT_CSS" <<'CSSHEAD' +@import "tailwindcss"; + +CSSHEAD + +# Find all .templ files and add @source directives +find internal -name "*.templ" -type f | sort | while read -r file; do + # Convert to relative path from pkg/embedfs/files/css/ + rel_path="../../../../$file" + echo "@source \"$rel_path\";" >> "$INPUT_CSS" +done + +# Add the custom theme and utility classes +cat >> "$INPUT_CSS" <<'CSSBODY' + +[x-cloak] { + display: none !important; +} +@theme inline { + --color-rosewater: var(--rosewater); + --color-flamingo: var(--flamingo); + --color-pink: var(--pink); + --color-mauve: var(--mauve); + --color-red: var(--red); + --color-dark-red: var(--dark-red); + --color-maroon: var(--maroon); + --color-peach: var(--peach); + --color-yellow: var(--yellow); + --color-green: var(--green); + --color-teal: var(--teal); + --color-sky: var(--sky); + --color-sapphire: var(--sapphire); + --color-blue: var(--blue); + --color-lavender: var(--lavender); + --color-text: var(--text); + --color-subtext1: var(--subtext1); + --color-subtext0: var(--subtext0); + --color-overlay2: var(--overlay2); + --color-overlay1: var(--overlay1); + --color-overlay0: var(--overlay0); + --color-surface2: var(--surface2); + --color-surface1: var(--surface1); + --color-surface0: var(--surface0); + --color-base: var(--base); + --color-mantle: var(--mantle); + --color-crust: var(--crust); +} +:root { + --rosewater: hsl(11, 59%, 67%); + --flamingo: hsl(0, 60%, 67%); + --pink: hsl(316, 73%, 69%); + --mauve: hsl(266, 85%, 58%); + --red: hsl(347, 87%, 44%); + --dark-red: hsl(343, 50%, 82%); + --maroon: hsl(355, 76%, 59%); + --peach: hsl(22, 99%, 52%); + --yellow: hsl(35, 77%, 49%); + --green: hsl(109, 58%, 40%); + --teal: hsl(183, 74%, 35%); + --sky: hsl(197, 97%, 46%); + --sapphire: hsl(189, 70%, 42%); + --blue: hsl(220, 91%, 54%); + --lavender: hsl(231, 97%, 72%); + --text: hsl(234, 16%, 35%); + --subtext1: hsl(233, 13%, 41%); + --subtext0: hsl(233, 10%, 47%); + --overlay2: hsl(232, 10%, 53%); + --overlay1: hsl(231, 10%, 59%); + --overlay0: hsl(228, 11%, 65%); + --surface2: hsl(227, 12%, 71%); + --surface1: hsl(225, 14%, 77%); + --surface0: hsl(223, 16%, 83%); + --base: hsl(220, 23%, 95%); + --mantle: hsl(220, 22%, 92%); + --crust: hsl(220, 21%, 89%); +} + +.dark { + --rosewater: hsl(10, 56%, 91%); + --flamingo: hsl(0, 59%, 88%); + --pink: hsl(316, 72%, 86%); + --mauve: hsl(267, 84%, 81%); + --red: hsl(343, 81%, 75%); + --dark-red: hsl(316, 19%, 27%); + --maroon: hsl(350, 65%, 77%); + --peach: hsl(23, 92%, 75%); + --yellow: hsl(41, 86%, 83%); + --green: hsl(115, 54%, 76%); + --teal: hsl(170, 57%, 73%); + --sky: hsl(189, 71%, 73%); + --sapphire: hsl(199, 76%, 69%); + --blue: hsl(217, 92%, 76%); + --lavender: hsl(232, 97%, 85%); + --text: hsl(226, 64%, 88%); + --subtext1: hsl(227, 35%, 80%); + --subtext0: hsl(228, 24%, 72%); + --overlay2: hsl(228, 17%, 64%); + --overlay1: hsl(230, 13%, 55%); + --overlay0: hsl(231, 11%, 47%); + --surface2: hsl(233, 12%, 39%); + --surface1: hsl(234, 13%, 31%); + --surface0: hsl(237, 16%, 23%); + --base: hsl(240, 21%, 15%); + --mantle: hsl(240, 21%, 12%); + --crust: hsl(240, 23%, 9%); +} +.ubuntu-mono-regular { + font-family: "Ubuntu Mono", serif; + font-weight: 400; + font-style: normal; +} + +.ubuntu-mono-bold { + font-family: "Ubuntu Mono", serif; + font-weight: 700; + font-style: normal; +} + +.ubuntu-mono-regular-italic { + font-family: "Ubuntu Mono", serif; + font-weight: 400; + font-style: italic; +} + +.ubuntu-mono-bold-italic { + font-family: "Ubuntu Mono", serif; + font-weight: 700; + font-style: italic; +} +CSSBODY + +echo "Generated $INPUT_CSS with $(grep -c '@source' "$INPUT_CSS") source files"