Compare commits

7 Commits

63 changed files with 1707 additions and 1500 deletions

View File

@@ -0,0 +1,45 @@
package main
import (
"context"
"projectreshoot/internal/config"
"projectreshoot/internal/handler"
"projectreshoot/internal/models"
"projectreshoot/pkg/contexts"
"git.haelnorr.com/h/golib/hlog"
"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,
db *bun.DB,
server *hws.Server,
ignoredPaths []string,
) (*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(
config.HWSAuth,
models.GetUserByID,
server,
beginTx,
logger,
handler.ErrorPage,
)
if err != nil {
return nil, errors.Wrap(err, "hwsauth.NewAuthenticator")
}
auth.IgnorePaths(ignoredPaths...)
contexts.CurrentUser = auth.CurrentModel
return auth, nil
}

52
cmd/projectreshoot/db.go Normal file
View File

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

View File

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

View File

@@ -7,24 +7,18 @@ import (
func setupFlags() map[string]string { func setupFlags() map[string]string {
// Parse commandline args // Parse commandline args
host := flag.String("host", "", "Override host to listen on") resetDB := flag.Bool("resetdb", false, "Reset all the database tables with the updated models")
port := flag.String("port", "", "Override port to listen on") printEnv := flag.Bool("printenv", false, "Print all environment variables and their documentation")
test := flag.Bool("test", false, "Run server in test mode") genEnv := flag.String("genenv", "", "Generate a .env file with all environment variables (specify filename)")
tester := flag.Bool("tester", false, "Run tester function instead of main program") envfile := flag.String("envfile", ".env", "Specify a .env file to use for the configuration")
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)")
flag.Parse() flag.Parse()
// Map the args for easy access // Map the args for easy access
args := map[string]string{ args := map[string]string{
"host": *host, "resetdb": strconv.FormatBool(*resetDB),
"port": *port, "printenv": strconv.FormatBool(*printEnv),
"test": strconv.FormatBool(*test), "genenv": *genEnv,
"tester": strconv.FormatBool(*tester), "envfile": *envfile,
"dbver": strconv.FormatBool(*dbver),
"loglevel": *loglevel,
"logoutput": *logoutput,
} }
return args return args
} }

View File

@@ -0,0 +1,67 @@
package main
import (
"io/fs"
"net/http"
"projectreshoot/internal/config"
"projectreshoot/internal/handler"
"git.haelnorr.com/h/golib/hws"
"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, logger, bun, httpServer, ignoredPaths)
if err != nil {
return nil, errors.Wrap(err, "setupAuth")
}
err = httpServer.AddErrorPage(handler.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
}

View File

@@ -0,0 +1,47 @@
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(cfg *config.HLOGConfig, w *io.Writer) (*hlog.Logger, error) {
// Setup the logfile
var logfile *os.File = nil
if cfg.LogOutput == "both" || cfg.LogOutput == "file" {
logfile, err := hlog.NewLogFile(cfg.LogDir)
if err != nil {
return nil, errors.Wrap(err, "hlog")
}
defer logfile.Close()
}
// Setup the console writer
var consoleWriter io.Writer
if cfg.LogOutput == "both" || cfg.LogOutput == "console" {
if w != nil {
consoleWriter = *w
} else {
if cfg.LogOutput == "console" {
return nil, errors.New("Console logging specified as sole method but no writer provided")
}
}
}
// Setup the logger
logger, err := hlog.NewLogger(
cfg.LogLevel,
consoleWriter,
logfile,
cfg.LogDir,
)
if err != nil {
return nil, errors.Wrap(err, "hlog")
}
return logger, nil
}

View File

@@ -4,12 +4,41 @@ import (
"context" "context"
"fmt" "fmt"
"os" "os"
"projectreshoot/internal/config"
"github.com/pkg/errors"
) )
func main() { func main() {
args := setupFlags() args := setupFlags()
ctx := context.Background() ctx := context.Background()
if err := run(ctx, os.Stdout, args); err != nil {
// 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, cfg); err != nil {
fmt.Fprintf(os.Stderr, "%s\n", err) fmt.Fprintf(os.Stderr, "%s\n", err)
os.Exit(1) os.Exit(1)
} }

View File

@@ -0,0 +1,25 @@
package main
import (
"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.UserBun, bun.Tx],
) error {
err := server.AddMiddleware(
auth.Authenticate(),
)
if err != nil {
return errors.Wrap(err, "server.AddMiddleware")
}
return nil
}

View File

@@ -0,0 +1,126 @@
package main
import (
"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"
"github.com/pkg/errors"
"github.com/uptrace/bun"
)
func addRoutes(
server *hws.Server,
staticFS *http.FileSystem,
config *config.Config,
logger *hlog.Logger,
db *bun.DB,
auth *hwsauth.Authenticator[*models.UserBun, bun.Tx],
) error {
// Create the routes
routes := []hws.Route{
{
Path: "/static/",
Method: hws.MethodGET,
Handler: http.StripPrefix("/static/", handler.StaticFS(staticFS, logger)),
},
{
Path: "/",
Method: hws.MethodGET,
Handler: handler.Root(),
},
{
Path: "/about",
Method: hws.MethodGET,
Handler: handler.HandlePage(page.About()),
},
{
Path: "/login",
Method: hws.MethodGET,
Handler: auth.LogoutReq(handler.LoginPage(config.HWSAuth.TrustedHost)),
},
{
Path: "/login",
Method: hws.MethodPOST,
Handler: auth.LogoutReq(handler.LoginRequest(server, auth, db)),
},
{
Path: "/register",
Method: hws.MethodGET,
Handler: auth.LogoutReq(handler.RegisterPage(config.HWSAuth.TrustedHost)),
},
{
Path: "/register",
Method: hws.MethodPOST,
Handler: auth.LogoutReq(handler.RegisterRequest(server, auth, db)),
},
{
Path: "/logout",
Method: hws.MethodPOST,
Handler: handler.Logout(server, auth, db),
},
{
Path: "/reauthenticate",
Method: hws.MethodPOST,
Handler: auth.LoginReq(handler.Reauthenticate(server, auth, db)),
},
{
Path: "/profile",
Method: hws.MethodGET,
Handler: auth.LoginReq(handler.ProfilePage()),
},
{
Path: "/account",
Method: hws.MethodGET,
Handler: auth.LoginReq(handler.AccountPage()),
},
{
Path: "/account-select-page",
Method: hws.MethodPOST,
Handler: auth.LoginReq(handler.AccountSubpage()),
},
{
Path: "/change-username",
Method: hws.MethodPOST,
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, db))),
},
{
Path: "/change-bio",
Method: hws.MethodPOST,
Handler: auth.LoginReq(handler.ChangeBio(server, auth, db)),
},
{
Path: "/movies",
Method: hws.MethodGET,
Handler: handler.MoviesPage(),
},
{
Path: "/search-movies",
Method: hws.MethodPOST,
Handler: handler.SearchMovies(config.TMDB, logger),
},
{
Path: "/movie/{movie_id}",
Method: hws.MethodGET,
Handler: handler.Movie(server, config.TMDB),
},
}
// Register the routes with the server
err := server.AddRoutes(routes...)
if err != nil {
return errors.Wrap(err, "server.AddRoutes")
}
return nil
}

View File

@@ -2,76 +2,40 @@ package main
import ( import (
"context" "context"
"fmt"
"io" "io"
"net/http"
"os" "os"
"os/signal" "os/signal"
"projectreshoot/internal/httpserver" "projectreshoot/internal/config"
"projectreshoot/pkg/config"
"projectreshoot/pkg/embedfs" "projectreshoot/pkg/embedfs"
"strconv"
"sync" "sync"
"time" "time"
"git.haelnorr.com/h/golib/hlog"
"git.haelnorr.com/h/golib/jwt"
"github.com/pkg/errors" "github.com/pkg/errors"
) )
var maint uint32 // atomic: 1 if in maintenance mode
// Initializes and runs the server // Initializes and runs the server
func run(ctx context.Context, w io.Writer, args map[string]string) error { func run(ctx context.Context, w io.Writer, args map[string]string, config *config.Config) error {
ctx, cancel := signal.NotifyContext(ctx, os.Interrupt) ctx, cancel := signal.NotifyContext(ctx, os.Interrupt)
defer cancel() defer cancel()
config, err := config.GetConfig(args) logger, err := setupLogger(config.HLOG, &w)
if err != nil { if err != nil {
return errors.Wrap(err, "server.GetConfig") return errors.Wrap(err, "setupLogger")
}
// Return the version of the database required
if args["dbver"] == "true" {
fmt.Fprintf(w, "Database version: %s\n", config.DBName)
return nil
}
// Setup the logfile
var logfile *os.File = nil
if config.LogOutput == "both" || config.LogOutput == "file" {
logfile, err = hlog.NewLogFile(config.LogDir)
if err != nil {
return errors.Wrap(err, "logging.GetLogFile")
}
defer logfile.Close()
}
// Setup the console writer
var consoleWriter io.Writer
if config.LogOutput == "both" || config.LogOutput == "console" {
consoleWriter = w
}
// Setup the logger
logger, err := hlog.NewLogger(
config.LogLevel,
consoleWriter,
logfile,
config.LogDir,
)
if err != nil {
return errors.Wrap(err, "logging.GetLogger")
} }
// Setup the database connection // Setup the database connection
logger.Debug().Msg("Config loaded and logger started") logger.Debug().Msg("Config loaded and logger started")
logger.Debug().Msg("Connecting to database") 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 { if err != nil {
return errors.Wrap(err, "setupDBConn") return errors.Wrap(err, "setupDBConn")
} }
defer conn.Close() defer closedb()
// Setup embedded files // Setup embedded files
logger.Debug().Msg("Getting embedded files") logger.Debug().Msg("Getting embedded files")
@@ -80,31 +44,18 @@ func run(ctx context.Context, w io.Writer, args map[string]string) error {
return errors.Wrap(err, "getStaticFiles") 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") logger.Debug().Msg("Setting up HTTP server")
httpServer := httpserver.NewServer(config, logger, conn, tokenGen, &staticFS, &maint) httpServer, err := setupHttpServer(&staticFS, config, logger, bun)
if err != nil {
// Setups a channel to listen for os.Signal return errors.Wrap(err, "setupHttpServer")
handleMaintSignals(httpServer, logger) }
// Runs the http server // Runs the http server
logger.Debug().Msg("Starting up the HTTP server") logger.Debug().Msg("Starting up the HTTP server")
go func() { err = httpServer.Start(ctx)
logger.Info().Str("address", httpServer.Addr).Msg("Listening for requests") if err != nil {
if err := httpServer.ListenAndServe(); err != nil && err != http.ErrServerClosed { return errors.Wrap(err, "httpServer.Start")
logger.Error().Err(err).Msg("Error listening and serving")
} }
}()
// Handles graceful shutdown // Handles graceful shutdown
var wg sync.WaitGroup var wg sync.WaitGroup
@@ -113,8 +64,9 @@ func run(ctx context.Context, w io.Writer, args map[string]string) error {
shutdownCtx := context.Background() shutdownCtx := context.Background()
shutdownCtx, cancel := context.WithTimeout(shutdownCtx, 10*time.Second) shutdownCtx, cancel := context.WithTimeout(shutdownCtx, 10*time.Second)
defer cancel() defer cancel()
if err := httpServer.Shutdown(shutdownCtx); err != nil { err := httpServer.Shutdown(shutdownCtx)
logger.Error().Err(err).Msg("Error shutting down server") if err != nil {
logger.Error().Err(err).Msg("Graceful shutdown failed")
} }
}) })
wg.Wait() wg.Wait()

View File

