From 1eedbc5220eef6d654d2fe398f4995a5947f9ecb Mon Sep 17 00:00:00 2001 From: Haelnorr Date: Sun, 11 Jan 2026 23:39:10 +1100 Subject: [PATCH] updated to use bun and updated hws modules. --- cmd/projectreshoot/auth.go | 29 ++--- cmd/projectreshoot/db.go | 52 +++++++++ cmd/projectreshoot/dbconn.go | 53 --------- cmd/projectreshoot/flags.go | 20 ++-- cmd/projectreshoot/httpserver.go | 19 +--- cmd/projectreshoot/logger.go | 15 +-- cmd/projectreshoot/main.go | 23 +++- cmd/projectreshoot/middleware.go | 8 +- cmd/projectreshoot/routes.go | 35 +++--- cmd/projectreshoot/run.go | 33 ++---- go.mod | 32 +++--- go.sum | 52 ++++++--- internal/config/auth.go | 37 ++++++ internal/config/config.go | 133 ++++++---------------- internal/config/db.go | 55 +++++++++ internal/config/envdoc.go | 114 +++++++++++++++++++ internal/config/envgen.go | 95 ++++++++++++++++ internal/config/envprint.go | 87 ++++++++++++++ internal/config/httpserver.go | 29 +++++ internal/config/logger.go | 36 ++++++ internal/config/tmdb.go | 28 +++++ internal/handler/account.go | 33 +++--- internal/handler/login.go | 28 +++-- internal/handler/logout.go | 13 ++- internal/handler/movie.go | 8 +- internal/handler/movie_search.go | 6 +- internal/handler/reauthenticatate.go | 22 ++-- internal/handler/register.go | 59 ++++++---- internal/models/user.go | 2 +- internal/models/user_bun.go | 163 +++++++++++++++++++++++++++ internal/models/user_functions.go | 7 +- pkg/contexts/currentuser.go | 2 +- pkg/embedfs/files/css/output.css | 31 +---- 33 files changed, 984 insertions(+), 375 deletions(-) create mode 100644 cmd/projectreshoot/db.go delete mode 100644 cmd/projectreshoot/dbconn.go create mode 100644 internal/config/auth.go create mode 100644 internal/config/db.go create mode 100644 internal/config/envdoc.go create mode 100644 internal/config/envgen.go create mode 100644 internal/config/envprint.go create mode 100644 internal/config/httpserver.go create mode 100644 internal/config/logger.go create mode 100644 internal/config/tmdb.go create mode 100644 internal/models/user_bun.go diff --git a/cmd/projectreshoot/auth.go b/cmd/projectreshoot/auth.go index bd267fb..dd9437d 100644 --- a/cmd/projectreshoot/auth.go +++ b/cmd/projectreshoot/auth.go @@ -1,7 +1,7 @@ package main import ( - "database/sql" + "context" "projectreshoot/internal/config" "projectreshoot/internal/handler" "projectreshoot/internal/models" @@ -11,19 +11,25 @@ import ( "git.haelnorr.com/h/golib/hws" "git.haelnorr.com/h/golib/hwsauth" "github.com/pkg/errors" + "github.com/uptrace/bun" ) func setupAuth( config *config.Config, logger *hlog.Logger, - conn *sql.DB, + db *bun.DB, server *hws.Server, ignoredPaths []string, -) (*hwsauth.Authenticator[*models.User], error) { +) (*hwsauth.Authenticator[*models.UserBun, bun.Tx], error) { + beginTx := func(ctx context.Context) (hwsauth.DBTransaction, error) { + tx, err := db.BeginTx(ctx, nil) + return tx, err + } auth, err := hwsauth.NewAuthenticator( - models.GetUserFromID, + config.HWSAuth, + models.GetUserByID, server, - conn, + beginTx, logger, handler.ErrorPage, ) @@ -31,21 +37,8 @@ func setupAuth( return nil, errors.Wrap(err, "hwsauth.NewAuthenticator") } - auth.SSL = config.SSL - auth.AccessTokenExpiry = config.AccessTokenExpiry - auth.RefreshTokenExpiry = config.RefreshTokenExpiry - auth.TokenFreshTime = config.TokenFreshTime - auth.TrustedHost = config.TrustedHost - auth.SecretKey = config.SecretKey - auth.LandingPage = "/profile" - auth.IgnorePaths(ignoredPaths...) - err = auth.Initialise() - if err != nil { - return nil, errors.Wrap(err, "auth.Initialise") - } - contexts.CurrentUser = auth.CurrentModel return auth, nil diff --git a/cmd/projectreshoot/db.go b/cmd/projectreshoot/db.go new file mode 100644 index 0000000..7d9a29d --- /dev/null +++ b/cmd/projectreshoot/db.go @@ -0,0 +1,52 @@ +package main + +import ( + "context" + "database/sql" + "fmt" + "projectreshoot/internal/config" + "projectreshoot/internal/models" + + "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.DBConfig, resetDB bool) (db *bun.DB, close func() error, err error) { + dsn := fmt.Sprintf("postgres://%s:%s@%s:%v/%s?sslmode=%s", cfg.User, cfg.Password, cfg.Host, cfg.Port, cfg.DB, cfg.SSL) + sqldb := sql.OpenDB(pgdriver.NewConnector(pgdriver.WithDSN(dsn))) + db = bun.NewDB(sqldb, pgdialect.New()) + close = sqldb.Close + + err = loadModels(ctx, db, resetDB) + if err != nil { + return nil, nil, errors.Wrap(err, "loadModels") + } + + return db, close, nil +} + +func loadModels(ctx context.Context, db *bun.DB, resetDB bool) error { + models := []any{ + (*models.UserBun)(nil), + } + + for _, model := range models { + _, err := db.NewCreateTable(). + Model(model). + IfNotExists(). + Exec(ctx) + if err != nil { + return errors.Wrap(err, "db.NewCreateTable") + } + if resetDB { + err = db.ResetModel(ctx, model) + if err != nil { + return errors.Wrap(err, "db.ResetModel") + } + } + } + + return nil +} diff --git a/cmd/projectreshoot/dbconn.go b/cmd/projectreshoot/dbconn.go deleted file mode 100644 index c6b800d..0000000 --- a/cmd/projectreshoot/dbconn.go +++ /dev/null @@ -1,53 +0,0 @@ -package main - -import ( - "database/sql" - "fmt" - "strconv" - - "github.com/pkg/errors" - - _ "github.com/mattn/go-sqlite3" -) - -func setupDBConn(dbName string) (*sql.DB, error) { - opts := "_journal_mode=WAL&_synchronous=NORMAL&_txlock=IMMEDIATE" - file := fmt.Sprintf("file:%s.db?%s", dbName, opts) - conn, err := sql.Open("sqlite3", file) - if err != nil { - return nil, errors.Wrap(err, "sql.Open") - } - err = checkDBVersion(conn, dbName) - if err != nil { - return nil, errors.Wrap(err, "checkDBVersion") - } - return conn, nil -} - -// Check the database version -func checkDBVersion(db *sql.DB, dbName string) error { - expectVer, err := strconv.Atoi(dbName) - if err != nil { - return errors.Wrap(err, "strconv.Atoi") - } - query := `SELECT version_id FROM goose_db_version WHERE is_applied = 1 - ORDER BY version_id DESC LIMIT 1` - rows, err := db.Query(query) - if err != nil { - return errors.Wrap(err, "db.Query") - } - defer rows.Close() - if rows.Next() { - var version int - err = rows.Scan(&version) - if err != nil { - return errors.Wrap(err, "rows.Scan") - } - if version != expectVer { - return errors.New("Version mismatch") - } - } else { - return errors.New("No version found") - } - return nil -} diff --git a/cmd/projectreshoot/flags.go b/cmd/projectreshoot/flags.go index 4ad83cf..2bf3d22 100644 --- a/cmd/projectreshoot/flags.go +++ b/cmd/projectreshoot/flags.go @@ -7,22 +7,18 @@ import ( func setupFlags() map[string]string { // Parse commandline args - host := flag.String("host", "", "Override host to listen on") - port := flag.String("port", "", "Override port to listen on") - test := flag.Bool("test", false, "Run server in test mode") - dbver := flag.Bool("dbver", false, "Get the version of the database required") - loglevel := flag.String("loglevel", "", "Set log level") - logoutput := flag.String("logoutput", "", "Set log destination (file, console or both)") + resetDB := flag.Bool("resetdb", false, "Reset all the database tables with the updated models") + printEnv := flag.Bool("printenv", false, "Print all environment variables 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() // Map the args for easy access args := map[string]string{ - "host": *host, - "port": *port, - "test": strconv.FormatBool(*test), - "dbver": strconv.FormatBool(*dbver), - "loglevel": *loglevel, - "logoutput": *logoutput, + "resetdb": strconv.FormatBool(*resetDB), + "printenv": strconv.FormatBool(*printEnv), + "genenv": *genEnv, + "envfile": *envfile, } return args } diff --git a/cmd/projectreshoot/httpserver.go b/cmd/projectreshoot/httpserver.go index ab1ce4d..cc6b84b 100644 --- a/cmd/projectreshoot/httpserver.go +++ b/cmd/projectreshoot/httpserver.go @@ -1,7 +1,6 @@ package main import ( - "database/sql" "io/fs" "net/http" "projectreshoot/internal/config" @@ -10,39 +9,31 @@ import ( "git.haelnorr.com/h/golib/hws" "git.haelnorr.com/h/golib/hlog" - "git.haelnorr.com/h/golib/jwt" "github.com/pkg/errors" + "github.com/uptrace/bun" ) func setupHttpServer( staticFS *fs.FS, config *config.Config, logger *hlog.Logger, - conn *sql.DB, - tokenGen *jwt.TokenGenerator, + 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.Host, - config.Port, - ) + httpServer, err := hws.NewServer(config.HWS) if err != nil { return nil, errors.Wrap(err, "hws.NewServer") } - httpServer.ReadHeaderTimeout(config.ReadHeaderTimeout) - httpServer.WriteTimeout(config.WriteTimeout) - httpServer.IdleTimeout(config.IdleTimeout) - httpServer.GZIP = config.GZIP ignoredPaths := []string{ "/static/css/output.css", "/static/favicon.ico", } - auth, err := setupAuth(config, logger, conn, httpServer, ignoredPaths) + auth, err := setupAuth(config, logger, bun, httpServer, ignoredPaths) if err != nil { return nil, errors.Wrap(err, "setupAuth") } @@ -62,7 +53,7 @@ func setupHttpServer( return nil, errors.Wrap(err, "httpServer.LoggerIgnorePaths") } - err = addRoutes(httpServer, &fs, config, logger, conn, tokenGen, auth) + err = addRoutes(httpServer, &fs, config, logger, bun, auth) if err != nil { return nil, errors.Wrap(err, "addRoutes") } diff --git a/cmd/projectreshoot/logger.go b/cmd/projectreshoot/logger.go index 9876890..43f0b6d 100644 --- a/cmd/projectreshoot/logger.go +++ b/cmd/projectreshoot/logger.go @@ -3,17 +3,18 @@ package main import ( "io" "os" + "projectreshoot/internal/config" "git.haelnorr.com/h/golib/hlog" "github.com/pkg/errors" ) // Take in the desired logOutput and a console writer to use -func setupLogger(logLevel hlog.Level, logOutput string, w *io.Writer, logDirectory string) (*hlog.Logger, error) { +func setupLogger(cfg *config.HLOGConfig, w *io.Writer) (*hlog.Logger, error) { // Setup the logfile var logfile *os.File = nil - if logOutput == "both" || logOutput == "file" { - logfile, err := hlog.NewLogFile(logDirectory) + if cfg.LogOutput == "both" || cfg.LogOutput == "file" { + logfile, err := hlog.NewLogFile(cfg.LogDir) if err != nil { return nil, errors.Wrap(err, "hlog") } @@ -22,11 +23,11 @@ func setupLogger(logLevel hlog.Level, logOutput string, w *io.Writer, logDirecto // Setup the console writer var consoleWriter io.Writer - if logOutput == "both" || logOutput == "console" { + if cfg.LogOutput == "both" || cfg.LogOutput == "console" { if w != nil { consoleWriter = *w } else { - if logOutput == "console" { + if cfg.LogOutput == "console" { return nil, errors.New("Console logging specified as sole method but no writer provided") } } @@ -34,10 +35,10 @@ func setupLogger(logLevel hlog.Level, logOutput string, w *io.Writer, logDirecto // Setup the logger logger, err := hlog.NewLogger( - logLevel, + cfg.LogLevel, consoleWriter, logfile, - logDirectory, + cfg.LogDir, ) if err != nil { return nil, errors.Wrap(err, "hlog") diff --git a/cmd/projectreshoot/main.go b/cmd/projectreshoot/main.go index 504ebb3..653257b 100644 --- a/cmd/projectreshoot/main.go +++ b/cmd/projectreshoot/main.go @@ -13,13 +13,32 @@ func main() { args := setupFlags() ctx := context.Background() - config, err := config.GetConfig(args) + // Handle printenv flag + if args["printenv"] == "true" { + if err := config.PrintEnvVars(os.Stdout); err != nil { + fmt.Fprintf(os.Stderr, "Failed to print environment variables: %s\n", err) + os.Exit(1) + } + return + } + + // Handle genenv flag + if args["genenv"] != "" { + if err := config.GenerateDotEnv(args["genenv"]); err != nil { + fmt.Fprintf(os.Stderr, "Failed to generate .env file: %s\n", err) + os.Exit(1) + } + fmt.Printf("Successfully generated .env file: %s\n", args["genenv"]) + return + } + + cfg, err := config.GetConfig(args["envfile"]) if err != nil { fmt.Fprintf(os.Stderr, "%s\n", errors.Wrap(err, "Failed to load config")) os.Exit(1) } - if err := run(ctx, os.Stdout, args, config); err != nil { + if err := run(ctx, os.Stdout, args, cfg); err != nil { fmt.Fprintf(os.Stderr, "%s\n", err) os.Exit(1) } diff --git a/cmd/projectreshoot/middleware.go b/cmd/projectreshoot/middleware.go index ca0c846..97b4f92 100644 --- a/cmd/projectreshoot/middleware.go +++ b/cmd/projectreshoot/middleware.go @@ -1,16 +1,18 @@ package main import ( - "git.haelnorr.com/h/golib/hws" - "git.haelnorr.com/h/golib/hwsauth" "projectreshoot/internal/models" + "git.haelnorr.com/h/golib/hws" + "git.haelnorr.com/h/golib/hwsauth" + "github.com/pkg/errors" + "github.com/uptrace/bun" ) func addMiddleware( server *hws.Server, - auth *hwsauth.Authenticator[*models.User], + auth *hwsauth.Authenticator[*models.UserBun, bun.Tx], ) error { err := server.AddMiddleware( diff --git a/cmd/projectreshoot/routes.go b/cmd/projectreshoot/routes.go index 8f75fb3..5082281 100644 --- a/cmd/projectreshoot/routes.go +++ b/cmd/projectreshoot/routes.go @@ -1,18 +1,18 @@ package main import ( - "database/sql" - "git.haelnorr.com/h/golib/hws" - "git.haelnorr.com/h/golib/hwsauth" "net/http" "projectreshoot/internal/config" "projectreshoot/internal/handler" "projectreshoot/internal/models" "projectreshoot/internal/view/page" + "git.haelnorr.com/h/golib/hws" + "git.haelnorr.com/h/golib/hwsauth" + "git.haelnorr.com/h/golib/hlog" - "git.haelnorr.com/h/golib/jwt" "github.com/pkg/errors" + "github.com/uptrace/bun" ) func addRoutes( @@ -20,9 +20,8 @@ func addRoutes( staticFS *http.FileSystem, config *config.Config, logger *hlog.Logger, - conn *sql.DB, - tokenGen *jwt.TokenGenerator, - auth *hwsauth.Authenticator[*models.User], + db *bun.DB, + auth *hwsauth.Authenticator[*models.UserBun, bun.Tx], ) error { // Create the routes routes := []hws.Route{ @@ -44,32 +43,32 @@ func addRoutes( { Path: "/login", Method: hws.MethodGET, - Handler: auth.LogoutReq(handler.LoginPage(config.TrustedHost)), + Handler: auth.LogoutReq(handler.LoginPage(config.HWSAuth.TrustedHost)), }, { Path: "/login", Method: hws.MethodPOST, - Handler: auth.LogoutReq(handler.LoginRequest(server, auth, conn)), + Handler: auth.LogoutReq(handler.LoginRequest(server, auth, db)), }, { Path: "/register", Method: hws.MethodGET, - Handler: auth.LogoutReq(handler.RegisterPage(config.TrustedHost)), + Handler: auth.LogoutReq(handler.RegisterPage(config.HWSAuth.TrustedHost)), }, { Path: "/register", Method: hws.MethodPOST, - Handler: auth.LogoutReq(handler.RegisterRequest(config, logger, conn, tokenGen)), + Handler: auth.LogoutReq(handler.RegisterRequest(server, auth, db)), }, { Path: "/logout", Method: hws.MethodPOST, - Handler: handler.Logout(server, auth, conn), + Handler: handler.Logout(server, auth, db), }, { Path: "/reauthenticate", Method: hws.MethodPOST, - Handler: auth.LoginReq(handler.Reauthenticate(server, auth, conn)), + Handler: auth.LoginReq(handler.Reauthenticate(server, auth, db)), }, { Path: "/profile", @@ -89,17 +88,17 @@ func addRoutes( { Path: "/change-username", Method: hws.MethodPOST, - Handler: auth.LoginReq(auth.FreshReq(handler.ChangeUsername(server, auth, conn))), + Handler: auth.LoginReq(auth.FreshReq(handler.ChangeUsername(server, auth, db))), }, { Path: "/change-password", Method: hws.MethodPOST, - Handler: auth.LoginReq(auth.FreshReq(handler.ChangePassword(server, auth, conn))), + Handler: auth.LoginReq(auth.FreshReq(handler.ChangePassword(server, auth, db))), }, { Path: "/change-bio", Method: hws.MethodPOST, - Handler: auth.LoginReq(handler.ChangeBio(server, auth, conn)), + Handler: auth.LoginReq(handler.ChangeBio(server, auth, db)), }, { Path: "/movies", @@ -109,12 +108,12 @@ func addRoutes( { Path: "/search-movies", Method: hws.MethodPOST, - Handler: handler.SearchMovies(config, logger), + Handler: handler.SearchMovies(config.TMDB, logger), }, { Path: "/movie/{movie_id}", Method: hws.MethodGET, - Handler: handler.Movie(server, config), + Handler: handler.Movie(server, config.TMDB), }, } diff --git a/cmd/projectreshoot/run.go b/cmd/projectreshoot/run.go index 5e51ecc..bbfe8a5 100644 --- a/cmd/projectreshoot/run.go +++ b/cmd/projectreshoot/run.go @@ -2,17 +2,15 @@ package main import ( "context" - "fmt" "io" "os" "os/signal" "projectreshoot/internal/config" "projectreshoot/pkg/embedfs" + "strconv" "sync" "time" - "git.haelnorr.com/h/golib/jwt" - "github.com/pkg/errors" ) @@ -21,13 +19,7 @@ func run(ctx context.Context, w io.Writer, args map[string]string, config *confi ctx, cancel := signal.NotifyContext(ctx, os.Interrupt) defer cancel() - // Return the version of the database required - if args["dbver"] == "true" { - fmt.Fprintf(w, "Database version: %s\n", config.DBName) - return nil - } - - logger, err := setupLogger(config.LogLevel, config.LogOutput, &w, config.LogDir) + logger, err := setupLogger(config.HLOG, &w) if err != nil { return errors.Wrap(err, "setupLogger") } @@ -35,11 +27,15 @@ func run(ctx context.Context, w io.Writer, args map[string]string, config *confi // Setup the database connection logger.Debug().Msg("Config loaded and logger started") logger.Debug().Msg("Connecting to database") - conn, err := setupDBConn(config.DBName) + resetdb, err := strconv.ParseBool(args["resetdb"]) + if err != nil { + return errors.Wrap(err, "strconv.ParseBool") + } + bun, closedb, err := setupBun(ctx, config.DB, resetdb) if err != nil { return errors.Wrap(err, "setupDBConn") } - defer conn.Close() + defer closedb() // Setup embedded files logger.Debug().Msg("Getting embedded files") @@ -48,19 +44,8 @@ func run(ctx context.Context, w io.Writer, args map[string]string, config *confi return errors.Wrap(err, "getStaticFiles") } - // Setup TokenGenerator - logger.Debug().Msg("Creating TokenGenerator") - tokenGen, err := jwt.CreateGenerator( - config.AccessTokenExpiry, - config.RefreshTokenExpiry, - config.TokenFreshTime, - config.TrustedHost, - config.SecretKey, - conn, - ) - logger.Debug().Msg("Setting up HTTP server") - httpServer, err := setupHttpServer(&staticFS, config, logger, conn, tokenGen) + httpServer, err := setupHttpServer(&staticFS, config, logger, bun) if err != nil { return errors.Wrap(err, "setupHttpServer") } diff --git a/go.mod b/go.mod index 136ad4d..60d1c4a 100644 --- a/go.mod +++ b/go.mod @@ -4,44 +4,50 @@ go 1.25.5 require ( git.haelnorr.com/h/golib/cookies v0.9.0 - git.haelnorr.com/h/golib/env v0.9.0 - git.haelnorr.com/h/golib/hlog v0.9.0 - git.haelnorr.com/h/golib/hws v0.1.0 - git.haelnorr.com/h/golib/hwsauth v0.2.0 - git.haelnorr.com/h/golib/jwt v0.9.2 + git.haelnorr.com/h/golib/env v0.9.1 + git.haelnorr.com/h/golib/hlog v0.9.1 + git.haelnorr.com/h/golib/hws v0.2.0 + git.haelnorr.com/h/golib/hwsauth v0.3.1 git.haelnorr.com/h/golib/tmdb v0.8.0 github.com/a-h/templ v0.3.977 github.com/joho/godotenv v1.5.1 - github.com/mattn/go-sqlite3 v1.14.24 github.com/pkg/errors v0.9.1 github.com/pressly/goose/v3 v3.24.1 - golang.org/x/crypto v0.33.0 + github.com/uptrace/bun v1.2.16 + golang.org/x/crypto v0.45.0 modernc.org/sqlite v1.35.0 ) -replace git.haelnorr.com/h/golib/hwsauth => /home/haelnorr/projects/golib/hwsauth - -replace git.haelnorr.com/h/golib/hws => /home/haelnorr/projects/golib/hws - require ( + git.haelnorr.com/h/golib/jwt v0.10.0 // indirect github.com/dustin/go-humanize v1.0.1 // 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/mfridman/interpolate v0.0.2 // indirect github.com/ncruces/go-strftime v0.1.9 // indirect + github.com/puzpuzpuz/xsync/v3 v3.5.1 // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/rs/zerolog v1.34.0 // indirect github.com/sethvargo/go-retry v0.3.0 // indirect + github.com/tmthrgd/go-hex v0.0.0-20190904060850-447a3041c3bc // indirect + github.com/uptrace/bun/dialect/pgdialect v1.2.16 + github.com/uptrace/bun/driver/pgdriver v1.2.16 + 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 go.uber.org/multierr v1.11.0 // indirect golang.org/x/exp v0.0.0-20240325151524-a685a6edb6d8 // indirect golang.org/x/sync v0.16.0 // indirect - golang.org/x/sys v0.34.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-20251002143259-bc988d571ff4 // indirect + k8s.io/utils v0.0.0-20260108192941-914a6e750570 // indirect + mellium.im/sasl v0.3.2 // indirect modernc.org/libc v1.61.13 // indirect modernc.org/mathutil v1.7.1 // indirect modernc.org/memory v1.8.2 // indirect diff --git a/go.sum b/go.sum index 53f778d..484eb28 100644 --- a/go.sum +++ b/go.sum @@ -1,11 +1,15 @@ 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.0 h1:Ahqr3PbHy7HdWEHUhylzIZy6Gg8mST5UdgKlU2RAhls= -git.haelnorr.com/h/golib/env v0.9.0/go.mod h1:glUQVdA1HMKX1avTDyTyuhcr36SSxZtlJxKDT5KTztg= -git.haelnorr.com/h/golib/hlog v0.9.0 h1:ib8n2MdmiRK2TF067p220kXmhDe9aAnlcsgpuv+QpvE= -git.haelnorr.com/h/golib/hlog v0.9.0/go.mod h1:oOlzb8UVHUYP1k7dN5PSJXVskAB2z8EYgRN85jAi0Zk= -git.haelnorr.com/h/golib/jwt v0.9.2 h1:l1Ow7DPGACAU54CnMP/NlZjdc4nRD1wr3xZ8a7taRvU= -git.haelnorr.com/h/golib/jwt v0.9.2/go.mod h1:fbuPrfucT9lL0faV5+Q5Gk9WFJxPlwzRPpbMQKYZok4= +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/hlog v0.9.1 h1:9VmE/IQTfD8LAEyTbUCZLy/+8PbcHA1Kob/WQHRHKzc= +git.haelnorr.com/h/golib/hlog v0.9.1/go.mod h1:oOlzb8UVHUYP1k7dN5PSJXVskAB2z8EYgRN85jAi0Zk= +git.haelnorr.com/h/golib/hws v0.2.0 h1:MR2Tu2qPaW+/oK8aXFJLRFaYZIHgKiex3t3zE41cu1U= +git.haelnorr.com/h/golib/hws v0.2.0/go.mod h1:6ZlRKnt8YMpv5XcMXmyBGmD1/euvBo3d1azEvHJjOLo= +git.haelnorr.com/h/golib/hwsauth v0.3.1 h1:+vVkVj/5DTPXSp7em2DqF3QuovhHKSCRTRFbwRQ7g8E= +git.haelnorr.com/h/golib/hwsauth v0.3.1/go.mod h1:WHHMy1EVQWrHtyJx+gQQkB+5otJ4E6ZyEtKBjqZqKhQ= +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= git.haelnorr.com/h/golib/tmdb v0.8.0 h1:OQ6M2TB8FHm8fJD7/ebfWm63Duzfp0kmFX9genEig34= git.haelnorr.com/h/golib/tmdb v0.8.0/go.mod h1:mGKYa3o3z0IsQ5EO3MPmnL2Bwl2sSMsUHXVgaIGR7Z0= github.com/DATA-DOG/go-sqlmock v1.5.2 h1:OcvFkGmslmlZibjAjaHm3L//6LiuBgolP7OputlJIzU= @@ -28,6 +32,8 @@ github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd h1:gbpYu9NMq8jhDVbvlG github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd/go.mod h1:kf6iHlnVGwgKolg33glAes7Yg/8iWP8ukqeldJSO7jw= 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= @@ -37,8 +43,6 @@ github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/ 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/mattn/go-sqlite3 v1.14.24 h1:tpSp2G2KyMnnQu99ngJ47EIkWVmliIizyZBfPrBWDRM= -github.com/mattn/go-sqlite3 v1.14.24/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= github.com/mfridman/interpolate v0.0.2 h1:pnuTK7MQIxxFz1Gr+rjSIx9u7qVjf5VOoM/u6BbAxPY= github.com/mfridman/interpolate v0.0.2/go.mod h1:p+7uk6oE07mpE/Ik1b8EckO0O4ZXiGAfshKBWLUM9Xg= github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4= @@ -49,6 +53,8 @@ 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/pressly/goose/v3 v3.24.1 h1:bZmxRco2uy5uu5Ng1MMVEfYsFlrMJI+e/VMXHQ3C4LY= github.com/pressly/goose/v3 v3.24.1/go.mod h1:rEWreU9uVtt0DHCyLzF9gRcWiiTF/V+528DV+4DORug= +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/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0= @@ -58,10 +64,26 @@ github.com/sethvargo/go-retry v0.3.0 h1:EEt31A35QhrcRZtrYFDTBg91cqZVnFL2navjDrah github.com/sethvargo/go-retry v0.3.0/go.mod h1:mNX17F0C/HguQMyMyJxcnU471gOZGxCLyYaFyAZraas= 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= go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= -golang.org/x/crypto v0.33.0 h1:IOBPskki6Lysi0lo9qQvbxiQ+FvsCC/YWOecCHAixus= -golang.org/x/crypto v0.33.0/go.mod h1:bVdXmD7IV/4GdElGPozy6U7lWdRXA4qyRVGJV57uQ5M= +golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q= +golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4= golang.org/x/exp v0.0.0-20240325151524-a685a6edb6d8 h1:aAcj0Da7eBAtrTp03QXWvm88pSyOt+UgdZw2BFZ+lEw= golang.org/x/exp v0.0.0-20240325151524-a685a6edb6d8/go.mod h1:CQ1k9gNrJ50XIzaKCRR2hssIjF07kZFEiieALBM/ARQ= golang.org/x/mod v0.26.0 h1:EGMPT//Ezu+ylkCijjPc+f4Aih7sZvaAr+O3EHBxvZg= @@ -71,8 +93,8 @@ golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= 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.34.0 h1:H5Y5sJ2L2JRdyv7ROF1he/lPdvFsd0mJHFw2ThKHxLA= -golang.org/x/sys v0.34.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ= +golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/tools v0.35.0 h1:mBffYraMEf7aa0sB+NuKnuCy8qI/9Bughn8dC2Gu5r0= golang.org/x/tools v0.35.0/go.mod h1:NKdj5HkL/73byiZSJjqJgKn3ep7KjFkBOkR/Hps3VPw= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= @@ -81,8 +103,10 @@ 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-20251002143259-bc988d571ff4 h1:SjGebBtkBqHFOli+05xYbK8YF1Dzkbzn+gDM4X9T4Ck= -k8s.io/utils v0.0.0-20251002143259-bc988d571ff4/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0= +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= modernc.org/cc/v4 v4.24.4 h1:TFkx1s6dCkQpd6dKurBNmpo+G8Zl4Sq/ztJ+2+DEsh0= modernc.org/cc/v4 v4.24.4/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0= modernc.org/ccgo/v4 v4.23.16 h1:Z2N+kk38b7SfySC1ZkpGLN2vthNJP1+ZzGZIlH7uBxo= diff --git a/internal/config/auth.go b/internal/config/auth.go new file mode 100644 index 0000000..fd47683 --- /dev/null +++ b/internal/config/auth.go @@ -0,0 +1,37 @@ +package config + +import ( + "git.haelnorr.com/h/golib/env" + "github.com/pkg/errors" +) + +type HWSAUTHConfig struct { + SSL bool // ENV HWSAUTH_SSL: Flag for SSL Mode (default: false) + TrustedHost string // ENV HWSAUTH_TRUSTED_HOST: Full server address to accept as trusted SSL host (required if SSL is true) + SecretKey string // ENV HWSAUTH_SECRET_KEY: Secret key for signing tokens (required) + AccessTokenExpiry int64 // ENV HWSAUTH_ACCESS_TOKEN_EXPIRY: Access token expiry in minutes (default: 5) + RefreshTokenExpiry int64 // ENV HWSAUTH_REFRESH_TOKEN_EXPIRY: Refresh token expiry in minutes (default: 1440) + TokenFreshTime int64 // ENV HWSAUTH_TOKEN_FRESH_TIME: Time for tokens to stay fresh in minutes (default: 5) +} + +func setupHWSAuth() (*HWSAUTHConfig, error) { + ssl := env.Bool("HWSAUTH_SSL", false) + trustedHost := env.String("HWS_TRUSTED_HOST", "") + if ssl && trustedHost == "" { + return nil, errors.New("SSL is enabled and no HWS_TRUSTED_HOST set") + } + cfg := &HWSAUTHConfig{ + SSL: ssl, + TrustedHost: trustedHost, + SecretKey: env.String("HWSAUTH_SECRET_KEY", ""), + AccessTokenExpiry: env.Int64("HWSAUTH_ACCESS_TOKEN_EXPIRY", 5), + RefreshTokenExpiry: env.Int64("HWSAUTH_REFRESH_TOKEN_EXPIRY", 1440), + TokenFreshTime: env.Int64("HWSAUTH_TOKEN_FRESH_TIME", 5), + } + + if cfg.SecretKey == "" { + return nil, errors.New("Envar not set: HWSAUTH_SECRET_KEY") + } + + return cfg, nil +} diff --git a/internal/config/config.go b/internal/config/config.go index 3b43910..03a6fd4 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -1,116 +1,55 @@ package config import ( - "fmt" - "os" - "time" - - "git.haelnorr.com/h/golib/env" - "git.haelnorr.com/h/golib/hlog" - "git.haelnorr.com/h/golib/tmdb" + "git.haelnorr.com/h/golib/hws" + "git.haelnorr.com/h/golib/hwsauth" "github.com/joho/godotenv" "github.com/pkg/errors" ) type Config struct { - Host string // Host to listen on - Port string // Port to listen on - TrustedHost string // Domain/Hostname to accept as trusted - SSL bool // Flag for SSL Mode - GZIP bool // Flag for GZIP compression on requests - ReadHeaderTimeout time.Duration // Timeout for reading request headers in seconds - WriteTimeout time.Duration // Timeout for writing requests in seconds - IdleTimeout time.Duration // Timeout for idle connections in seconds - DBName string // Filename of the db - hardcoded and doubles as DB version - DBLockTimeout time.Duration // Timeout for acquiring database lock - SecretKey string // Secret key for signing tokens - AccessTokenExpiry int64 // Access token expiry in minutes - RefreshTokenExpiry int64 // Refresh token expiry in minutes - TokenFreshTime int64 // Time for tokens to stay fresh in minutes - LogLevel hlog.Level // Log level for global logging. Defaults to info - LogOutput string // "file", "console", or "both". Defaults to console - LogDir string // Path to create log files - TMDBToken string // Read access token for TMDB API - TMDBConfig *tmdb.Config // Config data for interfacing with TMDB + DB *DBConfig + HWS *hws.Config + HWSAuth *hwsauth.Config + TMDB *TMDBConfig + HLOG *HLOGConfig } // Load the application configuration and get a pointer to the Config object -func GetConfig(args map[string]string) (*Config, error) { - godotenv.Load(".env") - var ( - host string - port string - logLevel hlog.Level - logOutput string - valid bool - ) +func GetConfig(envfile string) (*Config, error) { + godotenv.Load(envfile) - if args["host"] != "" { - host = args["host"] - } else { - host = env.String("HOST", "127.0.0.1") - } - if args["port"] != "" { - port = args["port"] - } else { - port = env.String("PORT", "3010") - } - if args["loglevel"] != "" { - logLevel = hlog.LogLevel(args["loglevel"]) - } else { - logLevel = hlog.LogLevel(env.String("LOG_LEVEL", "info")) - } - if args["logoutput"] != "" { - opts := map[string]string{ - "both": "both", - "file": "file", - "console": "console", - } - logOutput, valid = opts[args["logoutput"]] - if !valid { - logOutput = "console" - fmt.Println( - "Log output type was not parsed correctly. Defaulting to console only", - ) - } - } else { - logOutput = env.String("LOG_OUTPUT", "console") - } - if logOutput != "both" && logOutput != "console" && logOutput != "file" { - logOutput = "console" - } - tmdbcfg, err := tmdb.GetConfig(os.Getenv("TMDB_API_TOKEN")) + db, err := setupDB() if err != nil { - return nil, errors.Wrap(err, "tmdb.GetConfig") + return nil, errors.Wrap(err, "setupDB") + } + + hws, err := hws.ConfigFromEnv() + if err != nil { + return nil, errors.Wrap(err, "hws.ConfigFromEnv") + } + + hwsAuth, err := hwsauth.ConfigFromEnv() + if err != nil { + return nil, errors.Wrap(err, "hwsauth.ConfigFromEnv") + } + + tmdb, err := setupTMDB() + if err != nil { + return nil, errors.Wrap(err, "setupTMDB") + } + + hlog, err := setupHLOG() + if err != nil { + return nil, errors.Wrap(err, "setupHLOG") } config := &Config{ - Host: host, - Port: port, - TrustedHost: env.String("TRUSTED_HOST", "127.0.0.1"), - SSL: env.Bool("SSL_MODE", false), - GZIP: env.Bool("GZIP", false), - ReadHeaderTimeout: env.Duration("READ_HEADER_TIMEOUT", 2) * time.Second, - WriteTimeout: env.Duration("WRITE_TIMEOUT", 10) * time.Second, - IdleTimeout: env.Duration("IDLE_TIMEOUT", 120) * time.Second, - DBName: "00001", - DBLockTimeout: env.Duration("DB_LOCK_TIMEOUT", 60), - SecretKey: env.String("SECRET_KEY", ""), - AccessTokenExpiry: env.Int64("ACCESS_TOKEN_EXPIRY", 5), - RefreshTokenExpiry: env.Int64("REFRESH_TOKEN_EXPIRY", 1440), // defaults to 1 day - TokenFreshTime: env.Int64("TOKEN_FRESH_TIME", 5), - LogLevel: logLevel, - LogOutput: logOutput, - LogDir: env.String("LOG_DIR", ""), - TMDBToken: env.String("TMDB_API_TOKEN", ""), - TMDBConfig: tmdbcfg, - } - - if config.SecretKey == "" && args["dbver"] != "true" { - return nil, errors.New("Envar not set: SECRET_KEY") - } - if config.TMDBToken == "" && args["dbver"] != "true" { - return nil, errors.New("Envar not set: TMDB_API_TOKEN") + DB: db, + HWS: hws, + HWSAuth: hwsAuth, + TMDB: tmdb, + HLOG: hlog, } return config, nil diff --git a/internal/config/db.go b/internal/config/db.go new file mode 100644 index 0000000..1e51df2 --- /dev/null +++ b/internal/config/db.go @@ -0,0 +1,55 @@ +package config + +import ( + "git.haelnorr.com/h/golib/env" + "github.com/pkg/errors" +) + +type DBConfig 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 setupDB() (*DBConfig, error) { + cfg := &DBConfig{ + 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/config/envdoc.go b/internal/config/envdoc.go new file mode 100644 index 0000000..366990a --- /dev/null +++ b/internal/config/envdoc.go @@ -0,0 +1,114 @@ +package config + +import ( + "reflect" + "regexp" + "strings" +) + +// EnvVar represents an environment variable with its documentation +type EnvVar struct { + Name string + Description string + Default string + HasDefault bool + Required bool +} + +// extractEnvVars parses a struct's field comments to extract environment variable documentation +func extractEnvVars(structType reflect.Type, fieldIndex int) *EnvVar { + field := structType.Field(fieldIndex) + tag := field.Tag.Get("comment") + if tag == "" { + // Try to get the comment from the struct field's tag or use reflection + // For now, we'll parse it manually from the comment string + return nil + } + + comment := tag + if !strings.HasPrefix(comment, "ENV ") { + return nil + } + + // Remove "ENV " prefix + comment = strings.TrimPrefix(comment, "ENV ") + + // Extract name and description + parts := strings.SplitN(comment, ":", 2) + if len(parts) != 2 { + return nil + } + + name := strings.TrimSpace(parts[0]) + desc := strings.TrimSpace(parts[1]) + + // Check for default value in description + defaultRegex := regexp.MustCompile(`\(default:\s*([^)]+)\)`) + matches := defaultRegex.FindStringSubmatch(desc) + + envVar := &EnvVar{ + Name: name, + Description: desc, + } + + if len(matches) > 1 { + envVar.Default = matches[1] + envVar.HasDefault = true + // Remove the default notation from description + envVar.Description = strings.TrimSpace(defaultRegex.ReplaceAllString(desc, "")) + } + + return envVar +} + +// GetAllEnvVars returns a list of all environment variables used in the config +func GetAllEnvVars() []EnvVar { + var envVars []EnvVar + + // Manually define all env vars based on the config structs + // This is more reliable than reflection for extracting comments + + // DBConfig + envVars = append(envVars, []EnvVar{ + {Name: "DB_USER", Description: "Database user for authentication", HasDefault: false, Required: true}, + {Name: "DB_PASSWORD", Description: "Database password for authentication", HasDefault: false, Required: true}, + {Name: "DB_HOST", Description: "Database host address", HasDefault: false, Required: true}, + {Name: "DB_PORT", Description: "Database port", Default: "5432", HasDefault: true, Required: false}, + {Name: "DB_NAME", Description: "Database name to connect to", HasDefault: false, Required: true}, + {Name: "DB_SSL", Description: "SSL mode for connection", Default: "disable", HasDefault: true, Required: false}, + }...) + + // HWSConfig + envVars = append(envVars, []EnvVar{ + {Name: "HWS_HOST", Description: "Host to listen on", Default: "127.0.0.1", HasDefault: true, Required: false}, + {Name: "HWS_PORT", Description: "Port to listen on", Default: "3000", HasDefault: true, Required: false}, + {Name: "HWS_TRUSTED_HOST", Description: "Domain/Hostname to accept as trusted", Default: "same as Host", HasDefault: true, Required: false}, + {Name: "HWS_SSL", Description: "Flag for SSL Mode", Default: "false", HasDefault: true, Required: false}, + {Name: "HWS_GZIP", Description: "Flag for GZIP compression on requests", Default: "false", HasDefault: true, Required: false}, + {Name: "HWS_READ_HEADER_TIMEOUT", Description: "Timeout for reading request headers in seconds", Default: "2", HasDefault: true, Required: false}, + {Name: "HWS_WRITE_TIMEOUT", Description: "Timeout for writing requests in seconds", Default: "10", HasDefault: true, Required: false}, + {Name: "HWS_IDLE_TIMEOUT", Description: "Timeout for idle connections in seconds", Default: "120", HasDefault: true, Required: false}, + }...) + + // HWSAUTHConfig + envVars = append(envVars, []EnvVar{ + {Name: "HWSAUTH_SECRET_KEY", Description: "Secret key for signing tokens", HasDefault: false, Required: true}, + {Name: "HWSAUTH_ACCESS_TOKEN_EXPIRY", Description: "Access token expiry in minutes", Default: "5", HasDefault: true, Required: false}, + {Name: "HWSAUTH_REFRESH_TOKEN_EXPIRY", Description: "Refresh token expiry in minutes", Default: "1440", HasDefault: true, Required: false}, + {Name: "HWSAUTH_TOKEN_FRESH_TIME", Description: "Time for tokens to stay fresh in minutes", Default: "5", HasDefault: true, Required: false}, + }...) + + // TMDBConfig + envVars = append(envVars, []EnvVar{ + {Name: "TMDB_TOKEN", Description: "API token for TMDB", HasDefault: false, Required: true}, + }...) + + // HLOGConfig + envVars = append(envVars, []EnvVar{ + {Name: "LOG_LEVEL", Description: "Log level for global logging", Default: "info", HasDefault: true, Required: false}, + {Name: "LOG_OUTPUT", Description: "Output method for the logger (file, console, or both)", Default: "console", HasDefault: true, Required: false}, + {Name: "LOG_DIR", Description: "Path to create log files", HasDefault: false, Required: false}, + }...) + + return envVars +} diff --git a/internal/config/envgen.go b/internal/config/envgen.go new file mode 100644 index 0000000..639c1d0 --- /dev/null +++ b/internal/config/envgen.go @@ -0,0 +1,95 @@ +package config + +import ( + "fmt" + "os" + "strings" +) + +// GenerateDotEnv creates a new .env file with all environment variables and their defaults +func GenerateDotEnv(filename string) error { + envVars := GetAllEnvVars() + + file, err := os.Create(filename) + if err != nil { + return fmt.Errorf("failed to create file: %w", err) + } + defer file.Close() + + // Write header + fmt.Fprintln(file, "# Environment Configuration") + fmt.Fprintln(file, "# Generated by Project Reshoot") + fmt.Fprintln(file, "#") + fmt.Fprintln(file, "# Variables marked as (required) must be set") + fmt.Fprintln(file, "# Variables with defaults can be left commented out to use the default value") + fmt.Fprintln(file) + + // Group by prefix + groups := map[string][]EnvVar{ + "DB_": {}, + "HWS_": {}, + "HWSAUTH_": {}, + "TMDB_": {}, + "LOG_": {}, + } + + for _, ev := range envVars { + assigned := false + for prefix := range groups { + if strings.HasPrefix(ev.Name, prefix) { + groups[prefix] = append(groups[prefix], ev) + assigned = true + break + } + } + if !assigned { + // Handle ungrouped vars + if _, ok := groups["OTHER"]; !ok { + groups["OTHER"] = []EnvVar{} + } + groups["OTHER"] = append(groups["OTHER"], ev) + } + } + + // Print each group + groupOrder := []string{"DB_", "HWS_", "HWSAUTH_", "TMDB_", "LOG_", "OTHER"} + groupTitles := map[string]string{ + "DB_": "Database Configuration", + "HWS_": "HTTP Web Server Configuration", + "HWSAUTH_": "Authentication Configuration", + "TMDB_": "TMDB API Configuration", + "LOG_": "Logging Configuration", + "OTHER": "Other Configuration", + } + + for _, prefix := range groupOrder { + vars, ok := groups[prefix] + if !ok || len(vars) == 0 { + continue + } + + fmt.Fprintf(file, "# %s\n", groupTitles[prefix]) + fmt.Fprintln(file, strings.Repeat("#", len(groupTitles[prefix])+2)) + + for _, ev := range vars { + // Write description as comment + if ev.Required { + fmt.Fprintf(file, "# %s (required)\n", ev.Description) + // Leave required variables uncommented but empty + fmt.Fprintf(file, "%s=\n", ev.Name) + } else if ev.HasDefault { + fmt.Fprintf(file, "# %s\n", ev.Description) + // Comment out variables with defaults + fmt.Fprintf(file, "# %s=%s\n", ev.Name, ev.Default) + } else { + fmt.Fprintf(file, "# %s\n", ev.Description) + // Optional variables without defaults are commented out + fmt.Fprintf(file, "# %s=\n", ev.Name) + } + fmt.Fprintln(file) + } + fmt.Fprintln(file) + } + + return nil +} diff --git a/internal/config/envprint.go b/internal/config/envprint.go new file mode 100644 index 0000000..55dac33 --- /dev/null +++ b/internal/config/envprint.go @@ -0,0 +1,87 @@ +package config + +import ( + "fmt" + "io" + "strings" +) + +// PrintEnvVars writes all environment variables and their documentation to the provided writer +func PrintEnvVars(w io.Writer) error { + envVars := GetAllEnvVars() + + // Find the longest name for alignment + maxNameLen := 0 + for _, ev := range envVars { + if len(ev.Name) > maxNameLen { + maxNameLen = len(ev.Name) + } + } + + // Print header + fmt.Fprintln(w, "Environment Variables") + fmt.Fprintln(w, strings.Repeat("=", 80)) + fmt.Fprintln(w) + + // Group by prefix + groups := map[string][]EnvVar{ + "DB_": {}, + "HWS_": {}, + "HWSAUTH_": {}, + "TMDB_": {}, + "LOG_": {}, + } + + for _, ev := range envVars { + assigned := false + for prefix := range groups { + if strings.HasPrefix(ev.Name, prefix) { + groups[prefix] = append(groups[prefix], ev) + assigned = true + break + } + } + if !assigned { + // Handle ungrouped vars + if _, ok := groups["OTHER"]; !ok { + groups["OTHER"] = []EnvVar{} + } + groups["OTHER"] = append(groups["OTHER"], ev) + } + } + + // Print each group + groupOrder := []string{"DB_", "HWS_", "HWSAUTH_", "TMDB_", "LOG_", "OTHER"} + groupTitles := map[string]string{ + "DB_": "Database Configuration", + "HWS_": "HTTP Web Server Configuration", + "HWSAUTH_": "Authentication Configuration", + "TMDB_": "TMDB API Configuration", + "LOG_": "Logging Configuration", + "OTHER": "Other Configuration", + } + + for _, prefix := range groupOrder { + vars, ok := groups[prefix] + if !ok || len(vars) == 0 { + continue + } + + fmt.Fprintf(w, "%s\n", groupTitles[prefix]) + fmt.Fprintln(w, strings.Repeat("-", len(groupTitles[prefix]))) + + for _, ev := range vars { + padding := strings.Repeat(" ", maxNameLen-len(ev.Name)) + if ev.Required { + fmt.Fprintf(w, " %s%s : %s (required)\n", ev.Name, padding, ev.Description) + } else if ev.HasDefault { + fmt.Fprintf(w, " %s%s : %s (default: %s)\n", ev.Name, padding, ev.Description, ev.Default) + } else { + fmt.Fprintf(w, " %s%s : %s\n", ev.Name, padding, ev.Description) + } + } + fmt.Fprintln(w) + } + + return nil +} diff --git a/internal/config/httpserver.go b/internal/config/httpserver.go new file mode 100644 index 0000000..c3485f1 --- /dev/null +++ b/internal/config/httpserver.go @@ -0,0 +1,29 @@ +package config + +import ( + "time" + + "git.haelnorr.com/h/golib/env" +) + +type HWSConfig struct { + Host string // ENV HWS_HOST: Host to listen on (default: 127.0.0.1) + Port uint64 // ENV HWS_PORT: Port to listen on (default: 3000) + GZIP bool // ENV HWS_GZIP: Flag for GZIP compression on requests (default: false) + ReadHeaderTimeout time.Duration // ENV HWS_READ_HEADER_TIMEOUT: Timeout for reading request headers in seconds (default: 2) + WriteTimeout time.Duration // ENV HWS_WRITE_TIMEOUT: Timeout for writing requests in seconds (default: 10) + IdleTimeout time.Duration // ENV HWS_IDLE_TIMEOUT: Timeout for idle connections in seconds (default: 120) +} + +func setupHWS() (*HWSConfig, error) { + cfg := &HWSConfig{ + Host: env.String("HWS_HOST", "127.0.0.1"), + Port: env.UInt64("HWS_PORT", 3000), + GZIP: env.Bool("HWS_GZIP", false), + ReadHeaderTimeout: time.Duration(env.Int("HWS_READ_HEADER_TIMEOUT", 2)) * time.Second, + WriteTimeout: time.Duration(env.Int("HWS_WRITE_TIMEOUT", 10)) * time.Second, + IdleTimeout: time.Duration(env.Int("HWS_IDLE_TIMEOUT", 120)) * time.Second, + } + + return cfg, nil +} diff --git a/internal/config/logger.go b/internal/config/logger.go new file mode 100644 index 0000000..c23b555 --- /dev/null +++ b/internal/config/logger.go @@ -0,0 +1,36 @@ +package config + +import ( + "git.haelnorr.com/h/golib/env" + "git.haelnorr.com/h/golib/hlog" + "github.com/pkg/errors" +) + +type HLOGConfig struct { + // ENV LOG_LEVEL: Log level for global logging. (default: info) + LogLevel hlog.Level + + // ENV LOG_OUTPUT: Output method for the logger. (default: console) + // Valid options: "file", "console", "both" + LogOutput string + + // ENV LOG_DIR: Path to create log files + LogDir string +} + +func setupHLOG() (*HLOGConfig, error) { + logLevel, err := hlog.LogLevel(env.String("LOG_LEVEL", "info")) + if err != nil { + return nil, errors.Wrap(err, "hlog.LogLevel") + } + logOutput := env.String("LOG_OUTPUT", "console") + if logOutput != "both" && logOutput != "console" && logOutput != "file" { + return nil, errors.Errorf("Invalid LOG_OUTPUT: %s", logOutput) + } + cfg := &HLOGConfig{ + LogLevel: logLevel, + LogOutput: logOutput, + LogDir: env.String("LOG_DIR", ""), + } + return cfg, nil +} diff --git a/internal/config/tmdb.go b/internal/config/tmdb.go new file mode 100644 index 0000000..0c19db6 --- /dev/null +++ b/internal/config/tmdb.go @@ -0,0 +1,28 @@ +package config + +import ( + "git.haelnorr.com/h/golib/env" + "git.haelnorr.com/h/golib/tmdb" + "github.com/pkg/errors" +) + +type TMDBConfig struct { + Token string // ENV TMDB_TOKEN: API token for TMDB (required) + Config *tmdb.Config // Config data for interfacing with TMDB +} + +func setupTMDB() (*TMDBConfig, error) { + token := env.String("TMDB_TOKEN", "") + if token == "" { + return nil, errors.New("No TMDB API Token provided") + } + tmdbcfg, err := tmdb.GetConfig(token) + if err != nil { + return nil, errors.Wrap(err, "tmdb.GetConfig") + } + cfg := &TMDBConfig{ + Token: token, + Config: tmdbcfg, + } + return cfg, nil +} diff --git a/internal/handler/account.go b/internal/handler/account.go index c1fd83f..d3f445d 100644 --- a/internal/handler/account.go +++ b/internal/handler/account.go @@ -2,19 +2,20 @@ package handler import ( "context" - "database/sql" "net/http" "time" - "git.haelnorr.com/h/golib/hws" - "git.haelnorr.com/h/golib/hwsauth" "projectreshoot/internal/models" "projectreshoot/internal/view/component/account" "projectreshoot/internal/view/page" + "git.haelnorr.com/h/golib/hws" + "git.haelnorr.com/h/golib/hwsauth" + "git.haelnorr.com/h/golib/cookies" "github.com/pkg/errors" + "github.com/uptrace/bun" ) // Renders the account page on the 'General' subpage @@ -46,8 +47,8 @@ func AccountSubpage() http.Handler { // Handles a request to change the users username func ChangeUsername( server *hws.Server, - auth *hwsauth.Authenticator[*models.User], - conn *sql.DB, + auth *hwsauth.Authenticator[*models.UserBun, bun.Tx], + db *bun.DB, ) http.Handler { return http.HandlerFunc( func(w http.ResponseWriter, r *http.Request) { @@ -55,7 +56,7 @@ func ChangeUsername( defer cancel() // Start the transaction - tx, err := conn.BeginTx(ctx, nil) + tx, err := db.BeginTx(ctx, nil) if err != nil { err := server.ThrowError(w, r, hws.HWSError{ StatusCode: http.StatusServiceUnavailable, @@ -69,7 +70,7 @@ func ChangeUsername( } r.ParseForm() newUsername := r.FormValue("username") - unique, err := models.CheckUsernameUnique(tx, newUsername) + unique, err := models.IsUsernameUnique(ctx, tx, newUsername) if err != nil { tx.Rollback() err := server.ThrowError(w, r, hws.HWSError{ @@ -89,7 +90,7 @@ func ChangeUsername( return } user := auth.CurrentModel(r.Context()) - err = user.ChangeUsername(tx, newUsername) + err = user.ChangeUsername(ctx, tx, newUsername) if err != nil { tx.Rollback() err := server.ThrowError(w, r, hws.HWSError{ @@ -111,8 +112,8 @@ func ChangeUsername( // Handles a request to change the users bio func ChangeBio( server *hws.Server, - auth *hwsauth.Authenticator[*models.User], - conn *sql.DB, + auth *hwsauth.Authenticator[*models.UserBun, bun.Tx], + db *bun.DB, ) http.Handler { return http.HandlerFunc( func(w http.ResponseWriter, r *http.Request) { @@ -120,7 +121,7 @@ func ChangeBio( defer cancel() // Start the transaction - tx, err := conn.BeginTx(ctx, nil) + tx, err := db.BeginTx(ctx, nil) if err != nil { err := server.ThrowError(w, r, hws.HWSError{ StatusCode: http.StatusServiceUnavailable, @@ -142,7 +143,7 @@ func ChangeBio( return } user := auth.CurrentModel(r.Context()) - err = user.ChangeBio(tx, newBio) + err = user.ChangeBio(ctx, tx, newBio) if err != nil { tx.Rollback() err := server.ThrowError(w, r, hws.HWSError{ @@ -178,8 +179,8 @@ func validateChangePassword( // Handles a request to change the users password func ChangePassword( server *hws.Server, - auth *hwsauth.Authenticator[*models.User], - conn *sql.DB, + auth *hwsauth.Authenticator[*models.UserBun, bun.Tx], + db *bun.DB, ) http.Handler { return http.HandlerFunc( func(w http.ResponseWriter, r *http.Request) { @@ -187,7 +188,7 @@ func ChangePassword( defer cancel() // Start the transaction - tx, err := conn.BeginTx(ctx, nil) + tx, err := db.BeginTx(ctx, nil) if err != nil { err := server.ThrowError(w, r, hws.HWSError{ StatusCode: http.StatusServiceUnavailable, @@ -206,7 +207,7 @@ func ChangePassword( return } user := auth.CurrentModel(r.Context()) - err = user.SetPassword(tx, newPass) + err = user.SetPassword(ctx, tx, newPass) if err != nil { tx.Rollback() err := server.ThrowError(w, r, hws.HWSError{ diff --git a/internal/handler/login.go b/internal/handler/login.go index 629db04..7b9af8c 100644 --- a/internal/handler/login.go +++ b/internal/handler/login.go @@ -2,35 +2,41 @@ package handler import ( "context" - "database/sql" "net/http" "strings" "time" - "git.haelnorr.com/h/golib/hws" - "git.haelnorr.com/h/golib/hwsauth" "projectreshoot/internal/models" "projectreshoot/internal/view/component/form" "projectreshoot/internal/view/page" + "git.haelnorr.com/h/golib/hws" + "git.haelnorr.com/h/golib/hwsauth" + "git.haelnorr.com/h/golib/cookies" "github.com/pkg/errors" + "github.com/uptrace/bun" ) // Validates the username matches a user in the database and the password // is correct. Returns the corresponding user func validateLogin( - tx *sql.Tx, + ctx context.Context, + tx bun.Tx, r *http.Request, -) (*models.User, error) { +) (*models.UserBun, error) { formUsername := r.FormValue("username") formPassword := r.FormValue("password") - user, err := models.GetUserFromUsername(tx, formUsername) + user, err := models.GetUserByUsername(ctx, tx, formUsername) if err != nil { return nil, errors.Wrap(err, "db.GetUserFromUsername") } - err = user.CheckPassword(tx, formPassword) + if user == nil { + return nil, errors.New("Username or password incorrect") + } + + err = user.CheckPassword(ctx, tx, formPassword) if err != nil { if !strings.Contains(err.Error(), "Username or password incorrect") { return nil, errors.Wrap(err, "user.CheckPassword") @@ -55,8 +61,8 @@ func checkRememberMe(r *http.Request) bool { // template for user feedback func LoginRequest( server *hws.Server, - auth *hwsauth.Authenticator[*models.User], - conn *sql.DB, + auth *hwsauth.Authenticator[*models.UserBun, bun.Tx], + db *bun.DB, ) http.Handler { return http.HandlerFunc( func(w http.ResponseWriter, r *http.Request) { @@ -64,7 +70,7 @@ func LoginRequest( defer cancel() // Start the transaction - tx, err := conn.BeginTx(ctx, nil) + tx, err := db.BeginTx(ctx, nil) if err != nil { err := server.ThrowError(w, r, hws.HWSError{ StatusCode: http.StatusServiceUnavailable, @@ -77,7 +83,7 @@ func LoginRequest( return } r.ParseForm() - user, err := validateLogin(tx, r) + user, err := validateLogin(ctx, tx, r) if err != nil { tx.Rollback() if err.Error() != "Username or password incorrect" { diff --git a/internal/handler/logout.go b/internal/handler/logout.go index 8408630..3460b8b 100644 --- a/internal/handler/logout.go +++ b/internal/handler/logout.go @@ -2,26 +2,27 @@ package handler import ( "context" - "database/sql" - "git.haelnorr.com/h/golib/hws" - "git.haelnorr.com/h/golib/hwsauth" "net/http" "projectreshoot/internal/models" "time" + + "git.haelnorr.com/h/golib/hws" + "git.haelnorr.com/h/golib/hwsauth" + "github.com/uptrace/bun" ) // Handle a logout request func Logout( server *hws.Server, - auth *hwsauth.Authenticator[*models.User], - conn *sql.DB, + auth *hwsauth.Authenticator[*models.UserBun, bun.Tx], + db *bun.DB, ) http.Handler { return http.HandlerFunc( func(w http.ResponseWriter, r *http.Request) { ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second) defer cancel() - tx, err := conn.BeginTx(ctx, nil) + tx, err := db.BeginTx(ctx, nil) if err != nil { err := server.ThrowError(w, r, hws.HWSError{ StatusCode: http.StatusInternalServerError, diff --git a/internal/handler/movie.go b/internal/handler/movie.go index db65316..49dde58 100644 --- a/internal/handler/movie.go +++ b/internal/handler/movie.go @@ -12,7 +12,7 @@ import ( func Movie( server *hws.Server, - config *config.Config, + cfg *config.TMDBConfig, ) http.Handler { return http.HandlerFunc( func(w http.ResponseWriter, r *http.Request) { @@ -31,7 +31,7 @@ func Movie( } return } - movie, err := tmdb.GetMovie(int32(movie_id), config.TMDBToken) + movie, err := tmdb.GetMovie(int32(movie_id), cfg.Token) if err != nil { err := server.ThrowError(w, r, hws.HWSError{ StatusCode: http.StatusInternalServerError, @@ -43,7 +43,7 @@ func Movie( } return } - credits, err := tmdb.GetCredits(int32(movie_id), config.TMDBToken) + credits, err := tmdb.GetCredits(int32(movie_id), cfg.Token) if err != nil { err := server.ThrowError(w, r, hws.HWSError{ StatusCode: http.StatusInternalServerError, @@ -55,7 +55,7 @@ func Movie( } return } - page.Movie(movie, credits, &config.TMDBConfig.Image).Render(r.Context(), w) + page.Movie(movie, credits, &cfg.Config.Image).Render(r.Context(), w) }, ) } diff --git a/internal/handler/movie_search.go b/internal/handler/movie_search.go index d173309..1cfa674 100644 --- a/internal/handler/movie_search.go +++ b/internal/handler/movie_search.go @@ -11,7 +11,7 @@ import ( ) func SearchMovies( - config *config.Config, + cfg *config.TMDBConfig, logger *hlog.Logger, ) http.Handler { return http.HandlerFunc( @@ -22,12 +22,12 @@ func SearchMovies( w.WriteHeader(http.StatusOK) return } - movies, err := tmdb.SearchMovies(config.TMDBToken, query, false, 1) + movies, err := tmdb.SearchMovies(cfg.Token, query, false, 1) if err != nil { w.WriteHeader(http.StatusInternalServerError) return } - search.MovieResults(movies, &config.TMDBConfig.Image).Render(r.Context(), w) + search.MovieResults(movies, &cfg.Config.Image).Render(r.Context(), w) }, ) } diff --git a/internal/handler/reauthenticatate.go b/internal/handler/reauthenticatate.go index 2d872d0..72395e0 100644 --- a/internal/handler/reauthenticatate.go +++ b/internal/handler/reauthenticatate.go @@ -2,28 +2,30 @@ package handler import ( "context" - "database/sql" "net/http" "time" - "git.haelnorr.com/h/golib/hws" - "git.haelnorr.com/h/golib/hwsauth" "projectreshoot/internal/models" "projectreshoot/internal/view/component/form" + "git.haelnorr.com/h/golib/hws" + "git.haelnorr.com/h/golib/hwsauth" + "github.com/pkg/errors" + "github.com/uptrace/bun" ) // Validate the provided password func validatePassword( - auth *hwsauth.Authenticator[*models.User], - tx *sql.Tx, + ctx context.Context, + auth *hwsauth.Authenticator[*models.UserBun, bun.Tx], + tx bun.Tx, r *http.Request, ) error { r.ParseForm() password := r.FormValue("password") user := auth.CurrentModel(r.Context()) - err := user.CheckPassword(tx, password) + err := user.CheckPassword(ctx, tx, password) if err != nil { return errors.Wrap(err, "user.CheckPassword") } @@ -33,8 +35,8 @@ func validatePassword( // Handle request to reauthenticate (i.e. make token fresh again) func Reauthenticate( server *hws.Server, - auth *hwsauth.Authenticator[*models.User], - conn *sql.DB, + auth *hwsauth.Authenticator[*models.UserBun, bun.Tx], + db *bun.DB, ) http.Handler { return http.HandlerFunc( func(w http.ResponseWriter, r *http.Request) { @@ -42,7 +44,7 @@ func Reauthenticate( defer cancel() // Start the transaction - tx, err := conn.BeginTx(ctx, nil) + tx, err := db.BeginTx(ctx, nil) if err != nil { err := server.ThrowError(w, r, hws.HWSError{ StatusCode: http.StatusInternalServerError, @@ -55,7 +57,7 @@ func Reauthenticate( return } defer tx.Rollback() - err = validatePassword(auth, tx, r) + err = validatePassword(ctx, auth, tx, r) if err != nil { w.WriteHeader(445) form.ConfirmPassword("Incorrect password").Render(r.Context(), w) diff --git a/internal/handler/register.go b/internal/handler/register.go index 2b04067..efee5d2 100644 --- a/internal/handler/register.go +++ b/internal/handler/register.go @@ -2,30 +2,30 @@ package handler import ( "context" - "database/sql" "net/http" "time" - "projectreshoot/internal/config" "projectreshoot/internal/models" "projectreshoot/internal/view/component/form" "projectreshoot/internal/view/page" "git.haelnorr.com/h/golib/cookies" - "git.haelnorr.com/h/golib/hlog" - "git.haelnorr.com/h/golib/jwt" + "git.haelnorr.com/h/golib/hws" + "git.haelnorr.com/h/golib/hwsauth" "github.com/pkg/errors" + "github.com/uptrace/bun" ) func validateRegistration( - tx *sql.Tx, + ctx context.Context, + tx bun.Tx, r *http.Request, -) (*models.User, error) { +) (*models.UserBun, error) { formUsername := r.FormValue("username") formPassword := r.FormValue("password") formConfirmPassword := r.FormValue("confirm-password") - unique, err := models.CheckUsernameUnique(tx, formUsername) + unique, err := models.IsUsernameUnique(ctx, tx, formUsername) if err != nil { return nil, errors.Wrap(err, "models.CheckUsernameUnique") } @@ -38,7 +38,7 @@ func validateRegistration( if len(formPassword) > 72 { return nil, errors.New("Password exceeds maximum length of 72 bytes") } - user, err := models.CreateNewUser(tx, formUsername, formPassword) + user, err := models.CreateUser(ctx, tx, formUsername, formPassword) if err != nil { return nil, errors.Wrap(err, "models.CreateNewUser") } @@ -47,10 +47,9 @@ func validateRegistration( } func RegisterRequest( - config *config.Config, - logger *hlog.Logger, - conn *sql.DB, - tokenGen *jwt.TokenGenerator, + server *hws.Server, + auth *hwsauth.Authenticator[*models.UserBun, bun.Tx], + db *bun.DB, ) http.Handler { return http.HandlerFunc( func(w http.ResponseWriter, r *http.Request) { @@ -58,21 +57,33 @@ func RegisterRequest( defer cancel() // Start the transaction - tx, err := conn.BeginTx(ctx, nil) + tx, err := db.BeginTx(ctx, nil) if err != nil { - logger.Warn().Err(err).Msg("Failed to set token cookies") - w.WriteHeader(http.StatusServiceUnavailable) + err := server.ThrowError(w, r, hws.HWSError{ + StatusCode: http.StatusServiceUnavailable, + Message: "Failed to start transaction", + Error: err, + }) + if err != nil { + server.ThrowFatal(w, err) + } return } r.ParseForm() - user, err := validateRegistration(tx, r) + user, err := validateRegistration(ctx, tx, r) if err != nil { tx.Rollback() if err.Error() != "Username is taken" && err.Error() != "Passwords do not match" && err.Error() != "Password exceeds maximum length of 72 bytes" { - logger.Warn().Caller().Err(err).Msg("Registration request failed") - w.WriteHeader(http.StatusInternalServerError) + err := server.ThrowError(w, r, hws.HWSError{ + StatusCode: http.StatusInternalServerError, + Message: "Registration failed", + Error: err, + }) + if err != nil { + server.ThrowFatal(w, err) + } } else { form.RegisterForm(err.Error()).Render(r.Context(), w) } @@ -80,11 +91,17 @@ func RegisterRequest( } rememberMe := checkRememberMe(r) - err = jwt.SetTokenCookies(w, r, tokenGen, user.ID(), true, rememberMe, config.SSL) + err = auth.Login(w, r, user, rememberMe) if err != nil { tx.Rollback() - w.WriteHeader(http.StatusInternalServerError) - logger.Warn().Caller().Err(err).Msg("Failed to set token cookies") + err := server.ThrowError(w, r, hws.HWSError{ + StatusCode: http.StatusInternalServerError, + Message: "Login failed", + Error: err, + }) + if err != nil { + server.ThrowFatal(w, err) + } return } tx.Commit() diff --git a/internal/models/user.go b/internal/models/user.go index 897ca96..754ca0d 100644 --- a/internal/models/user.go +++ b/internal/models/user.go @@ -47,7 +47,7 @@ func (user *User) CheckPassword(tx *sql.Tx, password string) error { } err = bcrypt.CompareHashAndPassword([]byte(hashedPassword), []byte(password)) if err != nil { - return errors.Wrap(err, "bcrypt.CompareHashAndPassword") + return errors.Wrap(err, "Username or password incorrect") } return nil } diff --git a/internal/models/user_bun.go b/internal/models/user_bun.go new file mode 100644 index 0000000..04db1c5 --- /dev/null +++ b/internal/models/user_bun.go @@ -0,0 +1,163 @@ +package models + +import ( + "context" + + "github.com/pkg/errors" + "github.com/uptrace/bun" + "golang.org/x/crypto/bcrypt" +) + +type UserBun 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 *UserBun) GetID() int { + return user.ID +} + +// Uses bcrypt to set the users password_hash from the given password +func (user *UserBun) 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 *UserBun) 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 *UserBun) 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 *UserBun) 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) (*UserBun, error) { + hashedPassword, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) + if err != nil { + return nil, errors.Wrap(err, "bcrypt.GenerateFromPassword") + } + + user := &UserBun{ + 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) (*UserBun, error) { + user := new(UserBun) + 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) (*UserBun, error) { + user := new(UserBun) + 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((*UserBun)(nil)). + Where("username = ?", username). + Count(ctx) + if err != nil { + return false, errors.Wrap(err, "tx.Count") + } + return count == 0, nil +} diff --git a/internal/models/user_functions.go b/internal/models/user_functions.go index d8e7db8..28a068b 100644 --- a/internal/models/user_functions.go +++ b/internal/models/user_functions.go @@ -4,6 +4,7 @@ import ( "database/sql" "fmt" + "git.haelnorr.com/h/golib/hwsauth" "github.com/pkg/errors" ) @@ -31,7 +32,9 @@ func CreateNewUser( // Fetches data from the users table using "WHERE column = 'value'" func fetchUserData( - tx *sql.Tx, + tx interface { + Query(query string, args ...any) (*sql.Rows, error) + }, column string, value any, ) (*sql.Rows, error) { @@ -87,7 +90,7 @@ func GetUserFromUsername(tx *sql.Tx, username string) (*User, error) { } // Queries the database for a user matching the given ID. -func GetUserFromID(tx *sql.Tx, id int) (*User, error) { +func GetUserFromID(tx hwsauth.DBTransaction, id int) (*User, error) { rows, err := fetchUserData(tx, "id", id) if err != nil { return nil, errors.Wrap(err, "fetchUserData") diff --git a/pkg/contexts/currentuser.go b/pkg/contexts/currentuser.go index 0b7105f..36f91b4 100644 --- a/pkg/contexts/currentuser.go +++ b/pkg/contexts/currentuser.go @@ -6,4 +6,4 @@ import ( "git.haelnorr.com/h/golib/hwsauth" ) -var CurrentUser hwsauth.ContextLoader[*models.User] +var CurrentUser hwsauth.ContextLoader[*models.UserBun] diff --git a/pkg/embedfs/files/css/output.css b/pkg/embedfs/files/css/output.css index 3942b82..a43eefa 100644 --- a/pkg/embedfs/files/css/output.css +++ b/pkg/embedfs/files/css/output.css @@ -273,9 +273,6 @@ .ms-3 { margin-inline-start: calc(var(--spacing) * 3); } - .mt-0 { - margin-top: calc(var(--spacing) * 0); - } .mt-0\.5 { margin-top: calc(var(--spacing) * 0.5); } @@ -449,21 +446,12 @@ .flex-1 { flex: 1; } - .flex-shrink { - flex-shrink: 1; - } .shrink-0 { flex-shrink: 0; } - .flex-grow { - flex-grow: 1; - } .grow { flex-grow: 1; } - .border-collapse { - border-collapse: collapse; - } .translate-x-0 { --tw-translate-x: calc(var(--spacing) * 0); translate: var(--tw-translate-x) var(--tw-translate-y); @@ -478,9 +466,6 @@ .cursor-pointer { cursor: pointer; } - .resize { - resize: both; - } .flex-col { flex-direction: column; } @@ -852,10 +837,6 @@ --tw-shadow-color: color-mix(in oklab, var(--color-black) var(--tw-shadow-alpha), transparent); } } - .outline { - outline-style: var(--tw-outline-style); - outline-width: 1px; - } .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)); @@ -1630,11 +1611,6 @@ inherits: false; initial-value: 0 0 #0000; } -@property --tw-outline-style { - syntax: "*"; - inherits: false; - initial-value: solid; -} @property --tw-duration { syntax: "*"; inherits: false; @@ -1663,6 +1639,11 @@ inherits: false; initial-value: 1; } +@property --tw-outline-style { + syntax: "*"; + inherits: false; + initial-value: solid; +} @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 { @@ -1695,13 +1676,13 @@ --tw-ring-offset-width: 0px; --tw-ring-offset-color: #fff; --tw-ring-offset-shadow: 0 0 #0000; - --tw-outline-style: solid; --tw-duration: initial; --tw-ease: initial; --tw-content: ""; --tw-scale-x: 1; --tw-scale-y: 1; --tw-scale-z: 1; + --tw-outline-style: solid; } } }