@@ -1,41 +0,0 @@
package main
import (
"net/http"
"os"
"os/signal"
"sync/atomic"
"syscall"
"git.haelnorr.com/h/golib/hlog"
)
// Handle SIGUSR1 and SIGUSR2 syscalls to toggle maintenance mode
func handleMaintSignals(
srv *http.Server,
logger *hlog.Logger,
) {
logger.Debug().Msg("Starting signal listener")
ch := make(chan os.Signal, 1)
srv.RegisterOnShutdown(func() {
logger.Debug().Msg("Shutting down signal listener")
close(ch)
})
go func() {
for sig := range ch {
switch sig {
case syscall.SIGUSR1:
if atomic.LoadUint32(&maint) != 1 {
atomic.StoreUint32(&maint, 1)
logger.Info().Msg("Signal received: Starting maintenance")
}
case syscall.SIGUSR2:
if atomic.LoadUint32(&maint) != 0 {
logger.Info().Msg("Signal received: Maintenance over")
atomic.StoreUint32(&maint, 0)
}
}
}
}()
signal.Notify(ch, syscall.SIGUSR1, syscall.SIGUSR2)
}

30
go.mod
View File

@@ -3,35 +3,51 @@ module projectreshoot
go 1.25.5 go 1.25.5
require ( require (
git.haelnorr.com/h/golib/hlog v0.9.0 git.haelnorr.com/h/golib/cookies v0.9.0
git.haelnorr.com/h/golib/jwt v0.9.0 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 git.haelnorr.com/h/golib/tmdb v0.8.0
github.com/a-h/templ v0.3.977 github.com/a-h/templ v0.3.977
github.com/joho/godotenv v1.5.1 github.com/joho/godotenv v1.5.1
github.com/mattn/go-sqlite3 v1.14.24
github.com/pkg/errors v0.9.1 github.com/pkg/errors v0.9.1
github.com/pressly/goose/v3 v3.24.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 modernc.org/sqlite v1.35.0
) )
replace git.haelnorr.com/h/golib/jwt v0.9.0 => /home/haelnorr/projects/golib/jwt
require ( require (
git.haelnorr.com/h/golib/jwt v0.10.0 // indirect
github.com/dustin/go-humanize v1.0.1 // 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/golang-jwt/jwt v3.2.2+incompatible // indirect
github.com/google/uuid v1.6.0 // 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-colorable v0.1.14 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mfridman/interpolate v0.0.2 // indirect github.com/mfridman/interpolate v0.0.2 // indirect
github.com/ncruces/go-strftime v0.1.9 // 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/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/rs/zerolog v1.34.0 // indirect github.com/rs/zerolog v1.34.0 // indirect
github.com/sethvargo/go-retry v0.3.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 go.uber.org/multierr v1.11.0 // indirect
golang.org/x/exp v0.0.0-20240325151524-a685a6edb6d8 // indirect golang.org/x/exp v0.0.0-20240325151524-a685a6edb6d8 // indirect
golang.org/x/sync v0.16.0 // 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-20260108192941-914a6e750570 // indirect
mellium.im/sasl v0.3.2 // indirect
modernc.org/libc v1.61.13 // indirect modernc.org/libc v1.61.13 // indirect
modernc.org/mathutil v1.7.1 // indirect modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.8.2 // indirect modernc.org/memory v1.8.2 // indirect

58
go.sum
View File

@@ -1,5 +1,15 @@
git.haelnorr.com/h/golib/hlog v0.9.0 h1:ib8n2MdmiRK2TF067p220kXmhDe9aAnlcsgpuv+QpvE= git.haelnorr.com/h/golib/cookies v0.9.0 h1:Vf+eX1prHkKuGrQon1BHY87yaPc1H+HJFRXDOV/AuWs=
git.haelnorr.com/h/golib/hlog v0.9.0/go.mod h1:oOlzb8UVHUYP1k7dN5PSJXVskAB2z8EYgRN85jAi0Zk= 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/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 h1:OQ6M2TB8FHm8fJD7/ebfWm63Duzfp0kmFX9genEig34=
git.haelnorr.com/h/golib/tmdb v0.8.0/go.mod h1:mGKYa3o3z0IsQ5EO3MPmnL2Bwl2sSMsUHXVgaIGR7Z0= 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= github.com/DATA-DOG/go-sqlmock v1.5.2 h1:OcvFkGmslmlZibjAjaHm3L//6LiuBgolP7OputlJIzU=
@@ -11,15 +21,19 @@ 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/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
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/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 h1:IfV12K8xAKAnZqdXVzCZ+TOjboZ2keLg81eXfW3O+oY=
github.com/golang-jwt/jwt v3.2.2+incompatible/go.mod h1:8pz2t5EyA70fFQQSrl6XZXzqecmYZeUEB8OUGHkxJ+I= github.com/golang-jwt/jwt v3.2.2+incompatible/go.mod h1:8pz2t5EyA70fFQQSrl6XZXzqecmYZeUEB8OUGHkxJ+I=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd h1:gbpYu9NMq8jhDVbvlGkMFWCjLFlqqEZjEmObmhUy6Vo= github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd h1:gbpYu9NMq8jhDVbvlGkMFWCjLFlqqEZjEmObmhUy6Vo=
github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd/go.mod h1:kf6iHlnVGwgKolg33glAes7Yg/8iWP8ukqeldJSO7jw= 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 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= 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 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= 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.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
@@ -29,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.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 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 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 h1:pnuTK7MQIxxFz1Gr+rjSIx9u7qVjf5VOoM/u6BbAxPY=
github.com/mfridman/interpolate v0.0.2/go.mod h1:p+7uk6oE07mpE/Ik1b8EckO0O4ZXiGAfshKBWLUM9Xg= github.com/mfridman/interpolate v0.0.2/go.mod h1:p+7uk6oE07mpE/Ik1b8EckO0O4ZXiGAfshKBWLUM9Xg=
github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4= github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
@@ -41,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/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 h1:bZmxRco2uy5uu5Ng1MMVEfYsFlrMJI+e/VMXHQ3C4LY=
github.com/pressly/goose/v3 v3.24.1/go.mod h1:rEWreU9uVtt0DHCyLzF9gRcWiiTF/V+528DV+4DORug= 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 h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= 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= github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0=
@@ -50,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/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 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= 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 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= 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.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q=
golang.org/x/crypto v0.33.0/go.mod h1:bVdXmD7IV/4GdElGPozy6U7lWdRXA4qyRVGJV57uQ5M= 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 h1:aAcj0Da7eBAtrTp03QXWvm88pSyOt+UgdZw2BFZ+lEw=
golang.org/x/exp v0.0.0-20240325151524-a685a6edb6d8/go.mod h1:CQ1k9gNrJ50XIzaKCRR2hssIjF07kZFEiieALBM/ARQ= 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= golang.org/x/mod v0.26.0 h1:EGMPT//Ezu+ylkCijjPc+f4Aih7sZvaAr+O3EHBxvZg=
@@ -63,12 +93,20 @@ 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.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.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.12.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.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ=
golang.org/x/sys v0.34.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= 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 h1:mBffYraMEf7aa0sB+NuKnuCy8qI/9Bughn8dC2Gu5r0=
golang.org/x/tools v0.35.0/go.mod h1:NKdj5HkL/73byiZSJjqJgKn3ep7KjFkBOkR/Hps3VPw= golang.org/x/tools v0.35.0/go.mod h1:NKdj5HkL/73byiZSJjqJgKn3ep7KjFkBOkR/Hps3VPw=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 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=
modernc.org/cc/v4 v4.24.4 h1:TFkx1s6dCkQpd6dKurBNmpo+G8Zl4Sq/ztJ+2+DEsh0= 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/cc/v4 v4.24.4/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
modernc.org/ccgo/v4 v4.23.16 h1:Z2N+kk38b7SfySC1ZkpGLN2vthNJP1+ZzGZIlH7uBxo= modernc.org/ccgo/v4 v4.23.16 h1:Z2N+kk38b7SfySC1ZkpGLN2vthNJP1+ZzGZIlH7uBxo=

37
internal/config/auth.go Normal file
View File

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

56
internal/config/config.go Normal file
View File

@@ -0,0 +1,56 @@
package config
import (
"git.haelnorr.com/h/golib/hws"
"git.haelnorr.com/h/golib/hwsauth"
"github.com/joho/godotenv"
"github.com/pkg/errors"
)
type Config struct {
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(envfile string) (*Config, error) {
godotenv.Load(envfile)
db, err := setupDB()
if err != nil {
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{
DB: db,
HWS: hws,
HWSAuth: hwsAuth,
TMDB: tmdb,
HLOG: hlog,
}
return config, nil
}

55
internal/config/db.go Normal file
View File

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

114
internal/config/envdoc.go Normal file
View File

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

95
internal/config/envgen.go Normal file
View File

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

View File

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

View File

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

36
internal/config/logger.go Normal file
View File

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

28
internal/config/tmdb.go Normal file
View File

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

View File

@@ -2,19 +2,20 @@ package handler
import ( import (
"context" "context"
"database/sql"
"net/http" "net/http"
"time" "time"
"projectreshoot/internal/models" "projectreshoot/internal/models"
"projectreshoot/internal/view/component/account" "projectreshoot/internal/view/component/account"
"projectreshoot/internal/view/page" "projectreshoot/internal/view/page"
"projectreshoot/pkg/contexts"
"projectreshoot/pkg/cookies"
"git.haelnorr.com/h/golib/hlog" "git.haelnorr.com/h/golib/hws"
"git.haelnorr.com/h/golib/hwsauth"
"git.haelnorr.com/h/golib/cookies"
"github.com/pkg/errors" "github.com/pkg/errors"
"github.com/uptrace/bun"
) )
// Renders the account page on the 'General' subpage // Renders the account page on the 'General' subpage
@@ -45,8 +46,9 @@ func AccountSubpage() http.Handler {
// Handles a request to change the users username // Handles a request to change the users username
func ChangeUsername( func ChangeUsername(
logger *hlog.Logger, server *hws.Server,
conn *sql.DB, auth *hwsauth.Authenticator[*models.UserBun, bun.Tx],
db *bun.DB,
) http.Handler { ) http.Handler {
return http.HandlerFunc( return http.HandlerFunc(
func(w http.ResponseWriter, r *http.Request) { func(w http.ResponseWriter, r *http.Request) {
@@ -54,19 +56,31 @@ func ChangeUsername(
defer cancel() defer cancel()
// Start the transaction // Start the transaction
tx, err := conn.BeginTx(ctx, nil) tx, err := db.BeginTx(ctx, nil)
if err != nil { if err != nil {
logger.Warn().Err(err).Msg("Error updating username") err := server.ThrowError(w, r, hws.HWSError{
w.WriteHeader(http.StatusServiceUnavailable) StatusCode: http.StatusServiceUnavailable,
Message: "Error updating username",
Error: err,
})
if err != nil {
server.ThrowFatal(w, err)
}
return return
} }
r.ParseForm() r.ParseForm()
newUsername := r.FormValue("username") newUsername := r.FormValue("username")
unique, err := models.CheckUsernameUnique(tx, newUsername) unique, err := models.IsUsernameUnique(ctx, tx, newUsername)
if err != nil { if err != nil {
tx.Rollback() tx.Rollback()
logger.Error().Err(err).Msg("Error updating username") err := server.ThrowError(w, r, hws.HWSError{
w.WriteHeader(http.StatusInternalServerError) StatusCode: http.StatusInternalServerError,
Message: "Error updating username",
Error: err,
})
if err != nil {
server.ThrowFatal(w, err)
}
return return
} }
if !unique { if !unique {
@@ -75,12 +89,18 @@ func ChangeUsername(
Render(r.Context(), w) Render(r.Context(), w)
return return
} }
user := contexts.GetUser(r.Context()) user := auth.CurrentModel(r.Context())
err = user.ChangeUsername(tx, newUsername) err = user.ChangeUsername(ctx, tx, newUsername)
if err != nil { if err != nil {
tx.Rollback() tx.Rollback()
logger.Error().Err(err).Msg("Error updating username") err := server.ThrowError(w, r, hws.HWSError{
w.WriteHeader(http.StatusInternalServerError) StatusCode: http.StatusInternalServerError,
Message: "Error updating username",
Error: err,
})
if err != nil {
server.ThrowFatal(w, err)
}
return return
} }
tx.Commit() tx.Commit()
@@ -91,8 +111,9 @@ func ChangeUsername(
// Handles a request to change the users bio // Handles a request to change the users bio
func ChangeBio( func ChangeBio(
logger *hlog.Logger, server *hws.Server,
conn *sql.DB, auth *hwsauth.Authenticator[*models.UserBun, bun.Tx],
db *bun.DB,
) http.Handler { ) http.Handler {
return http.HandlerFunc( return http.HandlerFunc(
func(w http.ResponseWriter, r *http.Request) { func(w http.ResponseWriter, r *http.Request) {
@@ -100,10 +121,16 @@ func ChangeBio(
defer cancel() defer cancel()
// Start the transaction // Start the transaction
tx, err := conn.BeginTx(ctx, nil) tx, err := db.BeginTx(ctx, nil)
if err != nil { if err != nil {
logger.Warn().Err(err).Msg("Error updating bio") err := server.ThrowError(w, r, hws.HWSError{
w.WriteHeader(http.StatusServiceUnavailable) StatusCode: http.StatusServiceUnavailable,
Message: "Error updating bio",
Error: err,
})
if err != nil {
server.ThrowFatal(w, err)
}
return return
} }
r.ParseForm() r.ParseForm()
@@ -115,12 +142,18 @@ func ChangeBio(
Render(r.Context(), w) Render(r.Context(), w)
return return
} }
user := contexts.GetUser(r.Context()) user := auth.CurrentModel(r.Context())
err = user.ChangeBio(tx, newBio) err = user.ChangeBio(ctx, tx, newBio)
if err != nil { if err != nil {
tx.Rollback() tx.Rollback()
logger.Error().Err(err).Msg("Error updating bio") err := server.ThrowError(w, r, hws.HWSError{
w.WriteHeader(http.StatusInternalServerError) StatusCode: http.StatusInternalServerError,
Message: "Error updating bio",
Error: err,
})
if err != nil {
server.ThrowFatal(w, err)
}
return return
} }
tx.Commit() tx.Commit()
@@ -145,8 +178,9 @@ func validateChangePassword(
// Handles a request to change the users password // Handles a request to change the users password
func ChangePassword( func ChangePassword(
logger *hlog.Logger, server *hws.Server,
conn *sql.DB, auth *hwsauth.Authenticator[*models.UserBun, bun.Tx],
db *bun.DB,
) http.Handler { ) http.Handler {
return http.HandlerFunc( return http.HandlerFunc(
func(w http.ResponseWriter, r *http.Request) { func(w http.ResponseWriter, r *http.Request) {
@@ -154,10 +188,16 @@ func ChangePassword(
defer cancel() defer cancel()
// Start the transaction // Start the transaction
tx, err := conn.BeginTx(ctx, nil) tx, err := db.BeginTx(ctx, nil)
if err != nil { if err != nil {
logger.Warn().Err(err).Msg("Error updating password") err := server.ThrowError(w, r, hws.HWSError{
w.WriteHeader(http.StatusServiceUnavailable) StatusCode: http.StatusServiceUnavailable,
Message: "Error updating password",
Error: err,
})
if err != nil {
server.ThrowFatal(w, err)
}
return return
} }
newPass, err := validateChangePassword(r) newPass, err := validateChangePassword(r)
@@ -166,12 +206,18 @@ func ChangePassword(
account.ChangePassword(err.Error()).Render(r.Context(), w) account.ChangePassword(err.Error()).Render(r.Context(), w)
return return
} }
user := contexts.GetUser(r.Context()) user := auth.CurrentModel(r.Context())
err = user.SetPassword(tx, newPass) err = user.SetPassword(ctx, tx, newPass)
if err != nil { if err != nil {
tx.Rollback() tx.Rollback()
logger.Error().Err(err).Msg("Error updating password") err := server.ThrowError(w, r, hws.HWSError{
w.WriteHeader(http.StatusInternalServerError) StatusCode: http.StatusInternalServerError,
Message: "Error updating password",
Error: err,
})
if err != nil {
server.ThrowFatal(w, err)
}
return return
} }
tx.Commit() tx.Commit()

View File

@@ -3,14 +3,16 @@ package handler
import ( import (
"net/http" "net/http"
"projectreshoot/internal/view/page" "projectreshoot/internal/view/page"
"git.haelnorr.com/h/golib/hws"
"github.com/pkg/errors"
) )
func ErrorPage( func ErrorPage(
errorCode int, errorCode int,
w http.ResponseWriter, ) (hws.ErrorPage, error) {
r *http.Request, messages := map[int]string{
) { 400: "The request you made was malformed or unexpected.",
message := map[int]string{
401: "You need to login to view this page.", 401: "You need to login to view this page.",
403: "You do not have permission 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.", 404: "The page or resource you have requested does not exist.",
@@ -18,7 +20,9 @@ func ErrorPage(
continues to happen contact an administrator.`, continues to happen contact an administrator.`,
503: "The server is currently down for maintenance and should be back soon. =)", 503: "The server is currently down for maintenance and should be back soon. =)",
} }
w.WriteHeader(errorCode) msg, exists := messages[errorCode]
page.Error(errorCode, http.StatusText(errorCode), message[errorCode]). if !exists {
Render(r.Context(), w) return nil, errors.New("No valid message for the given code")
}
return page.Error(errorCode, http.StatusText(errorCode), msg), nil
} }

View File

@@ -12,7 +12,14 @@ func Root() http.Handler {
return http.HandlerFunc( return http.HandlerFunc(
func(w http.ResponseWriter, r *http.Request) { func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/" { if r.URL.Path != "/" {
ErrorPage(http.StatusNotFound, w, r) page, err := ErrorPage(http.StatusNotFound)
if err != nil {
// TODO: add logger for this
}
err = page.Render(r.Context(), w)
if err != nil {
// TODO: add logger for this
}
return return
} }
page.Index().Render(r.Context(), w) page.Index().Render(r.Context(), w)

View File

@@ -2,36 +2,45 @@ package handler
import ( import (
"context" "context"
"database/sql"
"net/http" "net/http"
"strings"
"time" "time"
"projectreshoot/internal/models" "projectreshoot/internal/models"
"projectreshoot/internal/view/component/form" "projectreshoot/internal/view/component/form"
"projectreshoot/internal/view/page" "projectreshoot/internal/view/page"
"projectreshoot/pkg/config"
"projectreshoot/pkg/cookies"
"git.haelnorr.com/h/golib/hlog" "git.haelnorr.com/h/golib/hws"
"git.haelnorr.com/h/golib/jwt" "git.haelnorr.com/h/golib/hwsauth"
"git.haelnorr.com/h/golib/cookies"
"github.com/pkg/errors" "github.com/pkg/errors"
"github.com/uptrace/bun"
) )
// Validates the username matches a user in the database and the password // Validates the username matches a user in the database and the password
// is correct. Returns the corresponding user // is correct. Returns the corresponding user
func validateLogin( func validateLogin(
tx *sql.Tx, ctx context.Context,
tx bun.Tx,
r *http.Request, r *http.Request,
) (*models.User, error) { ) (*models.UserBun, error) {
formUsername := r.FormValue("username") formUsername := r.FormValue("username")
formPassword := r.FormValue("password") formPassword := r.FormValue("password")
user, err := models.GetUserFromUsername(tx, formUsername) user, err := models.GetUserByUsername(ctx, tx, formUsername)
if err != nil { if err != nil {
return nil, errors.Wrap(err, "db.GetUserFromUsername") 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 err != nil {
if !strings.Contains(err.Error(), "Username or password incorrect") {
return nil, errors.Wrap(err, "user.CheckPassword")
}
return nil, errors.New("Username or password incorrect") return nil, errors.New("Username or password incorrect")
} }
return user, nil return user, nil
@@ -51,10 +60,9 @@ func checkRememberMe(r *http.Request) bool {
// and on fail will return the login form again, passing the error to the // and on fail will return the login form again, passing the error to the
// template for user feedback // template for user feedback
func LoginRequest( func LoginRequest(
config *config.Config, server *hws.Server,
logger *hlog.Logger, auth *hwsauth.Authenticator[*models.UserBun, bun.Tx],
conn *sql.DB, db *bun.DB,
tokenGen *jwt.TokenGenerator,
) http.Handler { ) http.Handler {
return http.HandlerFunc( return http.HandlerFunc(
func(w http.ResponseWriter, r *http.Request) { func(w http.ResponseWriter, r *http.Request) {
@@ -62,31 +70,49 @@ func LoginRequest(
defer cancel() defer cancel()
// Start the transaction // Start the transaction
tx, err := conn.BeginTx(ctx, nil) tx, err := db.BeginTx(ctx, nil)
if err != nil { if err != nil {
logger.Warn().Err(err).Msg("Failed to set token cookies") err := server.ThrowError(w, r, hws.HWSError{
w.WriteHeader(http.StatusServiceUnavailable) StatusCode: http.StatusServiceUnavailable,
Message: "Login failed",
Error: err,
})
if err != nil {
server.ThrowFatal(w, err)
}
return return
} }
r.ParseForm() r.ParseForm()
user, err := validateLogin(tx, r) user, err := validateLogin(ctx, tx, r)
if err != nil { if err != nil {
tx.Rollback() tx.Rollback()
if err.Error() != "Username or password incorrect" { if err.Error() != "Username or password incorrect" {
logger.Warn().Caller().Err(err).Msg("Login request failed") err := server.ThrowError(w, r, hws.HWSError{
w.WriteHeader(http.StatusInternalServerError) StatusCode: http.StatusInternalServerError,
Message: "Login failed",
Error: err,
})
if err != nil {
server.ThrowFatal(w, err)
}
} else { } else {
form.LoginForm(err.Error()).Render(r.Context(), w) form.LoginForm("Username or password incorrect").Render(r.Context(), w)
} }
return return
} }
rememberMe := checkRememberMe(r) rememberMe := checkRememberMe(r)
err = cookies.SetTokenCookies(w, r, config, tokenGen, user, true, rememberMe) err = auth.Login(w, r, user, rememberMe)
if err != nil { if err != nil {
tx.Rollback() tx.Rollback()
w.WriteHeader(http.StatusInternalServerError) err := server.ThrowError(w, r, hws.HWSError{
logger.Warn().Caller().Err(err).Msg("Failed to set token cookies") StatusCode: http.StatusInternalServerError,
Message: "Login failed",
Error: err,
})
if err != nil {
server.ThrowFatal(w, err)
}
return return
} }

View File

@@ -2,108 +2,53 @@ package handler
import ( import (
"context" "context"
"database/sql"
"net/http" "net/http"
"strings" "projectreshoot/internal/models"
"time" "time"
"projectreshoot/pkg/cookies" "git.haelnorr.com/h/golib/hws"
"git.haelnorr.com/h/golib/hwsauth"
"git.haelnorr.com/h/golib/hlog" "github.com/uptrace/bun"
"git.haelnorr.com/h/golib/jwt"
"github.com/pkg/errors"
) )
func revokeAccess(
tokenGen *jwt.TokenGenerator,
tx *sql.Tx,
atStr string,
) error {
aT, err := tokenGen.ValidateAccess(tx, atStr)
if err != nil {
if strings.Contains(err.Error(), "Token is expired") ||
strings.Contains(err.Error(), "Token has been revoked") {
return nil // Token is expired, dont need to revoke it
}
return errors.Wrap(err, "jwt.ParseAccessToken")
}
err = aT.Revoke(tx)
if err != nil {
return errors.Wrap(err, "jwt.RevokeToken")
}
return nil
}
func revokeRefresh(
tokenGen *jwt.TokenGenerator,
tx *sql.Tx,
rtStr string,
) error {
rT, err := tokenGen.ValidateRefresh(tx, rtStr)
if err != nil {
if strings.Contains(err.Error(), "Token is expired") ||
strings.Contains(err.Error(), "Token has been revoked") {
return nil // Token is expired, dont need to revoke it
}
return errors.Wrap(err, "jwt.ParseRefreshToken")
}
err = rT.Revoke(tx)
if err != nil {
return errors.Wrap(err, "jwt.RevokeToken")
}
return nil
}
// Retrieve and revoke the user's tokens
func revokeTokens(
tokenGen *jwt.TokenGenerator,
tx *sql.Tx,
r *http.Request,
) error {
// get the tokens from the cookies
atStr, rtStr := cookies.GetTokenStrings(r)
// revoke the refresh token first as the access token expires quicker
// only matters if there is an error revoking the tokens
err := revokeRefresh(tokenGen, tx, rtStr)
if err != nil {
return errors.Wrap(err, "revokeRefresh")
}
err = revokeAccess(tokenGen, tx, atStr)
if err != nil {
return errors.Wrap(err, "revokeAccess")
}
return nil
}
// Handle a logout request // Handle a logout request
func Logout( func Logout(
conn *sql.DB, server *hws.Server,
tokenGen *jwt.TokenGenerator, auth *hwsauth.Authenticator[*models.UserBun, bun.Tx],
logger *hlog.Logger, db *bun.DB,
) http.Handler { ) http.Handler {
return http.HandlerFunc( return http.HandlerFunc(
func(w http.ResponseWriter, r *http.Request) { func(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second) ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second)
defer cancel() defer cancel()
tx, err := conn.BeginTx(ctx, nil) tx, err := db.BeginTx(ctx, nil)
if err != nil { if err != nil {
logger.Error().Err(err).Msg("Failed to start database transaction") err := server.ThrowError(w, r, hws.HWSError{
w.WriteHeader(http.StatusInternalServerError) StatusCode: http.StatusInternalServerError,
Message: "Logout failed",
Error: err,
})
if err != nil {
server.ThrowFatal(w, err)
}
return return
} }
defer tx.Rollback() defer tx.Rollback()
err = revokeTokens(tokenGen, tx, r) err = auth.Logout(tx, w, r)
if err != nil { if err != nil {
logger.Error().Err(err).Msg("Error occured on user logout") err := server.ThrowError(w, r, hws.HWSError{
w.WriteHeader(http.StatusInternalServerError) StatusCode: http.StatusInternalServerError,
Message: "Logout failed",
Error: err,
})
if err != nil {
server.ThrowFatal(w, err)
}
return return
} }
tx.Commit() tx.Commit()
cookies.DeleteCookie(w, "access", "/")
cookies.DeleteCookie(w, "refresh", "/")
w.Header().Set("HX-Redirect", "/login") w.Header().Set("HX-Redirect", "/login")
}, },
) )

View File

@@ -2,43 +2,60 @@ package handler
import ( import (
"net/http" "net/http"
"projectreshoot/internal/config"
"projectreshoot/internal/view/page" "projectreshoot/internal/view/page"
"projectreshoot/pkg/config"
"strconv" "strconv"
"git.haelnorr.com/h/golib/hlog" "git.haelnorr.com/h/golib/hws"
"git.haelnorr.com/h/golib/tmdb" "git.haelnorr.com/h/golib/tmdb"
) )
func Movie( func Movie(
logger *hlog.Logger, server *hws.Server,
config *config.Config, cfg *config.TMDBConfig,
) http.Handler { ) http.Handler {
return http.HandlerFunc( return http.HandlerFunc(
func(w http.ResponseWriter, r *http.Request) { func(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("movie_id") id := r.PathValue("movie_id")
movie_id, err := strconv.ParseInt(id, 10, 32) movie_id, err := strconv.ParseInt(id, 10, 32)
if err != nil { if err != nil {
ErrorPage(http.StatusNotFound, w, r) err := server.ThrowError(w, r, hws.HWSError{
logger.Error().Err(err).Str("movie_id", id). StatusCode: http.StatusBadRequest,
Msg("Error occured getting the movie") Message: "Movie ID provided is not valid",
return Error: err,
} Level: hws.ErrorDEBUG,
movie, err := tmdb.GetMovie(int32(movie_id), config.TMDBToken) RenderErrorPage: true,
})
if err != nil { if err != nil {
ErrorPage(http.StatusInternalServerError, w, r) server.ThrowFatal(w, err)
logger.Error().Err(err).Int32("movie_id", int32(movie_id)). }
Msg("Error occured getting the movie")
return return
} }
credits, err := tmdb.GetCredits(int32(movie_id), config.TMDBToken) movie, err := tmdb.GetMovie(int32(movie_id), cfg.Token)
if err != nil { if err != nil {
ErrorPage(http.StatusInternalServerError, w, r) err := server.ThrowError(w, r, hws.HWSError{
logger.Error().Err(err).Int32("movie_id", int32(movie_id)). StatusCode: http.StatusInternalServerError,
Msg("Error occured getting the movie credits") Message: "An error occured when trying to retrieve the requested movie",
Error: err,
})
if err != nil {
server.ThrowFatal(w, err)
}
return return
} }
page.Movie(movie, credits, &config.TMDBConfig.Image).Render(r.Context(), w) credits, err := tmdb.GetCredits(int32(movie_id), cfg.Token)
if err != nil {
err := server.ThrowError(w, r, hws.HWSError{
StatusCode: http.StatusInternalServerError,
Message: "An error occured when trying to retrieve the credits for the requested movie",
Error: err,
})
if err != nil {
server.ThrowFatal(w, err)
}
return
}
page.Movie(movie, credits, &cfg.Config.Image).Render(r.Context(), w)
}, },
) )
} }

View File

@@ -2,17 +2,17 @@ package handler
import ( import (
"net/http" "net/http"
"projectreshoot/internal/config"
"projectreshoot/internal/view/component/search" "projectreshoot/internal/view/component/search"
"projectreshoot/internal/view/page" "projectreshoot/internal/view/page"
"projectreshoot/pkg/config"
"git.haelnorr.com/h/golib/hlog" "git.haelnorr.com/h/golib/hlog"
"git.haelnorr.com/h/golib/tmdb" "git.haelnorr.com/h/golib/tmdb"
) )
func SearchMovies( func SearchMovies(
cfg *config.TMDBConfig,
logger *hlog.Logger, logger *hlog.Logger,
config *config.Config,
) http.Handler { ) http.Handler {
return http.HandlerFunc( return http.HandlerFunc(
func(w http.ResponseWriter, r *http.Request) { func(w http.ResponseWriter, r *http.Request) {
@@ -22,12 +22,12 @@ func SearchMovies(
w.WriteHeader(http.StatusOK) w.WriteHeader(http.StatusOK)
return return
} }
movies, err := tmdb.SearchMovies(config.TMDBToken, query, false, 1) movies, err := tmdb.SearchMovies(cfg.Token, query, false, 1)
if err != nil { if err != nil {
w.WriteHeader(http.StatusInternalServerError) w.WriteHeader(http.StatusInternalServerError)
return return
} }
search.MovieResults(movies, &config.TMDBConfig.Image).Render(r.Context(), w) search.MovieResults(movies, &cfg.Config.Image).Render(r.Context(), w)
}, },
) )
} }

View File

@@ -2,96 +2,30 @@ package handler
import ( import (
"context" "context"
"database/sql"
"net/http" "net/http"
"time" "time"
"projectreshoot/internal/models"
"projectreshoot/internal/view/component/form" "projectreshoot/internal/view/component/form"
"projectreshoot/pkg/config"
"projectreshoot/pkg/contexts"
"projectreshoot/pkg/cookies"
"git.haelnorr.com/h/golib/hlog" "git.haelnorr.com/h/golib/hws"
"git.haelnorr.com/h/golib/jwt" "git.haelnorr.com/h/golib/hwsauth"
"github.com/pkg/errors" "github.com/pkg/errors"
"github.com/uptrace/bun"
) )
// Get the tokens from the request
func getTokens(
tokenGen *jwt.TokenGenerator,
tx *sql.Tx,
r *http.Request,
) (*jwt.AccessToken, *jwt.RefreshToken, error) {
// get the existing tokens from the cookies
atStr, rtStr := cookies.GetTokenStrings(r)
aT, err := tokenGen.ValidateAccess(tx, atStr)
if err != nil {
return nil, nil, errors.Wrap(err, "tokenGen.ValidateAccess")
}
rT, err := tokenGen.ValidateRefresh(tx, rtStr)
if err != nil {
return nil, nil, errors.Wrap(err, "tokenGen.ValidateRefresh")
}
return aT, rT, nil
}
// Revoke the given token pair
func revokeTokenPair(
tx *sql.Tx,
aT *jwt.AccessToken,
rT *jwt.RefreshToken,
) error {
err := aT.Revoke(tx)
if err != nil {
return errors.Wrap(err, "aT.Revoke")
}
err = rT.Revoke(tx)
if err != nil {
return errors.Wrap(err, "rT.Revoke")
}
return nil
}
// Issue new tokens for the user, invalidating the old ones
func refreshTokens(
config *config.Config,
tokenGen *jwt.TokenGenerator,
tx *sql.Tx,
w http.ResponseWriter,
r *http.Request,
) error {
aT, rT, err := getTokens(tokenGen, tx, r)
if err != nil {
return errors.Wrap(err, "getTokens")
}
rememberMe := map[string]bool{
"session": false,
"exp": true,
}[aT.TTL]
// issue new tokens for the user
user := contexts.GetUser(r.Context())
err = cookies.SetTokenCookies(w, r, config, tokenGen, user.User, true, rememberMe)
if err != nil {
return errors.Wrap(err, "cookies.SetTokenCookies")
}
err = revokeTokenPair(tx, aT, rT)
if err != nil {
return errors.Wrap(err, "revokeTokenPair")
}
return nil
}
// Validate the provided password // Validate the provided password
func validatePassword( func validatePassword(
tx *sql.Tx, ctx context.Context,
auth *hwsauth.Authenticator[*models.UserBun, bun.Tx],
tx bun.Tx,
r *http.Request, r *http.Request,
) error { ) error {
r.ParseForm() r.ParseForm()
password := r.FormValue("password") password := r.FormValue("password")
user := contexts.GetUser(r.Context()) user := auth.CurrentModel(r.Context())
err := user.CheckPassword(tx, password) err := user.CheckPassword(ctx, tx, password)
if err != nil { if err != nil {
return errors.Wrap(err, "user.CheckPassword") return errors.Wrap(err, "user.CheckPassword")
} }
@@ -100,10 +34,9 @@ func validatePassword(
// Handle request to reauthenticate (i.e. make token fresh again) // Handle request to reauthenticate (i.e. make token fresh again)
func Reauthenticate( func Reauthenticate(
logger *hlog.Logger, server *hws.Server,
config *config.Config, auth *hwsauth.Authenticator[*models.UserBun, bun.Tx],
conn *sql.DB, db *bun.DB,
tokenGen *jwt.TokenGenerator,
) http.Handler { ) http.Handler {
return http.HandlerFunc( return http.HandlerFunc(
func(w http.ResponseWriter, r *http.Request) { func(w http.ResponseWriter, r *http.Request) {
@@ -111,23 +44,35 @@ func Reauthenticate(
defer cancel() defer cancel()
// Start the transaction // Start the transaction
tx, err := conn.BeginTx(ctx, nil) tx, err := db.BeginTx(ctx, nil)
if err != nil { if err != nil {
logger.Error().Err(err).Msg("Failed to start transaction") err := server.ThrowError(w, r, hws.HWSError{
w.WriteHeader(http.StatusInternalServerError) StatusCode: http.StatusInternalServerError,
Message: "Failed to start transcation",
Error: err,
})
if err != nil {
server.ThrowFatal(w, err)
}
return return
} }
defer tx.Rollback() defer tx.Rollback()
err = validatePassword(tx, r) err = validatePassword(ctx, auth, tx, r)
if err != nil { if err != nil {
w.WriteHeader(445) w.WriteHeader(445)
form.ConfirmPassword("Incorrect password").Render(r.Context(), w) form.ConfirmPassword("Incorrect password").Render(r.Context(), w)
return return
} }
err = refreshTokens(config, tokenGen, tx, w, r) err = auth.RefreshAuthTokens(tx, w, r)
if err != nil { if err != nil {
logger.Error().Err(err).Msg("Failed to refresh user tokens") err := server.ThrowError(w, r, hws.HWSError{
w.WriteHeader(http.StatusInternalServerError) StatusCode: http.StatusInternalServerError,
Message: "Failed to refresh user tokens",
Error: err,
})
if err != nil {
server.ThrowFatal(w, err)
}
return return
} }
tx.Commit() tx.Commit()

View File

@@ -2,30 +2,30 @@ package handler
import ( import (
"context" "context"
"database/sql"
"net/http" "net/http"
"time" "time"
"projectreshoot/internal/models" "projectreshoot/internal/models"
"projectreshoot/internal/view/component/form" "projectreshoot/internal/view/component/form"
"projectreshoot/internal/view/page" "projectreshoot/internal/view/page"
"projectreshoot/pkg/config"
"projectreshoot/pkg/cookies"
"git.haelnorr.com/h/golib/hlog" "git.haelnorr.com/h/golib/cookies"
"git.haelnorr.com/h/golib/jwt" "git.haelnorr.com/h/golib/hws"
"git.haelnorr.com/h/golib/hwsauth"
"github.com/pkg/errors" "github.com/pkg/errors"
"github.com/uptrace/bun"
) )
func validateRegistration( func validateRegistration(
tx *sql.Tx, ctx context.Context,
tx bun.Tx,
r *http.Request, r *http.Request,
) (*models.User, error) { ) (*models.UserBun, error) {
formUsername := r.FormValue("username") formUsername := r.FormValue("username")
formPassword := r.FormValue("password") formPassword := r.FormValue("password")
formConfirmPassword := r.FormValue("confirm-password") formConfirmPassword := r.FormValue("confirm-password")
unique, err := models.CheckUsernameUnique(tx, formUsername) unique, err := models.IsUsernameUnique(ctx, tx, formUsername)
if err != nil { if err != nil {
return nil, errors.Wrap(err, "models.CheckUsernameUnique") return nil, errors.Wrap(err, "models.CheckUsernameUnique")
} }
@@ -38,7 +38,7 @@ func validateRegistration(
if len(formPassword) > 72 { if len(formPassword) > 72 {
return nil, errors.New("Password exceeds maximum length of 72 bytes") 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 { if err != nil {
return nil, errors.Wrap(err, "models.CreateNewUser") return nil, errors.Wrap(err, "models.CreateNewUser")
} }
@@ -47,10 +47,9 @@ func validateRegistration(
} }
func RegisterRequest( func RegisterRequest(
config *config.Config, server *hws.Server,
tokenGen *jwt.TokenGenerator, auth *hwsauth.Authenticator[*models.UserBun, bun.Tx],
logger *hlog.Logger, db *bun.DB,
conn *sql.DB,
) http.Handler { ) http.Handler {
return http.HandlerFunc( return http.HandlerFunc(
func(w http.ResponseWriter, r *http.Request) { func(w http.ResponseWriter, r *http.Request) {
@@ -58,21 +57,33 @@ func RegisterRequest(
defer cancel() defer cancel()
// Start the transaction // Start the transaction
tx, err := conn.BeginTx(ctx, nil) tx, err := db.BeginTx(ctx, nil)
if err != nil { if err != nil {
logger.Warn().Err(err).Msg("Failed to set token cookies") err := server.ThrowError(w, r, hws.HWSError{
w.WriteHeader(http.StatusServiceUnavailable) StatusCode: http.StatusServiceUnavailable,
Message: "Failed to start transaction",
Error: err,
})
if err != nil {
server.ThrowFatal(w, err)
}
return return
} }
r.ParseForm() r.ParseForm()
user, err := validateRegistration(tx, r) user, err := validateRegistration(ctx, tx, r)
if err != nil { if err != nil {
tx.Rollback() tx.Rollback()
if err.Error() != "Username is taken" && if err.Error() != "Username is taken" &&
err.Error() != "Passwords do not match" && err.Error() != "Passwords do not match" &&
err.Error() != "Password exceeds maximum length of 72 bytes" { err.Error() != "Password exceeds maximum length of 72 bytes" {
logger.Warn().Caller().Err(err).Msg("Registration request failed") err := server.ThrowError(w, r, hws.HWSError{
w.WriteHeader(http.StatusInternalServerError) StatusCode: http.StatusInternalServerError,
Message: "Registration failed",
Error: err,
})
if err != nil {
server.ThrowFatal(w, err)
}
} else { } else {
form.RegisterForm(err.Error()).Render(r.Context(), w) form.RegisterForm(err.Error()).Render(r.Context(), w)
} }
@@ -80,11 +91,17 @@ func RegisterRequest(
} }
rememberMe := checkRememberMe(r) rememberMe := checkRememberMe(r)
err = cookies.SetTokenCookies(w, r, config, tokenGen, user, true, rememberMe) err = auth.Login(w, r, user, rememberMe)
if err != nil { if err != nil {
tx.Rollback() tx.Rollback()
w.WriteHeader(http.StatusInternalServerError) err := server.ThrowError(w, r, hws.HWSError{
logger.Warn().Caller().Err(err).Msg("Failed to set token cookies") StatusCode: http.StatusInternalServerError,
Message: "Login failed",
Error: err,
})
if err != nil {
server.ThrowFatal(w, err)
}
return return
} }
tx.Commit() tx.Commit()

View File

@@ -1,52 +1,23 @@
package handler package handler
import ( import (
"git.haelnorr.com/h/golib/hws"
"net/http" "net/http"
"os"
"git.haelnorr.com/h/golib/hlog"
) )
// Wrapper for default FileSystem
type justFilesFilesystem struct {
fs http.FileSystem
}
// Wrapper for default File
type neuteredReaddirFile struct {
http.File
}
// Modifies the behavior of FileSystem.Open to return the neutered version of File
func (fs justFilesFilesystem) Open(name string) (http.File, error) {
f, err := fs.fs.Open(name)
if err != nil {
return nil, err
}
// Check if the requested path is a directory
// and explicitly return an error to trigger a 404
fileInfo, err := f.Stat()
if err != nil {
return nil, err
}
if fileInfo.IsDir() {
return nil, os.ErrNotExist
}
return neuteredReaddirFile{f}, nil
}
// Overrides the Readdir method of File to always return nil
func (f neuteredReaddirFile) Readdir(count int) ([]os.FileInfo, error) {
return nil, nil
}
// Handles requests for static files, without allowing access to the // Handles requests for static files, without allowing access to the
// directory viewer and returning 404 if an exact file is not found // directory viewer and returning 404 if an exact file is not found
func StaticFS(staticFS *http.FileSystem) http.Handler { func StaticFS(staticFS *http.FileSystem, logger *hlog.Logger) http.Handler {
return http.HandlerFunc( return http.HandlerFunc(
func(w http.ResponseWriter, r *http.Request) { func(w http.ResponseWriter, r *http.Request) {
nfs := justFilesFilesystem{*staticFS} fs, err := hws.SafeFileServer(staticFS)
fs := http.FileServer(nfs) if err != nil {
w.WriteHeader(http.StatusInternalServerError)
logger.Error().Err(err).Msg("Failed to load file system")
return
}
fs.ServeHTTP(w, r) fs.ServeHTTP(w, r)
}, },
) )

View File

@@ -1,72 +0,0 @@
package httpserver
import (
"database/sql"
"net/http"
"projectreshoot/internal/handler"
"projectreshoot/internal/middleware"
"projectreshoot/internal/view/page"
"projectreshoot/pkg/config"
"git.haelnorr.com/h/golib/hlog"
"git.haelnorr.com/h/golib/jwt"
)
// Add all the handled routes to the mux
func addRoutes(
mux *http.ServeMux,
logger *hlog.Logger,
config *config.Config,
tokenGen *jwt.TokenGenerator,
conn *sql.DB,
staticFS *http.FileSystem,
) {
route := mux.Handle
loggedIn := middleware.LoginReq
loggedOut := middleware.LogoutReq
fresh := middleware.FreshReq
// Health check
mux.HandleFunc("GET /healthz", func(http.ResponseWriter, *http.Request) {})
// Static files
route("GET /static/", http.StripPrefix("/static/", handler.StaticFS(staticFS)))
// Index page and unhandled catchall (404)
route("GET /", handler.Root())
// Static content, unprotected pages
route("GET /about", handler.HandlePage(page.About()))
// Login page and handlers
route("GET /login", loggedOut(handler.LoginPage(config.TrustedHost)))
route("POST /login", loggedOut(handler.LoginRequest(config, logger, conn, tokenGen)))
// Register page and handlers
route("GET /register", loggedOut(handler.RegisterPage(config.TrustedHost)))
route("POST /register", loggedOut(handler.RegisterRequest(config, tokenGen, logger, conn)))
// Logout
route("POST /logout", handler.Logout(conn, tokenGen, logger))
// Reauthentication request
route("POST /reauthenticate", loggedIn(handler.Reauthenticate(logger, config, conn, tokenGen)))
// Profile page
route("GET /profile", loggedIn(handler.ProfilePage()))
// Account page
route("GET /account", loggedIn(handler.AccountPage()))
route("POST /account-select-page", loggedIn(handler.AccountSubpage()))
route("POST /change-username", loggedIn(fresh(handler.ChangeUsername(logger, conn))))
route("POST /change-bio", loggedIn(handler.ChangeBio(logger, conn)))
route("POST /change-password", loggedIn(fresh(handler.ChangePassword(logger, conn))))
// Movies Search
route("GET /movies", handler.MoviesPage())
route("POST /search-movies", handler.SearchMovies(logger, config))
// Movie page
route("GET /movie/{movie_id}", handler.Movie(logger, config))
}

View File

@@ -1,67 +0,0 @@
package httpserver
import (
"database/sql"
"io/fs"
"net"
"net/http"
"time"
"projectreshoot/internal/middleware"
"projectreshoot/pkg/config"
"git.haelnorr.com/h/golib/hlog"
"git.haelnorr.com/h/golib/jwt"
)
func NewServer(
config *config.Config,
logger *hlog.Logger,
conn *sql.DB,
tokenGen *jwt.TokenGenerator,
staticFS *fs.FS,
maint *uint32,
) *http.Server {
fs := http.FS(*staticFS)
srv := createServer(config, logger, conn, tokenGen, &fs, maint)
httpServer := &http.Server{
Addr: net.JoinHostPort(config.Host, config.Port),
Handler: srv,
ReadHeaderTimeout: config.ReadHeaderTimeout * time.Second,
WriteTimeout: config.WriteTimeout * time.Second,
IdleTimeout: config.IdleTimeout * time.Second,
}
return httpServer
}
// Returns a new http.Handler with all the routes and middleware added
func createServer(
config *config.Config,
logger *hlog.Logger,
conn *sql.DB,
tokenGen *jwt.TokenGenerator,
staticFS *http.FileSystem,
maint *uint32,
) http.Handler {
mux := http.NewServeMux()
addRoutes(
mux,
logger,
config,
tokenGen,
conn,
staticFS,
)
var handler http.Handler = mux
// Add middleware here, must be added in reverse order of execution
// i.e. First in list will get executed last during the request handling
handler = middleware.Logging(logger, handler)
handler = middleware.Authentication(logger, config, conn, tokenGen, handler, maint)
// Gzip
handler = middleware.Gzip(handler, config.GZIP)
// Start the timer for the request chain so logger can have accurate info
handler = middleware.StartTimer(handler)
return handler
}

View File

@@ -1,148 +0,0 @@
package middleware
import (
"context"
"database/sql"
"net/http"
"sync/atomic"
"time"
"projectreshoot/internal/handler"
"projectreshoot/internal/models"
"projectreshoot/pkg/config"
"projectreshoot/pkg/contexts"
"projectreshoot/pkg/cookies"
"git.haelnorr.com/h/golib/hlog"
"git.haelnorr.com/h/golib/jwt"
"github.com/pkg/errors"
)
// Attempt to use a valid refresh token to generate a new token pair
func refreshAuthTokens(
config *config.Config,
tokenGen *jwt.TokenGenerator,
tx *sql.Tx,
w http.ResponseWriter,
req *http.Request,
ref *jwt.RefreshToken,
) (*models.User, error) {
user, err := models.GetUserFromID(tx, ref.SUB)
if err != nil {
return nil, errors.Wrap(err, "models.GetUser")
}
rememberMe := map[string]bool{
"session": false,
"exp": true,
}[ref.TTL]
// Set fresh to true because new tokens coming from refresh request
err = cookies.SetTokenCookies(w, req, config, tokenGen, user, false, rememberMe)
if err != nil {
return nil, errors.Wrap(err, "cookies.SetTokenCookies")
}
// New tokens sent, revoke the used refresh token
err = ref.Revoke(tx)
if err != nil {
return nil, errors.Wrap(err, "ref.Revoke")
}
// Return the authorized user
return user, nil
}
// Check the cookies for token strings and attempt to authenticate them
func getAuthenticatedUser(
config *config.Config,
tokenGen *jwt.TokenGenerator,
tx *sql.Tx,
w http.ResponseWriter,
r *http.Request,
) (*contexts.AuthenticatedUser, error) {
// Get token strings from cookies
atStr, rtStr := cookies.GetTokenStrings(r)
if atStr == "" && rtStr == "" {
return nil, errors.New("No token strings provided")
}
// Attempt to parse the access token
aT, err := tokenGen.ValidateAccess(tx, atStr)
if err != nil {
// Access token invalid, attempt to parse refresh token
rT, err := tokenGen.ValidateRefresh(tx, rtStr)
if err != nil {
return nil, errors.Wrap(err, "tokenGen.ValidateRefresh")
}
// Refresh token valid, attempt to get a new token pair
user, err := refreshAuthTokens(config, tokenGen, tx, w, r, rT)
if err != nil {
return nil, errors.Wrap(err, "refreshAuthTokens")
}
// New token pair sent, return the authorized user
authUser := contexts.AuthenticatedUser{
User: user,
Fresh: time.Now().Unix(),
}
return &authUser, nil
}
// Access token valid
user, err := models.GetUserFromID(tx, aT.SUB)
if err != nil {
return nil, errors.Wrap(err, "models.GetUser")
}
authUser := contexts.AuthenticatedUser{
User: user,
Fresh: aT.Fresh,
}
return &authUser, nil
}
// Attempt to authenticate the user and add their account details
// to the request context
func Authentication(
logger *hlog.Logger,
config *config.Config,
conn *sql.DB,
tokenGen *jwt.TokenGenerator,
next http.Handler,
maint *uint32,
) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/static/css/output.css" ||
r.URL.Path == "/static/favicon.ico" {
next.ServeHTTP(w, r)
return
}
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
defer cancel()
if atomic.LoadUint32(maint) == 1 {
cancel()
}
// Start the transaction
tx, err := conn.BeginTx(ctx, nil)
if err != nil {
// Failed to start transaction, skip auth
logger.Warn().Err(err).
Msg("Skipping Auth - unable to start a transaction")
handler.ErrorPage(http.StatusServiceUnavailable, w, r)
return
}
user, err := getAuthenticatedUser(config, tokenGen, tx, w, r)
if err != nil {
tx.Rollback()
// User auth failed, delete the cookies to avoid repeat requests
cookies.DeleteCookie(w, "access", "/")
cookies.DeleteCookie(w, "refresh", "/")
logger.Debug().
Str("remote_addr", r.RemoteAddr).
Err(err).
Msg("Failed to authenticate user")
next.ServeHTTP(w, r)
return
}
tx.Commit()
uctx := contexts.SetUser(r.Context(), user)
newReq := r.WithContext(uctx)
next.ServeHTTP(w, newReq)
})
}

View File

@@ -1,32 +0,0 @@
package middleware
import (
"compress/gzip"
"io"
"net/http"
"strings"
)
func Gzip(next http.Handler, useGzip bool) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if !strings.Contains(r.Header.Get("Accept-Encoding"), "gzip") ||
!useGzip {
next.ServeHTTP(w, r)
return
}
w.Header().Set("Content-Encoding", "gzip")
gz := gzip.NewWriter(w)
defer gz.Close()
gzw := gzipResponseWriter{Writer: gz, ResponseWriter: w}
next.ServeHTTP(gzw, r)
})
}
type gzipResponseWriter struct {
io.Writer
http.ResponseWriter
}
func (w gzipResponseWriter) Write(b []byte) (int, error) {
return w.Writer.Write(b)
}

View File

@@ -1,50 +0,0 @@
package middleware
import (
"net/http"
"projectreshoot/internal/handler"
"projectreshoot/pkg/contexts"
"time"
"git.haelnorr.com/h/golib/hlog"
)
// Wraps the http.ResponseWriter, adding a statusCode field
type wrappedWriter struct {
http.ResponseWriter
statusCode int
}
// Extends WriteHeader to the ResponseWriter to add the status code
func (w *wrappedWriter) WriteHeader(statusCode int) {
w.ResponseWriter.WriteHeader(statusCode)
w.statusCode = statusCode
}
// Middleware to add logs to console with details of the request
func Logging(logger *hlog.Logger, next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/static/css/output.css" ||
r.URL.Path == "/static/favicon.ico" {
next.ServeHTTP(w, r)
return
}
start, err := contexts.GetStartTime(r.Context())
if err != nil {
handler.ErrorPage(http.StatusInternalServerError, w, r)
return
}
wrapped := &wrappedWriter{
ResponseWriter: w,
statusCode: http.StatusOK,
}
next.ServeHTTP(wrapped, r)
logger.Info().
Int("status", wrapped.statusCode).
Str("method", r.Method).
Str("resource", r.URL.Path).
Dur("time_elapsed", time.Since(start)).
Str("remote_addr", r.Header.Get("X-Forwarded-For")).
Msg("Served")
})
}

View File

@@ -1,32 +0,0 @@
package middleware
import (
"net/http"
"projectreshoot/internal/handler"
"projectreshoot/pkg/contexts"
)
// Checks if the user is set in the context and shows 401 page if not logged in
func LoginReq(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
user := contexts.GetUser(r.Context())
if user == nil {
handler.ErrorPage(http.StatusUnauthorized, w, r)
return
}
next.ServeHTTP(w, r)
})
}
// Checks if the user is set in the context and redirects them to profile if
// they are logged in
func LogoutReq(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
user := contexts.GetUser(r.Context())
if user != nil {
http.Redirect(w, r, "/profile", http.StatusFound)
return
}
next.ServeHTTP(w, r)
})
}

View File

@@ -1,21 +0,0 @@
package middleware
import (
"net/http"
"projectreshoot/pkg/contexts"
"time"
)
func FreshReq(
next http.Handler,
) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
user := contexts.GetUser(r.Context())
isFresh := time.Now().Before(time.Unix(user.Fresh, 0))
if !isFresh {
w.WriteHeader(444)
return
}
next.ServeHTTP(w, r)
})
}

View File

@@ -1,18 +0,0 @@
package middleware
import (
"net/http"
"projectreshoot/pkg/contexts"
"time"
)
func StartTimer(next http.Handler) http.Handler {
return http.HandlerFunc(
func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
ctx := contexts.SetStart(r.Context(), start)
newReq := r.WithContext(ctx)
next.ServeHTTP(w, newReq)
},
)
}

View File

@@ -8,12 +8,16 @@ import (
) )
type User struct { type User struct {
ID int // Integer ID (index primary key) id int // Integer ID (index primary key)
Username string // Username (unique) Username string // Username (unique)
Created_at int64 // Epoch timestamp when the user was added to the database Created_at int64 // Epoch timestamp when the user was added to the database
Bio string // Short byline set by the user Bio string // Short byline set by the user
} }
func (u User) ID() int {
return u.id
}
// Uses bcrypt to set the users Password_hash from the given password // Uses bcrypt to set the users Password_hash from the given password
func (user *User) SetPassword( func (user *User) SetPassword(
tx *sql.Tx, tx *sql.Tx,
@@ -25,7 +29,7 @@ func (user *User) SetPassword(
} }
newPassword := string(hashedPassword) newPassword := string(hashedPassword)
query := `UPDATE users SET password_hash = ? WHERE id = ?` query := `UPDATE users SET password_hash = ? WHERE id = ?`
_, err = tx.Exec(query, newPassword, user.ID) _, err = tx.Exec(query, newPassword, user.id)
if err != nil { if err != nil {
return errors.Wrap(err, "tx.Exec") return errors.Wrap(err, "tx.Exec")
} }
@@ -35,15 +39,15 @@ func (user *User) SetPassword(
// Uses bcrypt to check if the given password matches the users Password_hash // Uses bcrypt to check if the given password matches the users Password_hash
func (user *User) CheckPassword(tx *sql.Tx, password string) error { func (user *User) CheckPassword(tx *sql.Tx, password string) error {
query := `SELECT password_hash FROM users WHERE id = ? LIMIT 1` query := `SELECT password_hash FROM users WHERE id = ? LIMIT 1`
row := tx.QueryRow(query, user.ID) row := tx.QueryRow(query, user.id)
hashedPassword := "" var hashedPassword string
err := row.Scan(&hashedPassword) err := row.Scan(&hashedPassword)
if err != nil { if err != nil {
return errors.Wrap(err, "row.Scan") return errors.Wrap(err, "row.Scan")
} }
err = bcrypt.CompareHashAndPassword([]byte(hashedPassword), []byte(password)) err = bcrypt.CompareHashAndPassword([]byte(hashedPassword), []byte(password))
if err != nil { if err != nil {
return errors.Wrap(err, "bcrypt.CompareHashAndPassword") return errors.Wrap(err, "Username or password incorrect")
} }
return nil return nil
} }
@@ -51,7 +55,7 @@ func (user *User) CheckPassword(tx *sql.Tx, password string) error {
// Change the user's username // Change the user's username
func (user *User) ChangeUsername(tx *sql.Tx, newUsername string) error { func (user *User) ChangeUsername(tx *sql.Tx, newUsername string) error {
query := `UPDATE users SET username = ? WHERE id = ?` query := `UPDATE users SET username = ? WHERE id = ?`
_, err := tx.Exec(query, newUsername, user.ID) _, err := tx.Exec(query, newUsername, user.id)
if err != nil { if err != nil {
return errors.Wrap(err, "tx.Exec") return errors.Wrap(err, "tx.Exec")
} }
@@ -61,7 +65,7 @@ func (user *User) ChangeUsername(tx *sql.Tx, newUsername string) error {
// Change the user's bio // Change the user's bio
func (user *User) ChangeBio(tx *sql.Tx, newBio string) error { func (user *User) ChangeBio(tx *sql.Tx, newBio string) error {
query := `UPDATE users SET bio = ? WHERE id = ?` query := `UPDATE users SET bio = ? WHERE id = ?`
_, err := tx.Exec(query, newBio, user.ID) _, err := tx.Exec(query, newBio, user.id)
if err != nil { if err != nil {
return errors.Wrap(err, "tx.Exec") return errors.Wrap(err, "tx.Exec")
} }

163
internal/models/user_bun.go Normal file
View File

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

View File

@@ -4,6 +4,7 @@ import (
"database/sql" "database/sql"
"fmt" "fmt"
"git.haelnorr.com/h/golib/hwsauth"
"github.com/pkg/errors" "github.com/pkg/errors"
) )
@@ -31,7 +32,9 @@ func CreateNewUser(
// Fetches data from the users table using "WHERE column = 'value'" // Fetches data from the users table using "WHERE column = 'value'"
func fetchUserData( func fetchUserData(
tx *sql.Tx, tx interface {
Query(query string, args ...any) (*sql.Rows, error)
},
column string, column string,
value any, value any,
) (*sql.Rows, error) { ) (*sql.Rows, error) {
@@ -59,7 +62,7 @@ func scanUserRow(user *User, rows *sql.Rows) error {
return errors.New("User not found") return errors.New("User not found")
} }
err := rows.Scan( err := rows.Scan(
&user.ID, &user.id,
&user.Username, &user.Username,
&user.Created_at, &user.Created_at,
&user.Bio, &user.Bio,
@@ -87,7 +90,7 @@ func GetUserFromUsername(tx *sql.Tx, username string) (*User, error) {
} }
// Queries the database for a user matching the given ID. // 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) rows, err := fetchUserData(tx, "id", id)
if err != nil { if err != nil {
return nil, errors.Wrap(err, "fetchUserData") return nil, errors.Wrap(err, "fetchUserData")

View File

@@ -3,8 +3,8 @@ package account
import "projectreshoot/pkg/contexts" import "projectreshoot/pkg/contexts"
templ ChangeBio(err string, bio string) { templ ChangeBio(err string, bio string) {
{{ user := contexts.CurrentUser(ctx) }}
{{ {{
user := contexts.GetUser(ctx)
if bio == "" { if bio == "" {
bio = user.Bio bio = user.Bio
} }
@@ -21,21 +21,20 @@ templ ChangeBio(err string, bio string) {
bio: newBio, bio: newBio,
initialBio: oldBio, initialBio: oldBio,
err: err, err: err,
bioLenText: '', bioLenText: "",
updateTextArea() { updateTextArea() {
this.$nextTick(() => { this.$nextTick(() => {
if (this.$refs.bio) { if (this.$refs.bio) {
this.$refs.bio.style.height = 'auto'; this.$refs.bio.style.height = "auto";
this.$refs.bio.style.height = ` this.$refs.bio.style.height = `
${this.$refs.bio.scrollHeight + 20}px`; ${this.$refs.bio.scrollHeight + 20}px`;
}; }
this.bioLenText = `${this.bio.length}/128`; this.bioLenText = `${this.bio.length}/128`;
}); });
}, },
resetBio() { resetBio() {
this.bio = this.initialBio; this.bio = this.initialBio;
this.err = "", ((this.err = ""), this.updateTextArea());
this.updateTextArea();
}, },
init() { init() {
this.$nextTick(() => { this.$nextTick(() => {
@@ -46,7 +45,7 @@ templ ChangeBio(err string, bio string) {
this.updateTextArea(); this.updateTextArea();
}, 20); }, 20);
}); });
} },
}; };
} }
</script> </script>

View File

@@ -3,8 +3,8 @@ package account
import "projectreshoot/pkg/contexts" import "projectreshoot/pkg/contexts"
templ ChangeUsername(err string, username string) { templ ChangeUsername(err string, username string) {
{{ user := contexts.CurrentUser(ctx) }}
{{ {{
user := contexts.GetUser(ctx)
if username == "" { if username == "" {
username = user.Username username = user.Username
} }

View File

@@ -8,7 +8,7 @@ templ AccountContainer(subpage string) {
@resize.window="big = window.innerWidth >= 768" @resize.window="big = window.innerWidth >= 768"
> >
@SelectMenu(subpage) @SelectMenu(subpage)
<div class="mt-5 w-full md:ml-[200px] ml-[40px] transition-all duration-300"> <div class="mt-5 w-full md:ml-[200px] ml-10 transition-all duration-300">
<div <div
class="pl-5 text-2xl text-subtext1 border-b class="pl-5 text-2xl text-subtext1 border-b
border-overlay0 w-[90%] mx-auto" border-overlay0 w-[90%] mx-auto"

View File

@@ -21,7 +21,7 @@ templ Navbar() {
<div x-data="{ open: false }"> <div x-data="{ open: false }">
<header class="bg-crust"> <header class="bg-crust">
<div <div
class="mx-auto flex h-16 max-w-screen-xl items-center gap-8 class="mx-auto flex h-16 max-w-7xl items-center gap-8
px-4 sm:px-6 lg:px-8" px-4 sm:px-6 lg:px-8"
> >
<a class="block" href="/"> <a class="block" href="/">

View File

@@ -23,7 +23,7 @@ func getProfileItems() []ProfileItem {
// Returns the right portion of the navbar // Returns the right portion of the navbar
templ navRight() { templ navRight() {
{{ user := contexts.GetUser(ctx) }} {{ user := contexts.CurrentUser(ctx) }}
{{ items := getProfileItems() }} {{ items := getProfileItems() }}
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<div class="sm:flex sm:gap-2"> <div class="sm:flex sm:gap-2">

View File

@@ -4,7 +4,7 @@ import "projectreshoot/pkg/contexts"
// Returns the mobile version of the navbar thats only visible when activated // Returns the mobile version of the navbar thats only visible when activated
templ sideNav(navItems []NavItem) { templ sideNav(navItems []NavItem) {
{{ user := contexts.GetUser(ctx) }} {{ user := contexts.CurrentUser(ctx) }}
<div <div
x-show="open" x-show="open"
x-transition x-transition

View File

@@ -5,9 +5,11 @@ import "git.haelnorr.com/h/golib/tmdb"
templ MovieResults(movies *tmdb.ResultMovies, image *tmdb.Image) { templ MovieResults(movies *tmdb.ResultMovies, image *tmdb.Image) {
for _, movie := range movies.Results { for _, movie := range movies.Results {
<div <a
href={ templ.SafeURL(fmt.Sprintf("/movie/%v", movie.ID)) }
class="bg-surface0 p-4 rounded-lg shadow-lg flex class="bg-surface0 p-4 rounded-lg shadow-lg flex
items-start space-x-4" items-start space-x-4 cursor-pointer transition-all
hover:outline hover:outline-green hover:shadow-xl hover:scale-105"
> >
<img <img
src={ movie.GetPoster(image, "w92") } src={ movie.GetPoster(image, "w92") }
@@ -19,16 +21,14 @@ templ MovieResults(movies *tmdb.ResultMovies, image *tmdb.Image) {
/> />
<script> <script>
function setFallbackColor(img) { function setFallbackColor(img) {
const baseColor = getComputedStyle(document.documentElement). const baseColor = getComputedStyle(document.documentElement)
getPropertyValue('--base').trim(); .getPropertyValue("--base")
.trim();
img.src = `data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='96' height='144'%3E%3Crect width='100%' height='100%' fill='${baseColor}'/%3E%3C/svg%3E`; img.src = `data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='96' height='144'%3E%3Crect width='100%' height='100%' fill='${baseColor}'/%3E%3C/svg%3E`;
} }
</script> </script>
<div> <div>
<a <h3 class="text-xl font-semibold">{ movie.Title } { movie.ReleaseYear() }</h3>
href={ templ.SafeURL(fmt.Sprintf("/movie/%v", movie.ID)) }
class="text-xl font-semibold transition hover:text-green"
>{ movie.Title } { movie.ReleaseYear() }</a>
<p class="text-subtext0"> <p class="text-subtext0">
Released: Released:
<span class="font-medium">{ movie.ReleaseDate }</span> <span class="font-medium">{ movie.ReleaseDate }</span>
@@ -39,6 +39,6 @@ templ MovieResults(movies *tmdb.ResultMovies, image *tmdb.Image) {
</p> </p>
<p class="text-subtext0">{ movie.Overview }</p> <p class="text-subtext0">{ movie.Overview }</p>
</div> </div>
</div> </a>
} }
} }

View File

@@ -22,7 +22,9 @@ templ Global(title string) {
(function () { (function () {
let theme = localStorage.getItem("theme") || "system"; let theme = localStorage.getItem("theme") || "system";
if (theme === "system") { if (theme === "system") {
theme = window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light"; theme = window.matchMedia("(prefers-color-scheme: dark)").matches
? "dark"
: "light";
} }
if (theme === "dark") { if (theme === "dark") {
document.documentElement.classList.add("dark"); document.documentElement.classList.add("dark");
@@ -65,18 +67,18 @@ templ Global(title string) {
const errorCode = event.detail.errorInfo.error; const errorCode = event.detail.errorInfo.error;
// internal server error // internal server error
if (errorCode.includes('Code 500')) { if (errorCode.includes("Code 500")) {
this.showError500 = true; this.showError500 = true;
setTimeout(() => this.showError500 = false, 6000); setTimeout(() => (this.showError500 = false), 6000);
} }
// service not available error // service not available error
if (errorCode.includes('Code 503')) { if (errorCode.includes("Code 503")) {
this.showError503 = true; this.showError503 = true;
setTimeout(() => this.showError503 = false, 6000); setTimeout(() => (this.showError503 = false), 6000);
} }
// user is authorized but needs to refresh their login // user is authorized but needs to refresh their login
if (errorCode.includes('Code 444')) { if (errorCode.includes("Code 444")) {
this.showConfirmPasswordModal = true; this.showConfirmPasswordModal = true;
} }
}, },

View File

@@ -14,7 +14,7 @@ templ Movies() {
name="search" name="search"
type="text" type="text"
placeholder="Search movies..." placeholder="Search movies..."
class="flex-grow p-2 border rounded-lg class="grow p-2 border rounded-lg
bg-mantle border-surface2 shadow-sm bg-mantle border-surface2 shadow-sm
focus:outline-none focus:ring-2 focus:ring-blue" focus:outline-none focus:ring-2 focus:ring-blue"
/> />
@@ -24,7 +24,7 @@ templ Movies() {
hover:cursor-pointer hover:bg-green/75" hover:cursor-pointer hover:bg-green/75"
>Search</button> >Search</button>
</div> </div>
<div id="search-movies-results" class="space-y-4"></div> <div id="search-movies-results" class="space-y-4 pt-4"></div>
</form> </form>
</div> </div>
} }

View File

@@ -4,7 +4,7 @@ import "projectreshoot/internal/view/layout"
import "projectreshoot/pkg/contexts" import "projectreshoot/pkg/contexts"
templ Profile() { templ Profile() {
{{ user := contexts.GetUser(ctx) }} {{ user := contexts.CurrentUser(ctx) }}
@layout.Global("Profile - " + user.Username) { @layout.Global("Profile - " + user.Username) {
<div class=""> <div class="">
Hello, { user.Username } Hello, { user.Username }

View File

@@ -1,116 +0,0 @@
package config
import (
"fmt"
"os"
"time"
"git.haelnorr.com/h/golib/hlog"
"git.haelnorr.com/h/golib/tmdb"
"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
}
// 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
)
if args["host"] != "" {
host = args["host"]
} else {
host = GetEnvDefault("HOST", "127.0.0.1")
}
if args["port"] != "" {
port = args["port"]
} else {
port = GetEnvDefault("PORT", "3010")
}
if args["loglevel"] != "" {
logLevel = hlog.LogLevel(args["loglevel"])
} else {
logLevel = hlog.LogLevel(GetEnvDefault("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 = GetEnvDefault("LOG_OUTPUT", "console")
}
if logOutput != "both" && logOutput != "console" && logOutput != "file" {
logOutput = "console"
}
tmdbcfg, err := tmdb.GetConfig(os.Getenv("TMDB_API_TOKEN"))
if err != nil {
return nil, errors.Wrap(err, "tmdb.GetConfig")
}
config := &Config{
Host: host,
Port: port,
TrustedHost: GetEnvDefault("TRUSTED_HOST", "127.0.0.1"),
SSL: GetEnvBool("SSL_MODE", false),
GZIP: GetEnvBool("GZIP", false),
ReadHeaderTimeout: GetEnvDur("READ_HEADER_TIMEOUT", 2),
WriteTimeout: GetEnvDur("WRITE_TIMEOUT", 10),
IdleTimeout: GetEnvDur("IDLE_TIMEOUT", 120),
DBName: "00001",
DBLockTimeout: GetEnvDur("DB_LOCK_TIMEOUT", 60),
SecretKey: os.Getenv("SECRET_KEY"),
AccessTokenExpiry: GetEnvInt64("ACCESS_TOKEN_EXPIRY", 5),
RefreshTokenExpiry: GetEnvInt64("REFRESH_TOKEN_EXPIRY", 1440), // defaults to 1 day
TokenFreshTime: GetEnvInt64("TOKEN_FRESH_TIME", 5),
LogLevel: logLevel,
LogOutput: logOutput,
LogDir: GetEnvDefault("LOG_DIR", ""),
TMDBToken: os.Getenv("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")
}
return config, nil
}

View File

@@ -1,94 +0,0 @@
package config
import (
"os"
"strconv"
"strings"
"time"
)
// Get an environment variable, specifying a default value if its not set
func GetEnvDefault(key string, defaultValue string) string {
val, exists := os.LookupEnv(key)
if !exists {
return defaultValue
}
return val
}
// Get an environment variable as a time.Duration, specifying a default value if its
// not set or can't be parsed properly
func GetEnvDur(key string, defaultValue time.Duration) time.Duration {
val, exists := os.LookupEnv(key)
if !exists {
return time.Duration(defaultValue)
}
intVal, err := strconv.Atoi(val)
if err != nil {
return time.Duration(defaultValue)
}
return time.Duration(intVal)
}
// Get an environment variable as an int, specifying a default value if its
// not set or can't be parsed properly into an int
func GetEnvInt(key string, defaultValue int) int {
val, exists := os.LookupEnv(key)
if !exists {
return defaultValue
}
intVal, err := strconv.Atoi(val)
if err != nil {
return defaultValue
}
return intVal
}
// Get an environment variable as an int64, specifying a default value if its
// not set or can't be parsed properly into an int64
func GetEnvInt64(key string, defaultValue int64) int64 {
val, exists := os.LookupEnv(key)
if !exists {
return defaultValue
}
intVal, err := strconv.ParseInt(val, 10, 64)
if err != nil {
return defaultValue
}
return intVal
}
// Get an environment variable as a boolean, specifying a default value if its
// not set or can't be parsed properly into a bool
func GetEnvBool(key string, defaultValue bool) bool {
val, exists := os.LookupEnv(key)
if !exists {
return defaultValue
}
truthy := map[string]bool{
"true": true, "t": true, "yes": true, "y": true, "on": true, "1": true,
"enable": true, "enabled": true, "active": true, "affirmative": true,
}
falsy := map[string]bool{
"false": false, "f": false, "no": false, "n": false, "off": false, "0": false,
"disable": false, "disabled": false, "inactive": false, "negative": false,
}
normalized := strings.TrimSpace(strings.ToLower(val))
if val, ok := truthy[normalized]; ok {
return val
}
if val, ok := falsy[normalized]; ok {
return val
}
return defaultValue
}

View File

@@ -0,0 +1,9 @@
package contexts
import (
"projectreshoot/internal/models"
"git.haelnorr.com/h/golib/hwsauth"
)
var CurrentUser hwsauth.ContextLoader[*models.UserBun]

View File

@@ -5,8 +5,3 @@ type contextKey string
func (c contextKey) String() string { func (c contextKey) String() string {
return "projectreshoot context key " + string(c) return "projectreshoot context key " + string(c)
} }
var (
contextKeyAuthorizedUser = contextKey("auth-user")
contextKeyRequestTime = contextKey("req-time")
)

View File

@@ -1,21 +0,0 @@
package contexts
import (
"context"
"errors"
"time"
)
// Set the start time of the request
func SetStart(ctx context.Context, time time.Time) context.Context {
return context.WithValue(ctx, contextKeyRequestTime, time)
}
// Get the start time of the request
func GetStartTime(ctx context.Context) (time.Time, error) {
start, ok := ctx.Value(contextKeyRequestTime).(time.Time)
if !ok {
return time.Time{}, errors.New("Failed to get start time of request")
}
return start, nil
}

View File

@@ -1,25 +0,0 @@
package contexts
import (
"context"
"projectreshoot/internal/models"
)
type AuthenticatedUser struct {
*models.User
Fresh int64
}
// Return a new context with the user added in
func SetUser(ctx context.Context, u *AuthenticatedUser) context.Context {
return context.WithValue(ctx, contextKeyAuthorizedUser, u)
}
// Retrieve a user from the given context. Returns nil if not set
func GetUser(ctx context.Context) *AuthenticatedUser {
user, ok := ctx.Value(contextKeyAuthorizedUser).(*AuthenticatedUser)
if !ok {
return nil
}
return user
}

View File

@@ -1,37 +0,0 @@
package cookies
import (
"net/http"
"time"
)
// Tell the browser to delete the cookie matching the name provided
// Path must match the original set cookie for it to delete
func DeleteCookie(w http.ResponseWriter, name string, path string) {
http.SetCookie(w, &http.Cookie{
Name: name,
Value: "",
Path: path,
Expires: time.Unix(0, 0), // Expire in the past
MaxAge: -1, // Immediately expire
HttpOnly: true,
})
}
// Set a cookie with the given name, path and value. maxAge directly relates
// to cookie MaxAge (0 for no max age, >0 for TTL in seconds)
func SetCookie(
w http.ResponseWriter,
name string,
path string,
value string,
maxAge int,
) {
http.SetCookie(w, &http.Cookie{
Name: name,
Value: value,
Path: path,
HttpOnly: true,
MaxAge: maxAge,
})
}

View File

@@ -1,36 +0,0 @@
package cookies
import (
"net/http"
"net/url"
)
// Check the value of "pagefrom" cookie, delete the cookie, and return the value
func CheckPageFrom(w http.ResponseWriter, r *http.Request) string {
pageFromCookie, err := r.Cookie("pagefrom")
if err != nil {
return "/"
}
pageFrom := pageFromCookie.Value
DeleteCookie(w, pageFromCookie.Name, pageFromCookie.Path)
return pageFrom
}
// Check the referer of the request, and if it matches the trustedHost, set
// the "pagefrom" cookie as the Path of the referer
func SetPageFrom(w http.ResponseWriter, r *http.Request, trustedHost string) {
referer := r.Referer()
parsedURL, err := url.Parse(referer)
if err != nil {
return
}
var pageFrom string
if parsedURL.Path == "" || parsedURL.Host != trustedHost {
pageFrom = "/"
} else if parsedURL.Path == "/login" || parsedURL.Path == "/register" {
return
} else {
pageFrom = parsedURL.Path
}
SetCookie(w, "pagefrom", "/", pageFrom, 0)
}

View File

@@ -1,78 +0,0 @@
package cookies
import (
"net/http"
"time"
"projectreshoot/internal/models"
"projectreshoot/pkg/config"
"git.haelnorr.com/h/golib/jwt"
"github.com/pkg/errors"
)
// Get the value of the access and refresh tokens
func GetTokenStrings(
r *http.Request,
) (acc string, ref string) {
accCookie, accErr := r.Cookie("access")
refCookie, refErr := r.Cookie("refresh")
var (
accStr string = ""
refStr string = ""
)
if accErr == nil {
accStr = accCookie.Value
}
if refErr == nil {
refStr = refCookie.Value
}
return accStr, refStr
}
// Set a token with the provided details
func setToken(
w http.ResponseWriter,
config *config.Config,
token string,
scope string,
exp int64,
rememberme bool,
) {
tokenCookie := &http.Cookie{
Name: scope,
Value: token,
Path: "/",
HttpOnly: true,
SameSite: http.SameSiteLaxMode,
Secure: config.SSL,
}
if rememberme {
tokenCookie.Expires = time.Unix(exp, 0)
}
http.SetCookie(w, tokenCookie)
}
// Generate new tokens for the user and set them as cookies
func SetTokenCookies(
w http.ResponseWriter,
r *http.Request,
config *config.Config,
tokenGen *jwt.TokenGenerator,
user *models.User,
fresh bool,
rememberMe bool,
) error {
at, atexp, err := tokenGen.NewAccess(user.ID, fresh, rememberMe)
if err != nil {
return errors.Wrap(err, "jwt.GenerateAccessToken")
}
rt, rtexp, err := tokenGen.NewRefresh(user.ID, rememberMe)
if err != nil {
return errors.Wrap(err, "jwt.GenerateRefreshToken")
}
// Don't set the cookies until we know no errors occured
setToken(w, config, at, "access", atexp, rememberMe)
setToken(w, config, rt, "refresh", rtexp, rememberMe)
return nil
}

View File

@@ -15,6 +15,7 @@
--breakpoint-xl: 80rem; --breakpoint-xl: 80rem;
--container-md: 28rem; --container-md: 28rem;
--container-4xl: 56rem; --container-4xl: 56rem;
--container-7xl: 80rem;
--text-xs: 0.75rem; --text-xs: 0.75rem;
--text-xs--line-height: calc(1 / 0.75); --text-xs--line-height: calc(1 / 0.75);
--text-sm: 0.875rem; --text-sm: 0.875rem;
@@ -326,8 +327,8 @@
.ml-0 { .ml-0 {
margin-left: calc(var(--spacing) * 0); margin-left: calc(var(--spacing) * 0);
} }
.ml-\[40px\] { .ml-10 {
margin-left: 40px; margin-left: calc(var(--spacing) * 10);
} }
.ml-auto { .ml-auto {
margin-left: auto; margin-left: auto;
@@ -424,6 +425,9 @@
.max-w-4xl { .max-w-4xl {
max-width: var(--container-4xl); max-width: var(--container-4xl);
} }
.max-w-7xl {
max-width: var(--container-7xl);
}
.max-w-100 { .max-w-100 {
max-width: calc(var(--spacing) * 100); max-width: calc(var(--spacing) * 100);
} }
@@ -445,7 +449,7 @@
.shrink-0 { .shrink-0 {
flex-shrink: 0; flex-shrink: 0;
} }
.flex-grow { .grow {
flex-grow: 1; flex-grow: 1;
} }
.translate-x-0 { .translate-x-0 {
@@ -459,6 +463,9 @@
.transform { .transform {
transform: var(--tw-rotate-x,) var(--tw-rotate-y,) var(--tw-rotate-z,) var(--tw-skew-x,) var(--tw-skew-y,); transform: var(--tw-rotate-x,) var(--tw-rotate-y,) var(--tw-rotate-z,) var(--tw-skew-x,) var(--tw-skew-y,);
} }
.cursor-pointer {
cursor: pointer;
}
.flex-col { .flex-col {
flex-direction: column; flex-direction: column;
} }
@@ -691,6 +698,9 @@
.pt-3 { .pt-3 {
padding-top: calc(var(--spacing) * 3); padding-top: calc(var(--spacing) * 3);
} }
.pt-4 {
padding-top: calc(var(--spacing) * 4);
}
.pt-9 { .pt-9 {
padding-top: calc(var(--spacing) * 9); padding-top: calc(var(--spacing) * 9);
} }
@@ -899,6 +909,16 @@
border-color: var(--overlay1); border-color: var(--overlay1);
} }
} }
.hover\:scale-105 {
&:hover {
@media (hover: hover) {
--tw-scale-x: 105%;
--tw-scale-y: 105%;
--tw-scale-z: 105%;
scale: var(--tw-scale-x) var(--tw-scale-y);
}
}
}
.hover\:cursor-pointer { .hover\:cursor-pointer {
&:hover { &:hover {
@media (hover: hover) { @media (hover: hover) {
@@ -1032,6 +1052,29 @@
} }
} }
} }
.hover\:shadow-xl {
&:hover {
@media (hover: hover) {
--tw-shadow: 0 20px 25px -5px var(--tw-shadow-color, rgb(0 0 0 / 0.1)), 0 8px 10px -6px 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);
}
}
}
.hover\:outline {
&:hover {
@media (hover: hover) {
outline-style: var(--tw-outline-style);
outline-width: 1px;
}
}
}
.hover\:outline-green {
&:hover {
@media (hover: hover) {
outline-color: var(--green);
}
}
}
.focus\:border-blue { .focus\:border-blue {
&:focus { &:focus {
border-color: var(--blue); border-color: var(--blue);
@@ -1581,6 +1624,26 @@
initial-value: ""; initial-value: "";
inherits: false; inherits: false;
} }
@property --tw-scale-x {
syntax: "*";
inherits: false;
initial-value: 1;
}
@property --tw-scale-y {
syntax: "*";
inherits: false;
initial-value: 1;
}
@property --tw-scale-z {
syntax: "*";
inherits: false;
initial-value: 1;
}
@property --tw-outline-style {
syntax: "*";
inherits: false;
initial-value: solid;
}
@layer properties { @layer properties {
@supports ((-webkit-hyphens: none) and (not (margin-trim: inline))) or ((-moz-orient: inline) and (not (color:rgb(from red r g b)))) { @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 { *, ::before, ::after, ::backdrop {
@@ -1616,6 +1679,10 @@
--tw-duration: initial; --tw-duration: initial;
--tw-ease: initial; --tw-ease: initial;
--tw-content: ""; --tw-content: "";
--tw-scale-x: 1;
--tw-scale-y: 1;
--tw-scale-z: 1;
--tw-outline-style: solid;
} }
} }
} }