Compare commits
4 Commits
ef5a0dc078
...
4c31c24069
| Author | SHA1 | Date | |
|---|---|---|---|
| 4c31c24069 | |||
| 20308fe35c | |||
| d2b1a252ea | |||
| 24bbc5337b |
6
Makefile
6
Makefile
@@ -4,7 +4,7 @@
|
|||||||
BINARY_NAME=oslstats
|
BINARY_NAME=oslstats
|
||||||
|
|
||||||
build:
|
build:
|
||||||
tailwindcss -i ./pkg/embedfs/files/css/input.css -o ./pkg/embedfs/files/css/output.css && \
|
tailwindcss -i ./internal/embedfs/web/css/input.css -o ./internal/embedfs/web/css/output.css && \
|
||||||
go mod tidy && \
|
go mod tidy && \
|
||||||
templ generate && \
|
templ generate && \
|
||||||
go generate ./cmd/${BINARY_NAME} && \
|
go generate ./cmd/${BINARY_NAME} && \
|
||||||
@@ -17,7 +17,7 @@ run:
|
|||||||
dev:
|
dev:
|
||||||
templ generate --watch &\
|
templ generate --watch &\
|
||||||
air &\
|
air &\
|
||||||
tailwindcss -i ./pkg/embedfs/files/css/input.css -o ./pkg/embedfs/files/css/output.css --watch
|
tailwindcss -i ./internal/embedfs/web/css/input.css -o ./internal/embedfs/web/css/output.css --watch
|
||||||
|
|
||||||
clean:
|
clean:
|
||||||
go clean
|
go clean
|
||||||
@@ -82,7 +82,7 @@ migrate-create:
|
|||||||
|
|
||||||
# Reset database (DESTRUCTIVE - dev only!)
|
# Reset database (DESTRUCTIVE - dev only!)
|
||||||
reset-db:
|
reset-db:
|
||||||
@echo "⚠️ WARNING: This will DELETE ALL DATA!"
|
@echo "⚠️ WARNING - This will DELETE ALL DATA!"
|
||||||
make build
|
make build
|
||||||
./bin/${BINARY_NAME}${SUFFIX} --reset-db
|
./bin/${BINARY_NAME}${SUFFIX} --reset-db
|
||||||
|
|
||||||
|
|||||||
@@ -1,20 +1,18 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
|
||||||
"database/sql"
|
"database/sql"
|
||||||
"fmt"
|
"fmt"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.haelnorr.com/h/oslstats/internal/config"
|
"git.haelnorr.com/h/oslstats/internal/config"
|
||||||
"git.haelnorr.com/h/oslstats/internal/db"
|
"git.haelnorr.com/h/oslstats/internal/db"
|
||||||
"github.com/pkg/errors"
|
|
||||||
"github.com/uptrace/bun"
|
"github.com/uptrace/bun"
|
||||||
"github.com/uptrace/bun/dialect/pgdialect"
|
"github.com/uptrace/bun/dialect/pgdialect"
|
||||||
"github.com/uptrace/bun/driver/pgdriver"
|
"github.com/uptrace/bun/driver/pgdriver"
|
||||||
)
|
)
|
||||||
|
|
||||||
func setupBun(ctx context.Context, cfg *config.Config) (conn *bun.DB, close func() error, err error) {
|
func setupBun(cfg *config.Config) (conn *bun.DB, close func() error) {
|
||||||
dsn := fmt.Sprintf("postgres://%s:%s@%s:%v/%s?sslmode=%s",
|
dsn := fmt.Sprintf("postgres://%s:%s@%s:%v/%s?sslmode=%s",
|
||||||
cfg.DB.User, cfg.DB.Password, cfg.DB.Host, cfg.DB.Port, cfg.DB.DB, cfg.DB.SSL)
|
cfg.DB.User, cfg.DB.Password, cfg.DB.Host, cfg.DB.Port, cfg.DB.DB, cfg.DB.SSL)
|
||||||
sqldb := sql.OpenDB(pgdriver.NewConnector(pgdriver.WithDSN(dsn)))
|
sqldb := sql.OpenDB(pgdriver.NewConnector(pgdriver.WithDSN(dsn)))
|
||||||
@@ -26,30 +24,19 @@ func setupBun(ctx context.Context, cfg *config.Config) (conn *bun.DB, close func
|
|||||||
|
|
||||||
conn = bun.NewDB(sqldb, pgdialect.New())
|
conn = bun.NewDB(sqldb, pgdialect.New())
|
||||||
close = sqldb.Close
|
close = sqldb.Close
|
||||||
|
return conn, close
|
||||||
err = loadModels(ctx, conn)
|
|
||||||
if err != nil {
|
|
||||||
return nil, nil, errors.Wrap(err, "loadModels")
|
|
||||||
}
|
|
||||||
|
|
||||||
return conn, close, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func loadModels(ctx context.Context, conn *bun.DB) error {
|
func registerDBModels(conn *bun.DB) {
|
||||||
models := []any{
|
models := []any{
|
||||||
|
(*db.RolePermission)(nil),
|
||||||
|
(*db.UserRole)(nil),
|
||||||
(*db.User)(nil),
|
(*db.User)(nil),
|
||||||
(*db.DiscordToken)(nil),
|
(*db.DiscordToken)(nil),
|
||||||
|
(*db.Season)(nil),
|
||||||
|
(*db.Role)(nil),
|
||||||
|
(*db.Permission)(nil),
|
||||||
|
(*db.AuditLog)(nil),
|
||||||
}
|
}
|
||||||
|
conn.RegisterModel(models...)
|
||||||
for _, model := range models {
|
|
||||||
_, err := conn.NewCreateTable().
|
|
||||||
Model(model).
|
|
||||||
IfNotExists().
|
|
||||||
Exec(ctx)
|
|
||||||
if err != nil {
|
|
||||||
return errors.Wrap(err, "db.NewCreateTable")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,9 +9,11 @@ import (
|
|||||||
"github.com/pkg/errors"
|
"github.com/pkg/errors"
|
||||||
"github.com/uptrace/bun"
|
"github.com/uptrace/bun"
|
||||||
|
|
||||||
|
"git.haelnorr.com/h/oslstats/internal/auditlog"
|
||||||
"git.haelnorr.com/h/oslstats/internal/config"
|
"git.haelnorr.com/h/oslstats/internal/config"
|
||||||
"git.haelnorr.com/h/oslstats/internal/discord"
|
"git.haelnorr.com/h/oslstats/internal/discord"
|
||||||
"git.haelnorr.com/h/oslstats/internal/handlers"
|
"git.haelnorr.com/h/oslstats/internal/handlers"
|
||||||
|
"git.haelnorr.com/h/oslstats/internal/rbac"
|
||||||
"git.haelnorr.com/h/oslstats/internal/store"
|
"git.haelnorr.com/h/oslstats/internal/store"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -58,12 +60,21 @@ func setupHTTPServer(
|
|||||||
return nil, errors.Wrap(err, "httpServer.LoggerIgnorePaths")
|
return nil, errors.Wrap(err, "httpServer.LoggerIgnorePaths")
|
||||||
}
|
}
|
||||||
|
|
||||||
err = addRoutes(httpServer, &fs, cfg, bun, auth, store, discordAPI)
|
// Initialize permissions checker
|
||||||
|
perms, err := rbac.NewChecker(bun, httpServer)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.Wrap(err, "rbac.NewChecker")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize audit logger
|
||||||
|
audit := auditlog.NewLogger(bun)
|
||||||
|
|
||||||
|
err = addRoutes(httpServer, &fs, cfg, bun, auth, store, discordAPI, perms, audit)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, errors.Wrap(err, "addRoutes")
|
return nil, errors.Wrap(err, "addRoutes")
|
||||||
}
|
}
|
||||||
|
|
||||||
err = addMiddleware(httpServer, auth, cfg)
|
err = addMiddleware(httpServer, auth, cfg, perms)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, errors.Wrap(err, "addMiddleware")
|
return nil, errors.Wrap(err, "addMiddleware")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,6 +24,21 @@ func main() {
|
|||||||
fmt.Fprintf(os.Stderr, "%s\n", errors.Wrap(err, "Failed to load config"))
|
fmt.Fprintf(os.Stderr, "%s\n", errors.Wrap(err, "Failed to load config"))
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
|
// Handle utility flags
|
||||||
|
if flags.EnvDoc || flags.ShowEnv {
|
||||||
|
if err = loader.PrintEnvVarsStdout(flags.ShowEnv); err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "%s\n", errors.Wrap(err, "Failed to print env doc"))
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if flags.GenEnv != "" {
|
||||||
|
if err = loader.GenerateEnvFile(flags.GenEnv, true); err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "%s\n", errors.Wrap(err, "Failed to generate env file"))
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
//
|
||||||
// Setup the logger
|
// Setup the logger
|
||||||
logger, err := hlog.NewLogger(cfg.HLOG, os.Stdout)
|
logger, err := hlog.NewLogger(cfg.HLOG, os.Stdout)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -31,17 +46,6 @@ func main() {
|
|||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle utility flags
|
|
||||||
if flags.EnvDoc || flags.ShowEnv {
|
|
||||||
loader.PrintEnvVarsStdout(flags.ShowEnv)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if flags.GenEnv != "" {
|
|
||||||
loader.GenerateEnvFile(flags.GenEnv, true)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle migration file creation (doesn't need DB connection)
|
// Handle migration file creation (doesn't need DB connection)
|
||||||
if flags.MigrateCreate != "" {
|
if flags.MigrateCreate != "" {
|
||||||
if err := createMigration(flags.MigrateCreate); err != nil {
|
if err := createMigration(flags.MigrateCreate); err != nil {
|
||||||
@@ -55,24 +59,17 @@ func main() {
|
|||||||
flags.MigrateStatus || flags.MigrateDryRun ||
|
flags.MigrateStatus || flags.MigrateDryRun ||
|
||||||
flags.ResetDB {
|
flags.ResetDB {
|
||||||
|
|
||||||
// Setup database connection
|
|
||||||
conn, close, err := setupBun(ctx, cfg)
|
|
||||||
if err != nil {
|
|
||||||
logger.Fatal().Err(err).Str("stacktrace", fmt.Sprintf("%+v", errors.Wrap(err, "setupBun"))).Msg("Error setting up database")
|
|
||||||
}
|
|
||||||
defer close()
|
|
||||||
|
|
||||||
// Route to appropriate command
|
// Route to appropriate command
|
||||||
if flags.MigrateUp {
|
if flags.MigrateUp {
|
||||||
err = runMigrations(ctx, conn, cfg, "up")
|
err = runMigrations(ctx, cfg, "up")
|
||||||
} else if flags.MigrateRollback {
|
} else if flags.MigrateRollback {
|
||||||
err = runMigrations(ctx, conn, cfg, "rollback")
|
err = runMigrations(ctx, cfg, "rollback")
|
||||||
} else if flags.MigrateStatus {
|
} else if flags.MigrateStatus {
|
||||||
err = runMigrations(ctx, conn, cfg, "status")
|
err = runMigrations(ctx, cfg, "status")
|
||||||
} else if flags.MigrateDryRun {
|
} else if flags.MigrateDryRun {
|
||||||
err = runMigrations(ctx, conn, cfg, "dry-run")
|
err = runMigrations(ctx, cfg, "dry-run")
|
||||||
} else if flags.ResetDB {
|
} else if flags.ResetDB {
|
||||||
err = resetDatabase(ctx, conn)
|
err = resetDatabase(ctx, cfg)
|
||||||
}
|
}
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -8,8 +8,9 @@ import (
|
|||||||
"git.haelnorr.com/h/golib/hws"
|
"git.haelnorr.com/h/golib/hws"
|
||||||
"git.haelnorr.com/h/golib/hwsauth"
|
"git.haelnorr.com/h/golib/hwsauth"
|
||||||
"git.haelnorr.com/h/oslstats/internal/config"
|
"git.haelnorr.com/h/oslstats/internal/config"
|
||||||
|
"git.haelnorr.com/h/oslstats/internal/contexts"
|
||||||
"git.haelnorr.com/h/oslstats/internal/db"
|
"git.haelnorr.com/h/oslstats/internal/db"
|
||||||
"git.haelnorr.com/h/oslstats/pkg/contexts"
|
"git.haelnorr.com/h/oslstats/internal/rbac"
|
||||||
|
|
||||||
"github.com/pkg/errors"
|
"github.com/pkg/errors"
|
||||||
"github.com/uptrace/bun"
|
"github.com/uptrace/bun"
|
||||||
@@ -19,10 +20,11 @@ func addMiddleware(
|
|||||||
server *hws.Server,
|
server *hws.Server,
|
||||||
auth *hwsauth.Authenticator[*db.User, bun.Tx],
|
auth *hwsauth.Authenticator[*db.User, bun.Tx],
|
||||||
cfg *config.Config,
|
cfg *config.Config,
|
||||||
|
perms *rbac.Checker,
|
||||||
) error {
|
) error {
|
||||||
|
|
||||||
err := server.AddMiddleware(
|
err := server.AddMiddleware(
|
||||||
auth.Authenticate(),
|
auth.Authenticate(),
|
||||||
|
perms.LoadPermissionsMiddleware(),
|
||||||
devMode(cfg),
|
devMode(cfg),
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ import (
|
|||||||
"text/tabwriter"
|
"text/tabwriter"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
stderrors "errors"
|
||||||
|
|
||||||
"git.haelnorr.com/h/oslstats/cmd/oslstats/migrations"
|
"git.haelnorr.com/h/oslstats/cmd/oslstats/migrations"
|
||||||
"git.haelnorr.com/h/oslstats/internal/backup"
|
"git.haelnorr.com/h/oslstats/internal/backup"
|
||||||
"git.haelnorr.com/h/oslstats/internal/config"
|
"git.haelnorr.com/h/oslstats/internal/config"
|
||||||
@@ -20,7 +22,10 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// runMigrations executes database migrations
|
// runMigrations executes database migrations
|
||||||
func runMigrations(ctx context.Context, conn *bun.DB, cfg *config.Config, command string) error {
|
func runMigrations(ctx context.Context, cfg *config.Config, command string) error {
|
||||||
|
conn, close := setupBun(cfg)
|
||||||
|
defer func() { _ = close() }()
|
||||||
|
|
||||||
migrator := migrate.NewMigrator(conn, migrations.Migrations)
|
migrator := migrate.NewMigrator(conn, migrations.Migrations)
|
||||||
|
|
||||||
// Initialize migration tables
|
// Initialize migration tables
|
||||||
@@ -30,7 +35,14 @@ func runMigrations(ctx context.Context, conn *bun.DB, cfg *config.Config, comman
|
|||||||
|
|
||||||
switch command {
|
switch command {
|
||||||
case "up":
|
case "up":
|
||||||
return migrateUp(ctx, migrator, conn, cfg)
|
err := migrateUp(ctx, migrator, conn, cfg)
|
||||||
|
if err != nil {
|
||||||
|
err2 := migrateRollback(ctx, migrator, conn, cfg)
|
||||||
|
if err2 != nil {
|
||||||
|
return stderrors.Join(errors.Wrap(err2, "error while rolling back after migration error"), err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return err
|
||||||
case "rollback":
|
case "rollback":
|
||||||
return migrateRollback(ctx, migrator, conn, cfg)
|
return migrateRollback(ctx, migrator, conn, cfg)
|
||||||
case "status":
|
case "status":
|
||||||
@@ -165,8 +177,8 @@ func migrateStatus(ctx context.Context, migrator *migrate.Migrator) error {
|
|||||||
fmt.Println("╚══════════════════════════════════════════════════════════╝")
|
fmt.Println("╚══════════════════════════════════════════════════════════╝")
|
||||||
|
|
||||||
w := tabwriter.NewWriter(os.Stdout, 0, 0, 3, ' ', 0)
|
w := tabwriter.NewWriter(os.Stdout, 0, 0, 3, ' ', 0)
|
||||||
fmt.Fprintln(w, "STATUS\tMIGRATION\tGROUP\tMIGRATED AT")
|
_, _ = fmt.Fprintln(w, "STATUS\tMIGRATION\tGROUP\tMIGRATED AT")
|
||||||
fmt.Fprintln(w, "------\t---------\t-----\t-----------")
|
_, _ = fmt.Fprintln(w, "------\t---------\t-----\t-----------")
|
||||||
|
|
||||||
appliedCount := 0
|
appliedCount := 0
|
||||||
for _, m := range ms {
|
for _, m := range ms {
|
||||||
@@ -183,10 +195,10 @@ func migrateStatus(ctx context.Context, migrator *migrate.Migrator) error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fmt.Fprintf(w, "%s\t%s\t%s\t%s\n", status, m.Name, group, migratedAt)
|
_, _ = fmt.Fprintf(w, "%s\t%s\t%s\t%s\n", status, m.Name, group, migratedAt)
|
||||||
}
|
}
|
||||||
|
|
||||||
w.Flush()
|
_ = w.Flush()
|
||||||
|
|
||||||
fmt.Printf("\n📊 Summary: %d applied, %d pending\n\n",
|
fmt.Printf("\n📊 Summary: %d applied, %d pending\n\n",
|
||||||
appliedCount, len(ms)-appliedCount)
|
appliedCount, len(ms)-appliedCount)
|
||||||
@@ -293,12 +305,12 @@ func init() {
|
|||||||
Migrations.MustRegister(
|
Migrations.MustRegister(
|
||||||
// UP migration
|
// UP migration
|
||||||
func(ctx context.Context, dbConn *bun.DB) error {
|
func(ctx context.Context, dbConn *bun.DB) error {
|
||||||
// TODO: Add your migration code here
|
// Add your migration code here
|
||||||
return nil
|
return nil
|
||||||
},
|
},
|
||||||
// DOWN migration
|
// DOWN migration
|
||||||
func(ctx context.Context, dbConn *bun.DB) error {
|
func(ctx context.Context, dbConn *bun.DB) error {
|
||||||
// TODO: Add your rollback code here
|
// Add your rollback code here
|
||||||
return nil
|
return nil
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
@@ -306,7 +318,7 @@ func init() {
|
|||||||
`
|
`
|
||||||
|
|
||||||
// Write file
|
// Write file
|
||||||
if err := os.WriteFile(filename, []byte(template), 0644); err != nil {
|
if err := os.WriteFile(filename, []byte(template), 0o644); err != nil {
|
||||||
return errors.Wrap(err, "write migration file")
|
return errors.Wrap(err, "write migration file")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -319,8 +331,8 @@ func init() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// resetDatabase drops and recreates all tables (destructive)
|
// resetDatabase drops and recreates all tables (destructive)
|
||||||
func resetDatabase(ctx context.Context, conn *bun.DB) error {
|
func resetDatabase(ctx context.Context, cfg *config.Config) error {
|
||||||
fmt.Println("⚠️ WARNING: This will DELETE ALL DATA in the database!")
|
fmt.Println("⚠️ WARNING - This will DELETE ALL DATA in the database!")
|
||||||
fmt.Print("Type 'yes' to continue: ")
|
fmt.Print("Type 'yes' to continue: ")
|
||||||
|
|
||||||
reader := bufio.NewReader(os.Stdin)
|
reader := bufio.NewReader(os.Stdin)
|
||||||
@@ -334,6 +346,8 @@ func resetDatabase(ctx context.Context, conn *bun.DB) error {
|
|||||||
fmt.Println("❌ Reset cancelled")
|
fmt.Println("❌ Reset cancelled")
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
conn, close := setupBun(cfg)
|
||||||
|
defer func() { _ = close() }()
|
||||||
|
|
||||||
models := []any{
|
models := []any{
|
||||||
(*db.User)(nil),
|
(*db.User)(nil),
|
||||||
|
|||||||
244
cmd/oslstats/migrations/20260202231414_add_rbac_system.go
Normal file
244
cmd/oslstats/migrations/20260202231414_add_rbac_system.go
Normal file
@@ -0,0 +1,244 @@
|
|||||||
|
package migrations
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.haelnorr.com/h/oslstats/internal/db"
|
||||||
|
"github.com/uptrace/bun"
|
||||||
|
)
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
Migrations.MustRegister(
|
||||||
|
// UP migration
|
||||||
|
func(ctx context.Context, dbConn *bun.DB) error {
|
||||||
|
dbConn.RegisterModel((*db.RolePermission)(nil), (*db.UserRole)(nil))
|
||||||
|
// Create permissions table
|
||||||
|
_, err := dbConn.NewCreateTable().
|
||||||
|
Model((*db.Role)(nil)).
|
||||||
|
Exec(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create permissions table
|
||||||
|
_, err = dbConn.NewCreateTable().
|
||||||
|
Model((*db.Permission)(nil)).
|
||||||
|
Exec(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create indexes for permissions
|
||||||
|
_, err = dbConn.NewCreateIndex().
|
||||||
|
Model((*db.Permission)(nil)).
|
||||||
|
Index("idx_permissions_resource").
|
||||||
|
Column("resource").
|
||||||
|
Exec(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = dbConn.NewCreateIndex().
|
||||||
|
Model((*db.Permission)(nil)).
|
||||||
|
Index("idx_permissions_action").
|
||||||
|
Column("action").
|
||||||
|
Exec(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = dbConn.NewCreateTable().
|
||||||
|
Model((*db.RolePermission)(nil)).
|
||||||
|
Exec(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = dbConn.ExecContext(ctx, `
|
||||||
|
CREATE INDEX idx_role_permissions_role ON role_permissions(role_id)
|
||||||
|
`)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = dbConn.ExecContext(ctx, `
|
||||||
|
CREATE INDEX idx_role_permissions_permission ON role_permissions(permission_id)
|
||||||
|
`)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create user_roles table
|
||||||
|
_, err = dbConn.NewCreateTable().
|
||||||
|
Model((*db.UserRole)(nil)).
|
||||||
|
Exec(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create indexes for user_roles
|
||||||
|
_, err = dbConn.NewCreateIndex().
|
||||||
|
Model((*db.UserRole)(nil)).
|
||||||
|
Index("idx_user_roles_user").
|
||||||
|
Column("user_id").
|
||||||
|
Exec(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = dbConn.NewCreateIndex().
|
||||||
|
Model((*db.UserRole)(nil)).
|
||||||
|
Index("idx_user_roles_role").
|
||||||
|
Column("role_id").
|
||||||
|
Exec(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create audit_log table
|
||||||
|
_, err = dbConn.NewCreateTable().
|
||||||
|
Model((*db.AuditLog)(nil)).
|
||||||
|
Exec(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create indexes for audit_log
|
||||||
|
_, err = dbConn.NewCreateIndex().
|
||||||
|
Model((*db.AuditLog)(nil)).
|
||||||
|
Index("idx_audit_log_user").
|
||||||
|
Column("user_id").
|
||||||
|
Exec(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = dbConn.NewCreateIndex().
|
||||||
|
Model((*db.AuditLog)(nil)).
|
||||||
|
Index("idx_audit_log_action").
|
||||||
|
Column("action").
|
||||||
|
Exec(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = dbConn.NewCreateIndex().
|
||||||
|
Model((*db.AuditLog)(nil)).
|
||||||
|
Index("idx_audit_log_resource").
|
||||||
|
Column("resource_type", "resource_id").
|
||||||
|
Exec(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = dbConn.NewCreateIndex().
|
||||||
|
Model((*db.AuditLog)(nil)).
|
||||||
|
Index("idx_audit_log_created").
|
||||||
|
Column("created_at").
|
||||||
|
Exec(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Seed system roles
|
||||||
|
now := time.Now().Unix()
|
||||||
|
|
||||||
|
adminRole := &db.Role{
|
||||||
|
Name: "admin",
|
||||||
|
DisplayName: "Administrator",
|
||||||
|
Description: "Full system access with all permissions",
|
||||||
|
IsSystem: true,
|
||||||
|
CreatedAt: now,
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = dbConn.NewInsert().
|
||||||
|
Model(adminRole).
|
||||||
|
Returning("id").
|
||||||
|
Exec(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
userRole := &db.Role{
|
||||||
|
Name: "user",
|
||||||
|
DisplayName: "User",
|
||||||
|
Description: "Standard user with basic permissions",
|
||||||
|
IsSystem: true,
|
||||||
|
CreatedAt: now,
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = dbConn.NewInsert().
|
||||||
|
Model(userRole).
|
||||||
|
Exec(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Seed system permissions
|
||||||
|
permissionsData := []*db.Permission{
|
||||||
|
{Name: "*", DisplayName: "Wildcard (All Permissions)", Description: "Grants access to all permissions, past, present, and future", Resource: "*", Action: "*", IsSystem: true, CreatedAt: now},
|
||||||
|
{Name: "seasons.create", DisplayName: "Create Seasons", Description: "Create new seasons", Resource: "seasons", Action: "create", IsSystem: true, CreatedAt: now},
|
||||||
|
{Name: "seasons.update", DisplayName: "Update Seasons", Description: "Update existing seasons", Resource: "seasons", Action: "update", IsSystem: true, CreatedAt: now},
|
||||||
|
{Name: "seasons.delete", DisplayName: "Delete Seasons", Description: "Delete seasons", Resource: "seasons", Action: "delete", IsSystem: true, CreatedAt: now},
|
||||||
|
{Name: "users.update", DisplayName: "Update Users", Description: "Update user information", Resource: "users", Action: "update", IsSystem: true, CreatedAt: now},
|
||||||
|
{Name: "users.ban", DisplayName: "Ban Users", Description: "Ban users from the system", Resource: "users", Action: "ban", IsSystem: true, CreatedAt: now},
|
||||||
|
{Name: "users.manage_roles", DisplayName: "Manage User Roles", Description: "Assign and revoke user roles", Resource: "users", Action: "manage_roles", IsSystem: true, CreatedAt: now},
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = dbConn.NewInsert().
|
||||||
|
Model(&permissionsData).
|
||||||
|
Exec(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Grant wildcard permission to admin role using Bun
|
||||||
|
// First, get the IDs
|
||||||
|
var wildcardPerm db.Permission
|
||||||
|
err = dbConn.NewSelect().
|
||||||
|
Model(&wildcardPerm).
|
||||||
|
Where("name = ?", "*").
|
||||||
|
Scan(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Insert role_permission mapping
|
||||||
|
adminRolePerms := &db.RolePermission{
|
||||||
|
RoleID: adminRole.ID,
|
||||||
|
PermissionID: wildcardPerm.ID,
|
||||||
|
}
|
||||||
|
_, err = dbConn.NewInsert().
|
||||||
|
Model(adminRolePerms).
|
||||||
|
On("CONFLICT (role_id, permission_id) DO NOTHING").
|
||||||
|
Exec(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
// DOWN migration
|
||||||
|
func(ctx context.Context, dbConn *bun.DB) error {
|
||||||
|
// Drop tables in reverse order
|
||||||
|
// Use raw SQL to avoid relationship resolution issues
|
||||||
|
tables := []string{
|
||||||
|
"audit_log",
|
||||||
|
"user_roles",
|
||||||
|
"role_permissions",
|
||||||
|
"permissions",
|
||||||
|
"roles",
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, table := range tables {
|
||||||
|
_, err := dbConn.ExecContext(ctx, "DROP TABLE IF EXISTS "+table+" CASCADE")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
// Package migrations defines the database migrations to apply when using the migrate tags
|
||||||
package migrations
|
package migrations
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
|||||||
@@ -8,10 +8,12 @@ import (
|
|||||||
"github.com/pkg/errors"
|
"github.com/pkg/errors"
|
||||||
"github.com/uptrace/bun"
|
"github.com/uptrace/bun"
|
||||||
|
|
||||||
|
"git.haelnorr.com/h/oslstats/internal/auditlog"
|
||||||
"git.haelnorr.com/h/oslstats/internal/config"
|
"git.haelnorr.com/h/oslstats/internal/config"
|
||||||
"git.haelnorr.com/h/oslstats/internal/db"
|
"git.haelnorr.com/h/oslstats/internal/db"
|
||||||
"git.haelnorr.com/h/oslstats/internal/discord"
|
"git.haelnorr.com/h/oslstats/internal/discord"
|
||||||
"git.haelnorr.com/h/oslstats/internal/handlers"
|
"git.haelnorr.com/h/oslstats/internal/handlers"
|
||||||
|
"git.haelnorr.com/h/oslstats/internal/rbac"
|
||||||
"git.haelnorr.com/h/oslstats/internal/store"
|
"git.haelnorr.com/h/oslstats/internal/store"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -23,9 +25,16 @@ func addRoutes(
|
|||||||
auth *hwsauth.Authenticator[*db.User, bun.Tx],
|
auth *hwsauth.Authenticator[*db.User, bun.Tx],
|
||||||
store *store.Store,
|
store *store.Store,
|
||||||
discordAPI *discord.APIClient,
|
discordAPI *discord.APIClient,
|
||||||
|
perms *rbac.Checker,
|
||||||
|
audit *auditlog.Logger,
|
||||||
) error {
|
) error {
|
||||||
// Create the routes
|
// Create the routes
|
||||||
pageroutes := []hws.Route{
|
pageroutes := []hws.Route{
|
||||||
|
{
|
||||||
|
Path: "/permtest",
|
||||||
|
Methods: []hws.Method{hws.MethodGET, hws.MethodPOST},
|
||||||
|
Handler: handlers.PermTester(s, conn),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
Path: "/static/",
|
Path: "/static/",
|
||||||
Method: hws.MethodGET,
|
Method: hws.MethodGET,
|
||||||
@@ -59,8 +68,7 @@ func addRoutes(
|
|||||||
{
|
{
|
||||||
Path: "/notification-tester",
|
Path: "/notification-tester",
|
||||||
Methods: []hws.Method{hws.MethodGET, hws.MethodPOST},
|
Methods: []hws.Method{hws.MethodGET, hws.MethodPOST},
|
||||||
Handler: handlers.NotifyTester(s),
|
Handler: perms.RequireAdmin(s)(handlers.NotifyTester(s)),
|
||||||
// TODO: add login protection
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
Path: "/seasons",
|
Path: "/seasons",
|
||||||
@@ -115,8 +123,24 @@ func addRoutes(
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Admin routes
|
||||||
|
adminRoutes := []hws.Route{
|
||||||
|
{
|
||||||
|
// TODO: on page load, redirect to /admin/users
|
||||||
|
Path: "/admin",
|
||||||
|
Method: hws.MethodGET,
|
||||||
|
Handler: perms.RequireAdmin(s)(handlers.AdminDashboard(s, conn)),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Path: "/admin/users",
|
||||||
|
Method: hws.MethodPOST,
|
||||||
|
Handler: perms.RequireAdmin(s)(handlers.AdminUsersList(s, conn)),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
routes := append(pageroutes, htmxRoutes...)
|
routes := append(pageroutes, htmxRoutes...)
|
||||||
routes = append(routes, wsRoutes...)
|
routes = append(routes, wsRoutes...)
|
||||||
|
routes = append(routes, adminRoutes...)
|
||||||
|
|
||||||
// Register the routes with the server
|
// Register the routes with the server
|
||||||
err := s.AddRoutes(routes...)
|
err := s.AddRoutes(routes...)
|
||||||
|
|||||||
@@ -13,8 +13,8 @@ import (
|
|||||||
|
|
||||||
"git.haelnorr.com/h/oslstats/internal/config"
|
"git.haelnorr.com/h/oslstats/internal/config"
|
||||||
"git.haelnorr.com/h/oslstats/internal/discord"
|
"git.haelnorr.com/h/oslstats/internal/discord"
|
||||||
|
"git.haelnorr.com/h/oslstats/internal/embedfs"
|
||||||
"git.haelnorr.com/h/oslstats/internal/store"
|
"git.haelnorr.com/h/oslstats/internal/store"
|
||||||
"git.haelnorr.com/h/oslstats/pkg/embedfs"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// Initializes and runs the server
|
// Initializes and runs the server
|
||||||
@@ -25,10 +25,8 @@ func run(ctx context.Context, logger *hlog.Logger, cfg *config.Config) error {
|
|||||||
// 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")
|
||||||
bun, closedb, err := setupBun(ctx, cfg)
|
bun, closedb := setupBun(cfg)
|
||||||
if err != nil {
|
registerDBModels(bun)
|
||||||
return errors.Wrap(err, "setupDBConn")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Setup embedded files
|
// Setup embedded files
|
||||||
logger.Debug().Msg("Getting embedded files")
|
logger.Debug().Msg("Getting embedded files")
|
||||||
|
|||||||
4
go.mod
4
go.mod
@@ -6,8 +6,8 @@ require (
|
|||||||
git.haelnorr.com/h/golib/env v0.9.1
|
git.haelnorr.com/h/golib/env v0.9.1
|
||||||
git.haelnorr.com/h/golib/ezconf v0.1.1
|
git.haelnorr.com/h/golib/ezconf v0.1.1
|
||||||
git.haelnorr.com/h/golib/hlog v0.10.4
|
git.haelnorr.com/h/golib/hlog v0.10.4
|
||||||
git.haelnorr.com/h/golib/hws v0.4.4
|
git.haelnorr.com/h/golib/hws v0.5.0
|
||||||
git.haelnorr.com/h/golib/hwsauth v0.5.4
|
git.haelnorr.com/h/golib/hwsauth v0.5.5
|
||||||
git.haelnorr.com/h/golib/notify v0.1.0
|
git.haelnorr.com/h/golib/notify v0.1.0
|
||||||
github.com/a-h/templ v0.3.977
|
github.com/a-h/templ v0.3.977
|
||||||
github.com/coder/websocket v1.8.14
|
github.com/coder/websocket v1.8.14
|
||||||
|
|||||||
8
go.sum
8
go.sum
@@ -6,10 +6,10 @@ git.haelnorr.com/h/golib/ezconf v0.1.1 h1:4euTSDb9jvuQQkVq+x5gHoYPYyUZPWxoOSlWCI
|
|||||||
git.haelnorr.com/h/golib/ezconf v0.1.1/go.mod h1:rETDcjpcEyyeBgCiZSU617wc0XycwZSC5+IAOtXmwP8=
|
git.haelnorr.com/h/golib/ezconf v0.1.1/go.mod h1:rETDcjpcEyyeBgCiZSU617wc0XycwZSC5+IAOtXmwP8=
|
||||||
git.haelnorr.com/h/golib/hlog v0.10.4 h1:vpCsV/OddjIYx8F48U66WxojjmhEbeLGQAOBG4ViSRQ=
|
git.haelnorr.com/h/golib/hlog v0.10.4 h1:vpCsV/OddjIYx8F48U66WxojjmhEbeLGQAOBG4ViSRQ=
|
||||||
git.haelnorr.com/h/golib/hlog v0.10.4/go.mod h1:+wJ8vecQY/JITTXKmI3JfkHiUGyMs7N6wooj2wuWZbc=
|
git.haelnorr.com/h/golib/hlog v0.10.4/go.mod h1:+wJ8vecQY/JITTXKmI3JfkHiUGyMs7N6wooj2wuWZbc=
|
||||||
git.haelnorr.com/h/golib/hws v0.4.4 h1:tV9UjZ4q96UlOdJKsC7b3kDV+bpQYqKVPQuaV1n3U3k=
|
git.haelnorr.com/h/golib/hws v0.5.0 h1:0CSv2f+dm/KzB/o5o6uXCyvN74iBdMTImhkyAZzU52c=
|
||||||
git.haelnorr.com/h/golib/hws v0.4.4/go.mod h1:dxAbbGGNzqLXhZXwgt091QsvsPBdrS+1YsNQNldNVoM=
|
git.haelnorr.com/h/golib/hws v0.5.0/go.mod h1:dxAbbGGNzqLXhZXwgt091QsvsPBdrS+1YsNQNldNVoM=
|
||||||
git.haelnorr.com/h/golib/hwsauth v0.5.4 h1:nuaiVpJHHXgKVRPoQSE/v3CJHSkivViK5h3SVhEcbbM=
|
git.haelnorr.com/h/golib/hwsauth v0.5.5 h1:w1qssktq0zYo5cC/xa44h/ZE5G5r7rIsJ4QQWq2Jeoo=
|
||||||
git.haelnorr.com/h/golib/hwsauth v0.5.4/go.mod h1:eIjRPeGycvxRWERkxCoRVMEEhHuUdiPDvjpzzZOhQ0w=
|
git.haelnorr.com/h/golib/hwsauth v0.5.5/go.mod h1:xPdxqHzr1ZU0MHlG4o8r1zEstBu4FJCdaA0ZHSFxmKA=
|
||||||
git.haelnorr.com/h/golib/jwt v0.10.1 h1:1Adxt9H3Y4fWFvFjWpvg/vSFhbgCMDMxgiE3m7KvDMI=
|
git.haelnorr.com/h/golib/jwt v0.10.1 h1:1Adxt9H3Y4fWFvFjWpvg/vSFhbgCMDMxgiE3m7KvDMI=
|
||||||
git.haelnorr.com/h/golib/jwt v0.10.1/go.mod h1:fbuPrfucT9lL0faV5+Q5Gk9WFJxPlwzRPpbMQKYZok4=
|
git.haelnorr.com/h/golib/jwt v0.10.1/go.mod h1:fbuPrfucT9lL0faV5+Q5Gk9WFJxPlwzRPpbMQKYZok4=
|
||||||
git.haelnorr.com/h/golib/notify v0.1.0 h1:xdf6zd21F6n+SuGTeJiuLNMf6zFXMvwpKD0gmNq8N10=
|
git.haelnorr.com/h/golib/notify v0.1.0 h1:xdf6zd21F6n+SuGTeJiuLNMf6zFXMvwpKD0gmNq8N10=
|
||||||
|
|||||||
164
internal/auditlog/logger.go
Normal file
164
internal/auditlog/logger.go
Normal file
@@ -0,0 +1,164 @@
|
|||||||
|
// Package auditlog provides a system for logging events that require permissions to the audit log
|
||||||
|
package auditlog
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.haelnorr.com/h/oslstats/internal/db"
|
||||||
|
"github.com/pkg/errors"
|
||||||
|
"github.com/uptrace/bun"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Logger struct {
|
||||||
|
conn *bun.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewLogger(conn *bun.DB) *Logger {
|
||||||
|
return &Logger{conn: conn}
|
||||||
|
}
|
||||||
|
|
||||||
|
// LogSuccess logs a successful permission-protected action
|
||||||
|
func (l *Logger) LogSuccess(
|
||||||
|
ctx context.Context,
|
||||||
|
tx bun.Tx,
|
||||||
|
user *db.User,
|
||||||
|
action string,
|
||||||
|
resourceType string,
|
||||||
|
resourceID any, // Can be int, string, or nil
|
||||||
|
details map[string]any,
|
||||||
|
r *http.Request,
|
||||||
|
) error {
|
||||||
|
return l.log(ctx, tx, user, action, resourceType, resourceID, details, "success", nil, r)
|
||||||
|
}
|
||||||
|
|
||||||
|
// LogError logs a failed action due to an error
|
||||||
|
func (l *Logger) LogError(
|
||||||
|
ctx context.Context,
|
||||||
|
tx bun.Tx,
|
||||||
|
user *db.User,
|
||||||
|
action string,
|
||||||
|
resourceType string,
|
||||||
|
resourceID any,
|
||||||
|
err error,
|
||||||
|
r *http.Request,
|
||||||
|
) error {
|
||||||
|
errMsg := err.Error()
|
||||||
|
return l.log(ctx, tx, user, action, resourceType, resourceID, nil, "error", &errMsg, r)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *Logger) log(
|
||||||
|
ctx context.Context,
|
||||||
|
tx bun.Tx,
|
||||||
|
user *db.User,
|
||||||
|
action string,
|
||||||
|
resourceType string,
|
||||||
|
resourceID any,
|
||||||
|
details map[string]any,
|
||||||
|
result string,
|
||||||
|
errorMessage *string,
|
||||||
|
r *http.Request,
|
||||||
|
) error {
|
||||||
|
if user == nil {
|
||||||
|
return errors.New("user cannot be nil for audit logging")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert resourceID to string
|
||||||
|
var resourceIDStr *string
|
||||||
|
if resourceID != nil {
|
||||||
|
idStr := fmt.Sprintf("%v", resourceID)
|
||||||
|
resourceIDStr = &idStr
|
||||||
|
}
|
||||||
|
|
||||||
|
// Marshal details to JSON
|
||||||
|
var detailsJSON json.RawMessage
|
||||||
|
if details != nil {
|
||||||
|
jsonBytes, err := json.Marshal(details)
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(err, "json.Marshal details")
|
||||||
|
}
|
||||||
|
detailsJSON = jsonBytes
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract IP and User-Agent from request
|
||||||
|
ipAddress := r.RemoteAddr
|
||||||
|
userAgent := r.UserAgent()
|
||||||
|
|
||||||
|
log := &db.AuditLog{
|
||||||
|
UserID: user.ID,
|
||||||
|
Action: action,
|
||||||
|
ResourceType: resourceType,
|
||||||
|
ResourceID: resourceIDStr,
|
||||||
|
Details: detailsJSON,
|
||||||
|
IPAddress: ipAddress,
|
||||||
|
UserAgent: userAgent,
|
||||||
|
Result: result,
|
||||||
|
ErrorMessage: errorMessage,
|
||||||
|
CreatedAt: time.Now().Unix(),
|
||||||
|
}
|
||||||
|
|
||||||
|
return db.CreateAuditLog(ctx, tx, log)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetRecentLogs retrieves recent audit logs with pagination
|
||||||
|
func (l *Logger) GetRecentLogs(ctx context.Context, pageOpts *db.PageOpts) (*db.AuditLogs, error) {
|
||||||
|
tx, err := l.conn.BeginTx(ctx, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.Wrap(err, "conn.BeginTx")
|
||||||
|
}
|
||||||
|
defer func() { _ = tx.Rollback() }()
|
||||||
|
|
||||||
|
logs, err := db.GetAuditLogs(ctx, tx, pageOpts, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
_ = tx.Commit() // read only transaction
|
||||||
|
return logs, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetLogsByUser retrieves audit logs for a specific user
|
||||||
|
func (l *Logger) GetLogsByUser(ctx context.Context, userID int, pageOpts *db.PageOpts) (*db.AuditLogs, error) {
|
||||||
|
tx, err := l.conn.BeginTx(ctx, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.Wrap(err, "conn.BeginTx")
|
||||||
|
}
|
||||||
|
defer func() { _ = tx.Rollback() }()
|
||||||
|
|
||||||
|
logs, err := db.GetAuditLogsByUser(ctx, tx, userID, pageOpts)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
_ = tx.Commit() // read only transaction
|
||||||
|
return logs, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CleanupOldLogs deletes audit logs older than the specified number of days
|
||||||
|
func (l *Logger) CleanupOldLogs(ctx context.Context, daysToKeep int) (int, error) {
|
||||||
|
if daysToKeep <= 0 {
|
||||||
|
return 0, errors.New("daysToKeep must be positive")
|
||||||
|
}
|
||||||
|
|
||||||
|
cutoffTime := time.Now().AddDate(0, 0, -daysToKeep).Unix()
|
||||||
|
|
||||||
|
tx, err := l.conn.BeginTx(ctx, nil)
|
||||||
|
if err != nil {
|
||||||
|
return 0, errors.Wrap(err, "conn.BeginTx")
|
||||||
|
}
|
||||||
|
defer func() { _ = tx.Rollback() }()
|
||||||
|
|
||||||
|
count, err := db.CleanupOldAuditLogs(ctx, tx, cutoffTime)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
err = tx.Commit()
|
||||||
|
if err != nil {
|
||||||
|
return 0, errors.Wrap(err, "tx.Commit")
|
||||||
|
}
|
||||||
|
return count, nil
|
||||||
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
// Package config provides the environment based configuration for the program
|
||||||
package config
|
package config
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -7,6 +8,7 @@ import (
|
|||||||
"git.haelnorr.com/h/golib/hwsauth"
|
"git.haelnorr.com/h/golib/hwsauth"
|
||||||
"git.haelnorr.com/h/oslstats/internal/db"
|
"git.haelnorr.com/h/oslstats/internal/db"
|
||||||
"git.haelnorr.com/h/oslstats/internal/discord"
|
"git.haelnorr.com/h/oslstats/internal/discord"
|
||||||
|
"git.haelnorr.com/h/oslstats/internal/rbac"
|
||||||
"git.haelnorr.com/h/oslstats/pkg/oauth"
|
"git.haelnorr.com/h/oslstats/pkg/oauth"
|
||||||
"github.com/joho/godotenv"
|
"github.com/joho/godotenv"
|
||||||
"github.com/pkg/errors"
|
"github.com/pkg/errors"
|
||||||
@@ -19,10 +21,11 @@ type Config struct {
|
|||||||
HLOG *hlog.Config
|
HLOG *hlog.Config
|
||||||
Discord *discord.Config
|
Discord *discord.Config
|
||||||
OAuth *oauth.Config
|
OAuth *oauth.Config
|
||||||
|
RBAC *rbac.Config
|
||||||
Flags *Flags
|
Flags *Flags
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load the application configuration and get a pointer to the Config object
|
// GetConfig loads the application configuration and returns a pointer to the Config object
|
||||||
// If doconly is specified, only the loader will be returned
|
// If doconly is specified, only the loader will be returned
|
||||||
func GetConfig(flags *Flags) (*Config, *ezconf.ConfigLoader, error) {
|
func GetConfig(flags *Flags) (*Config, *ezconf.ConfigLoader, error) {
|
||||||
err := godotenv.Load(flags.EnvFile)
|
err := godotenv.Load(flags.EnvFile)
|
||||||
@@ -31,14 +34,18 @@ func GetConfig(flags *Flags) (*Config, *ezconf.ConfigLoader, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
loader := ezconf.New()
|
loader := ezconf.New()
|
||||||
loader.RegisterIntegrations(
|
err = loader.RegisterIntegrations(
|
||||||
hlog.NewEZConfIntegration(),
|
hlog.NewEZConfIntegration(),
|
||||||
hws.NewEZConfIntegration(),
|
hws.NewEZConfIntegration(),
|
||||||
hwsauth.NewEZConfIntegration(),
|
hwsauth.NewEZConfIntegration(),
|
||||||
db.NewEZConfIntegration(),
|
db.NewEZConfIntegration(),
|
||||||
discord.NewEZConfIntegration(),
|
discord.NewEZConfIntegration(),
|
||||||
oauth.NewEZConfIntegration(),
|
oauth.NewEZConfIntegration(),
|
||||||
|
rbac.NewEZConfIntegration(),
|
||||||
)
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, errors.Wrap(err, "loader.RegisterIntegrations")
|
||||||
|
}
|
||||||
if err := loader.ParseEnvVars(); err != nil {
|
if err := loader.ParseEnvVars(); err != nil {
|
||||||
return nil, nil, errors.Wrap(err, "loader.ParseEnvVars")
|
return nil, nil, errors.Wrap(err, "loader.ParseEnvVars")
|
||||||
}
|
}
|
||||||
@@ -81,6 +88,11 @@ func GetConfig(flags *Flags) (*Config, *ezconf.ConfigLoader, error) {
|
|||||||
return nil, nil, errors.New("OAuth Config not loaded")
|
return nil, nil, errors.New("OAuth Config not loaded")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
rbaccfg, ok := loader.GetConfig("rbac")
|
||||||
|
if !ok {
|
||||||
|
return nil, nil, errors.New("RBAC Config not loaded")
|
||||||
|
}
|
||||||
|
|
||||||
config := &Config{
|
config := &Config{
|
||||||
DB: dbcfg.(*db.Config),
|
DB: dbcfg.(*db.Config),
|
||||||
HWS: hwscfg.(*hws.Config),
|
HWS: hwscfg.(*hws.Config),
|
||||||
@@ -88,6 +100,7 @@ func GetConfig(flags *Flags) (*Config, *ezconf.ConfigLoader, error) {
|
|||||||
HLOG: hlogcfg.(*hlog.Config),
|
HLOG: hlogcfg.(*hlog.Config),
|
||||||
Discord: discordcfg.(*discord.Config),
|
Discord: discordcfg.(*discord.Config),
|
||||||
OAuth: oauthcfg.(*oauth.Config),
|
OAuth: oauthcfg.(*oauth.Config),
|
||||||
|
RBAC: rbaccfg.(*rbac.Config),
|
||||||
Flags: flags,
|
Flags: flags,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,14 +2,6 @@ package contexts
|
|||||||
|
|
||||||
import "context"
|
import "context"
|
||||||
|
|
||||||
type Key string
|
|
||||||
|
|
||||||
func (c Key) String() string {
|
|
||||||
return "oslstats context key " + string(c)
|
|
||||||
}
|
|
||||||
|
|
||||||
var DevModeKey Key = Key("devmode")
|
|
||||||
|
|
||||||
func DevMode(ctx context.Context) DevInfo {
|
func DevMode(ctx context.Context) DevInfo {
|
||||||
devmode, ok := ctx.Value(DevModeKey).(DevInfo)
|
devmode, ok := ctx.Value(DevModeKey).(DevInfo)
|
||||||
if !ok {
|
if !ok {
|
||||||
13
internal/contexts/keys.go
Normal file
13
internal/contexts/keys.go
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
// Package contexts provides utilities for loading and extracting structs from contexts
|
||||||
|
package contexts
|
||||||
|
|
||||||
|
type Key string
|
||||||
|
|
||||||
|
func (c Key) String() string {
|
||||||
|
return "oslstats context key " + string(c)
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
DevModeKey Key = Key("devmode")
|
||||||
|
PermissionCacheKey Key = Key("permissions")
|
||||||
|
)
|
||||||
23
internal/contexts/permissions.go
Normal file
23
internal/contexts/permissions.go
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
package contexts
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"git.haelnorr.com/h/oslstats/internal/permissions"
|
||||||
|
"git.haelnorr.com/h/oslstats/internal/roles"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Permissions retrieves the permission cache from context (type-safe)
|
||||||
|
func Permissions(ctx context.Context) *PermissionCache {
|
||||||
|
cache, ok := ctx.Value(PermissionCacheKey).(*PermissionCache)
|
||||||
|
if !ok {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return cache
|
||||||
|
}
|
||||||
|
|
||||||
|
type PermissionCache struct {
|
||||||
|
Permissions map[permissions.Permission]bool
|
||||||
|
Roles map[roles.Role]bool
|
||||||
|
HasWildcard bool
|
||||||
|
}
|
||||||
151
internal/db/auditlog.go
Normal file
151
internal/db/auditlog.go
Normal file
@@ -0,0 +1,151 @@
|
|||||||
|
package db
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"encoding/json"
|
||||||
|
|
||||||
|
"github.com/pkg/errors"
|
||||||
|
"github.com/uptrace/bun"
|
||||||
|
)
|
||||||
|
|
||||||
|
type AuditLog struct {
|
||||||
|
bun.BaseModel `bun:"table:audit_log,alias:al"`
|
||||||
|
|
||||||
|
ID int `bun:"id,pk,autoincrement"`
|
||||||
|
UserID int `bun:"user_id,notnull"`
|
||||||
|
Action string `bun:"action,notnull"`
|
||||||
|
ResourceType string `bun:"resource_type,notnull"`
|
||||||
|
ResourceID *string `bun:"resource_id"`
|
||||||
|
Details json.RawMessage `bun:"details,type:jsonb"`
|
||||||
|
IPAddress string `bun:"ip_address"`
|
||||||
|
UserAgent string `bun:"user_agent"`
|
||||||
|
Result string `bun:"result,notnull"` // success, denied, error
|
||||||
|
ErrorMessage *string `bun:"error_message"`
|
||||||
|
CreatedAt int64 `bun:"created_at,notnull"`
|
||||||
|
|
||||||
|
// Relations
|
||||||
|
User *User `bun:"rel:belongs-to,join:user_id=id"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type AuditLogs struct {
|
||||||
|
AuditLogs []*AuditLog
|
||||||
|
Total int
|
||||||
|
PageOpts PageOpts
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateAuditLog creates a new audit log entry
|
||||||
|
func CreateAuditLog(ctx context.Context, tx bun.Tx, log *AuditLog) error {
|
||||||
|
if log == nil {
|
||||||
|
return errors.New("log cannot be nil")
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := tx.NewInsert().
|
||||||
|
Model(log).
|
||||||
|
Exec(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(err, "tx.NewInsert")
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type AuditLogFilters struct {
|
||||||
|
UserID *int
|
||||||
|
Action *string
|
||||||
|
ResourceType *string
|
||||||
|
Result *string
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetAuditLogs retrieves audit logs with optional filters and pagination
|
||||||
|
func GetAuditLogs(ctx context.Context, tx bun.Tx, pageOpts *PageOpts, filters *AuditLogFilters) (*AuditLogs, error) {
|
||||||
|
pageOpts = setDefaultPageOpts(pageOpts, 1, 50, bun.OrderDesc, "created_at")
|
||||||
|
query := tx.NewSelect().
|
||||||
|
Model((*AuditLog)(nil)).
|
||||||
|
Relation("User").
|
||||||
|
OrderBy(pageOpts.OrderBy, pageOpts.Order)
|
||||||
|
|
||||||
|
// Apply filters if provided
|
||||||
|
if filters != nil {
|
||||||
|
if filters.UserID != nil {
|
||||||
|
query = query.Where("al.user_id = ?", *filters.UserID)
|
||||||
|
}
|
||||||
|
if filters.Action != nil {
|
||||||
|
query = query.Where("al.action = ?", *filters.Action)
|
||||||
|
}
|
||||||
|
if filters.ResourceType != nil {
|
||||||
|
query = query.Where("al.resource_type = ?", *filters.ResourceType)
|
||||||
|
}
|
||||||
|
if filters.Result != nil {
|
||||||
|
query = query.Where("al.result = ?", *filters.Result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get total count
|
||||||
|
total, err := query.Count(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.Wrap(err, "query.Count")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get paginated results
|
||||||
|
logs := new([]*AuditLog)
|
||||||
|
err = query.
|
||||||
|
Offset(pageOpts.PerPage*(pageOpts.Page-1)).
|
||||||
|
Limit(pageOpts.PerPage).
|
||||||
|
Scan(ctx, &logs)
|
||||||
|
if err != nil && err != sql.ErrNoRows {
|
||||||
|
return nil, errors.Wrap(err, "query.Scan")
|
||||||
|
}
|
||||||
|
|
||||||
|
list := &AuditLogs{
|
||||||
|
AuditLogs: *logs,
|
||||||
|
Total: total,
|
||||||
|
PageOpts: *pageOpts,
|
||||||
|
}
|
||||||
|
|
||||||
|
return list, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetAuditLogsByUser retrieves audit logs for a specific user
|
||||||
|
func GetAuditLogsByUser(ctx context.Context, tx bun.Tx, userID int, pageOpts *PageOpts) (*AuditLogs, error) {
|
||||||
|
if userID <= 0 {
|
||||||
|
return nil, errors.New("userID must be positive")
|
||||||
|
}
|
||||||
|
|
||||||
|
filters := &AuditLogFilters{
|
||||||
|
UserID: &userID,
|
||||||
|
}
|
||||||
|
|
||||||
|
return GetAuditLogs(ctx, tx, pageOpts, filters)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetAuditLogsByAction retrieves audit logs for a specific action
|
||||||
|
func GetAuditLogsByAction(ctx context.Context, tx bun.Tx, action string, pageOpts *PageOpts) (*AuditLogs, error) {
|
||||||
|
if action == "" {
|
||||||
|
return nil, errors.New("action cannot be empty")
|
||||||
|
}
|
||||||
|
|
||||||
|
filters := &AuditLogFilters{
|
||||||
|
Action: &action,
|
||||||
|
}
|
||||||
|
|
||||||
|
return GetAuditLogs(ctx, tx, pageOpts, filters)
|
||||||
|
}
|
||||||
|
|
||||||
|
// CleanupOldAuditLogs deletes audit logs older than the specified timestamp
|
||||||
|
func CleanupOldAuditLogs(ctx context.Context, tx bun.Tx, olderThan int64) (int, error) {
|
||||||
|
result, err := tx.NewDelete().
|
||||||
|
Model((*AuditLog)(nil)).
|
||||||
|
Where("created_at < ?", olderThan).
|
||||||
|
Exec(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return 0, errors.Wrap(err, "tx.NewDelete")
|
||||||
|
}
|
||||||
|
|
||||||
|
rowsAffected, err := result.RowsAffected()
|
||||||
|
if err != nil {
|
||||||
|
return 0, errors.Wrap(err, "result.RowsAffected")
|
||||||
|
}
|
||||||
|
|
||||||
|
return int(rowsAffected), nil
|
||||||
|
}
|
||||||
@@ -22,14 +22,14 @@ type DiscordToken struct {
|
|||||||
|
|
||||||
// UpdateDiscordToken adds the provided discord token to the database.
|
// UpdateDiscordToken adds the provided discord token to the database.
|
||||||
// If the user already has a token stored, it will replace that token instead.
|
// If the user already has a token stored, it will replace that token instead.
|
||||||
func (user *User) UpdateDiscordToken(ctx context.Context, tx bun.Tx, token *discord.Token) error {
|
func (u *User) UpdateDiscordToken(ctx context.Context, tx bun.Tx, token *discord.Token) error {
|
||||||
if token == nil {
|
if token == nil {
|
||||||
return errors.New("token cannot be nil")
|
return errors.New("token cannot be nil")
|
||||||
}
|
}
|
||||||
expiresAt := time.Now().Add(time.Duration(token.ExpiresIn) * time.Second).Unix()
|
expiresAt := time.Now().Add(time.Duration(token.ExpiresIn) * time.Second).Unix()
|
||||||
|
|
||||||
discordToken := &DiscordToken{
|
discordToken := &DiscordToken{
|
||||||
DiscordID: user.DiscordID,
|
DiscordID: u.DiscordID,
|
||||||
AccessToken: token.AccessToken,
|
AccessToken: token.AccessToken,
|
||||||
RefreshToken: token.RefreshToken,
|
RefreshToken: token.RefreshToken,
|
||||||
ExpiresAt: expiresAt,
|
ExpiresAt: expiresAt,
|
||||||
@@ -44,7 +44,6 @@ func (user *User) UpdateDiscordToken(ctx context.Context, tx bun.Tx, token *disc
|
|||||||
Set("refresh_token = EXCLUDED.refresh_token").
|
Set("refresh_token = EXCLUDED.refresh_token").
|
||||||
Set("expires_at = EXCLUDED.expires_at").
|
Set("expires_at = EXCLUDED.expires_at").
|
||||||
Exec(ctx)
|
Exec(ctx)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return errors.Wrap(err, "tx.NewInsert")
|
return errors.Wrap(err, "tx.NewInsert")
|
||||||
}
|
}
|
||||||
@@ -53,14 +52,14 @@ func (user *User) UpdateDiscordToken(ctx context.Context, tx bun.Tx, token *disc
|
|||||||
|
|
||||||
// DeleteDiscordTokens deletes a users discord OAuth tokens from the database.
|
// DeleteDiscordTokens deletes a users discord OAuth tokens from the database.
|
||||||
// It returns the DiscordToken so that it can be revoked via the discord API
|
// It returns the DiscordToken so that it can be revoked via the discord API
|
||||||
func (user *User) DeleteDiscordTokens(ctx context.Context, tx bun.Tx) (*DiscordToken, error) {
|
func (u *User) DeleteDiscordTokens(ctx context.Context, tx bun.Tx) (*DiscordToken, error) {
|
||||||
token, err := user.GetDiscordToken(ctx, tx)
|
token, err := u.GetDiscordToken(ctx, tx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, errors.Wrap(err, "user.GetDiscordToken")
|
return nil, errors.Wrap(err, "user.GetDiscordToken")
|
||||||
}
|
}
|
||||||
_, err = tx.NewDelete().
|
_, err = tx.NewDelete().
|
||||||
Model((*DiscordToken)(nil)).
|
Model((*DiscordToken)(nil)).
|
||||||
Where("discord_id = ?", user.DiscordID).
|
Where("discord_id = ?", u.DiscordID).
|
||||||
Exec(ctx)
|
Exec(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, errors.Wrap(err, "tx.NewDelete")
|
return nil, errors.Wrap(err, "tx.NewDelete")
|
||||||
@@ -69,11 +68,11 @@ func (user *User) DeleteDiscordTokens(ctx context.Context, tx bun.Tx) (*DiscordT
|
|||||||
}
|
}
|
||||||
|
|
||||||
// GetDiscordToken retrieves the users discord token from the database
|
// GetDiscordToken retrieves the users discord token from the database
|
||||||
func (user *User) GetDiscordToken(ctx context.Context, tx bun.Tx) (*DiscordToken, error) {
|
func (u *User) GetDiscordToken(ctx context.Context, tx bun.Tx) (*DiscordToken, error) {
|
||||||
token := new(DiscordToken)
|
token := new(DiscordToken)
|
||||||
err := tx.NewSelect().
|
err := tx.NewSelect().
|
||||||
Model(token).
|
Model(token).
|
||||||
Where("discord_id = ?", user.DiscordID).
|
Where("discord_id = ?", u.DiscordID).
|
||||||
Limit(1).
|
Limit(1).
|
||||||
Scan(ctx)
|
Scan(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -15,6 +15,25 @@ type OrderOpts struct {
|
|||||||
Label string
|
Label string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func setDefaultPageOpts(p *PageOpts, page, perpage int, order bun.Order, orderby string) *PageOpts {
|
||||||
|
if p == nil {
|
||||||
|
p = new(PageOpts)
|
||||||
|
}
|
||||||
|
if p.Page == 0 {
|
||||||
|
p.Page = page
|
||||||
|
}
|
||||||
|
if p.PerPage == 0 {
|
||||||
|
p.PerPage = perpage
|
||||||
|
}
|
||||||
|
if p.Order == "" {
|
||||||
|
p.Order = order
|
||||||
|
}
|
||||||
|
if p.OrderBy == "" {
|
||||||
|
p.OrderBy = orderby
|
||||||
|
}
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
|
||||||
// TotalPages calculates the total number of pages
|
// TotalPages calculates the total number of pages
|
||||||
func (p *PageOpts) TotalPages(total int) int {
|
func (p *PageOpts) TotalPages(total int) int {
|
||||||
if p.PerPage == 0 {
|
if p.PerPage == 0 {
|
||||||
@@ -48,7 +67,7 @@ func (p *PageOpts) GetPageRange(total int, maxButtons int) []int {
|
|||||||
// If total pages is less than max buttons, show all pages
|
// If total pages is less than max buttons, show all pages
|
||||||
if totalPages <= maxButtons {
|
if totalPages <= maxButtons {
|
||||||
pages := make([]int, totalPages)
|
pages := make([]int, totalPages)
|
||||||
for i := 0; i < totalPages; i++ {
|
for i := range totalPages {
|
||||||
pages[i] = i + 1
|
pages[i] = i + 1
|
||||||
}
|
}
|
||||||
return pages
|
return pages
|
||||||
|
|||||||
156
internal/db/permission.go
Normal file
156
internal/db/permission.go
Normal file
@@ -0,0 +1,156 @@
|
|||||||
|
package db
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
|
||||||
|
"git.haelnorr.com/h/oslstats/internal/permissions"
|
||||||
|
"github.com/pkg/errors"
|
||||||
|
"github.com/uptrace/bun"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Permission struct {
|
||||||
|
bun.BaseModel `bun:"table:permissions,alias:p"`
|
||||||
|
|
||||||
|
ID int `bun:"id,pk,autoincrement"`
|
||||||
|
Name permissions.Permission `bun:"name,unique,notnull"`
|
||||||
|
DisplayName string `bun:"display_name,notnull"`
|
||||||
|
Description string `bun:"description"`
|
||||||
|
Resource string `bun:"resource,notnull"`
|
||||||
|
Action string `bun:"action,notnull"`
|
||||||
|
IsSystem bool `bun:"is_system,default:false"`
|
||||||
|
CreatedAt int64 `bun:"created_at,notnull"`
|
||||||
|
|
||||||
|
Roles []Role `bun:"m2m:role_permissions,join:Permission=Role"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetPermissionByName queries the database for a permission matching the given name
|
||||||
|
// Returns nil, nil if no permission is found
|
||||||
|
func GetPermissionByName(ctx context.Context, tx bun.Tx, name permissions.Permission) (*Permission, error) {
|
||||||
|
if name == "" {
|
||||||
|
return nil, errors.New("name cannot be empty")
|
||||||
|
}
|
||||||
|
|
||||||
|
perm := new(Permission)
|
||||||
|
err := tx.NewSelect().
|
||||||
|
Model(perm).
|
||||||
|
Where("name = ?", name).
|
||||||
|
Limit(1).
|
||||||
|
Scan(ctx)
|
||||||
|
if err != nil && err != sql.ErrNoRows {
|
||||||
|
return nil, errors.Wrap(err, "tx.NewSelect")
|
||||||
|
}
|
||||||
|
return perm, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetPermissionByID queries the database for a permission matching the given ID
|
||||||
|
// Returns nil, nil if no permission is found
|
||||||
|
func GetPermissionByID(ctx context.Context, tx bun.Tx, id int) (*Permission, error) {
|
||||||
|
if id <= 0 {
|
||||||
|
return nil, errors.New("id must be positive")
|
||||||
|
}
|
||||||
|
|
||||||
|
perm := new(Permission)
|
||||||
|
err := tx.NewSelect().
|
||||||
|
Model(perm).
|
||||||
|
Where("id = ?", id).
|
||||||
|
Limit(1).
|
||||||
|
Scan(ctx)
|
||||||
|
if err != nil && err != sql.ErrNoRows {
|
||||||
|
return nil, errors.Wrap(err, "tx.NewSelect")
|
||||||
|
}
|
||||||
|
return perm, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetPermissionsByResource queries for all permissions for a given resource
|
||||||
|
func GetPermissionsByResource(ctx context.Context, tx bun.Tx, resource string) ([]*Permission, error) {
|
||||||
|
if resource == "" {
|
||||||
|
return nil, errors.New("resource cannot be empty")
|
||||||
|
}
|
||||||
|
|
||||||
|
var perms []*Permission
|
||||||
|
err := tx.NewSelect().
|
||||||
|
Model(&perms).
|
||||||
|
Where("resource = ?", resource).
|
||||||
|
Order("action ASC").
|
||||||
|
Scan(ctx)
|
||||||
|
if err != nil && err != sql.ErrNoRows {
|
||||||
|
return nil, errors.Wrap(err, "tx.NewSelect")
|
||||||
|
}
|
||||||
|
return perms, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetPermissionsByIDs queries for permissions matching the given IDs
|
||||||
|
func GetPermissionsByIDs(ctx context.Context, tx bun.Tx, ids []int) ([]*Permission, error) {
|
||||||
|
if len(ids) == 0 {
|
||||||
|
return []*Permission{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var perms []*Permission
|
||||||
|
err := tx.NewSelect().
|
||||||
|
Model(&perms).
|
||||||
|
Where("id IN (?)", bun.In(ids)).
|
||||||
|
Scan(ctx)
|
||||||
|
if err != nil && err != sql.ErrNoRows {
|
||||||
|
return nil, errors.Wrap(err, "tx.NewSelect")
|
||||||
|
}
|
||||||
|
return perms, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListAllPermissions returns all permissions
|
||||||
|
func ListAllPermissions(ctx context.Context, tx bun.Tx) ([]*Permission, error) {
|
||||||
|
var perms []*Permission
|
||||||
|
err := tx.NewSelect().
|
||||||
|
Model(&perms).
|
||||||
|
Order("resource ASC", "action ASC").
|
||||||
|
Scan(ctx)
|
||||||
|
if err != nil && err != sql.ErrNoRows {
|
||||||
|
return nil, errors.Wrap(err, "tx.NewSelect")
|
||||||
|
}
|
||||||
|
return perms, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreatePermission creates a new permission
|
||||||
|
func CreatePermission(ctx context.Context, tx bun.Tx, perm *Permission) error {
|
||||||
|
if perm == nil {
|
||||||
|
return errors.New("permission cannot be nil")
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := tx.NewInsert().
|
||||||
|
Model(perm).
|
||||||
|
Exec(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(err, "tx.NewInsert")
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeletePermission deletes a permission (checks IsSystem protection)
|
||||||
|
func DeletePermission(ctx context.Context, tx bun.Tx, id int) error {
|
||||||
|
if id <= 0 {
|
||||||
|
return errors.New("id must be positive")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if permission is system permission
|
||||||
|
perm, err := GetPermissionByID(ctx, tx, id)
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(err, "GetPermissionByID")
|
||||||
|
}
|
||||||
|
if perm == nil {
|
||||||
|
return errors.New("permission not found")
|
||||||
|
}
|
||||||
|
if perm.IsSystem {
|
||||||
|
return errors.New("cannot delete system permission")
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = tx.NewDelete().
|
||||||
|
Model((*Permission)(nil)).
|
||||||
|
Where("id = ?", id).
|
||||||
|
Exec(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(err, "tx.NewDelete")
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
214
internal/db/role.go
Normal file
214
internal/db/role.go
Normal file
@@ -0,0 +1,214 @@
|
|||||||
|
package db
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.haelnorr.com/h/oslstats/internal/roles"
|
||||||
|
"github.com/pkg/errors"
|
||||||
|
"github.com/uptrace/bun"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Role struct {
|
||||||
|
bun.BaseModel `bun:"table:roles,alias:r"`
|
||||||
|
|
||||||
|
ID int `bun:"id,pk,autoincrement"`
|
||||||
|
Name roles.Role `bun:"name,unique,notnull"`
|
||||||
|
DisplayName string `bun:"display_name,notnull"`
|
||||||
|
Description string `bun:"description"`
|
||||||
|
IsSystem bool `bun:"is_system,default:false"`
|
||||||
|
CreatedAt int64 `bun:"created_at,notnull"`
|
||||||
|
UpdatedAt *int64 `bun:"updated_at"`
|
||||||
|
|
||||||
|
// Relations (loaded on demand)
|
||||||
|
Users []User `bun:"m2m:user_roles,join:Role=User"`
|
||||||
|
Permissions []Permission `bun:"m2m:role_permissions,join:Role=Permission"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type RolePermission struct {
|
||||||
|
RoleID int `bun:",pk"`
|
||||||
|
Role *Role `bun:"rel:belongs-to,join:role_id=id"`
|
||||||
|
PermissionID int `bun:",pk"`
|
||||||
|
Permission *Permission `bun:"rel:belongs-to,join:permission_id=id"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetRoleByName queries the database for a role matching the given name
|
||||||
|
// Returns nil, nil if no role is found
|
||||||
|
func GetRoleByName(ctx context.Context, tx bun.Tx, name roles.Role) (*Role, error) {
|
||||||
|
if name == "" {
|
||||||
|
return nil, errors.New("name cannot be empty")
|
||||||
|
}
|
||||||
|
|
||||||
|
role := new(Role)
|
||||||
|
err := tx.NewSelect().
|
||||||
|
Model(role).
|
||||||
|
Where("name = ?", name).
|
||||||
|
Limit(1).
|
||||||
|
Scan(ctx)
|
||||||
|
if err != nil && err != sql.ErrNoRows {
|
||||||
|
return nil, errors.Wrap(err, "tx.NewSelect")
|
||||||
|
}
|
||||||
|
return role, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetRoleByID queries the database for a role matching the given ID
|
||||||
|
// Returns nil, nil if no role is found
|
||||||
|
func GetRoleByID(ctx context.Context, tx bun.Tx, id int) (*Role, error) {
|
||||||
|
if id <= 0 {
|
||||||
|
return nil, errors.New("id must be positive")
|
||||||
|
}
|
||||||
|
|
||||||
|
role := new(Role)
|
||||||
|
err := tx.NewSelect().
|
||||||
|
Model(role).
|
||||||
|
Where("id = ?", id).
|
||||||
|
Limit(1).
|
||||||
|
Scan(ctx)
|
||||||
|
if err != nil && err != sql.ErrNoRows {
|
||||||
|
return nil, errors.Wrap(err, "tx.NewSelect")
|
||||||
|
}
|
||||||
|
return role, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetRoleWithPermissions loads a role and all its permissions
|
||||||
|
func GetRoleWithPermissions(ctx context.Context, tx bun.Tx, id int) (*Role, error) {
|
||||||
|
if id <= 0 {
|
||||||
|
return nil, errors.New("id must be positive")
|
||||||
|
}
|
||||||
|
|
||||||
|
role := new(Role)
|
||||||
|
err := tx.NewSelect().
|
||||||
|
Model(role).
|
||||||
|
Where("id = ?", id).
|
||||||
|
Relation("Permissions").
|
||||||
|
Limit(1).
|
||||||
|
Scan(ctx)
|
||||||
|
if err != nil && err != sql.ErrNoRows {
|
||||||
|
return nil, errors.Wrap(err, "tx.NewSelect")
|
||||||
|
}
|
||||||
|
return role, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListAllRoles returns all roles
|
||||||
|
func ListAllRoles(ctx context.Context, tx bun.Tx) ([]*Role, error) {
|
||||||
|
var roles []*Role
|
||||||
|
err := tx.NewSelect().
|
||||||
|
Model(&roles).
|
||||||
|
Order("name ASC").
|
||||||
|
Scan(ctx)
|
||||||
|
if err != nil && err != sql.ErrNoRows {
|
||||||
|
return nil, errors.Wrap(err, "tx.NewSelect")
|
||||||
|
}
|
||||||
|
return roles, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateRole creates a new role
|
||||||
|
func CreateRole(ctx context.Context, tx bun.Tx, role *Role) error {
|
||||||
|
if role == nil {
|
||||||
|
return errors.New("role cannot be nil")
|
||||||
|
}
|
||||||
|
role.CreatedAt = time.Now().Unix()
|
||||||
|
|
||||||
|
_, err := tx.NewInsert().
|
||||||
|
Model(role).
|
||||||
|
Exec(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(err, "tx.NewInsert")
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateRole updates an existing role
|
||||||
|
func UpdateRole(ctx context.Context, tx bun.Tx, role *Role) error {
|
||||||
|
if role == nil {
|
||||||
|
return errors.New("role cannot be nil")
|
||||||
|
}
|
||||||
|
if role.ID <= 0 {
|
||||||
|
return errors.New("role id must be positive")
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := tx.NewUpdate().
|
||||||
|
Model(role).
|
||||||
|
WherePK().
|
||||||
|
Exec(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(err, "tx.NewUpdate")
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteRole deletes a role (checks IsSystem protection)
|
||||||
|
func DeleteRole(ctx context.Context, tx bun.Tx, id int) error {
|
||||||
|
if id <= 0 {
|
||||||
|
return errors.New("id must be positive")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if role is system role
|
||||||
|
role, err := GetRoleByID(ctx, tx, id)
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(err, "GetRoleByID")
|
||||||
|
}
|
||||||
|
if role == nil {
|
||||||
|
return errors.New("role not found")
|
||||||
|
}
|
||||||
|
if role.IsSystem {
|
||||||
|
return errors.New("cannot delete system role")
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = tx.NewDelete().
|
||||||
|
Model((*Role)(nil)).
|
||||||
|
Where("id = ?", id).
|
||||||
|
Exec(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(err, "tx.NewDelete")
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// AddPermissionToRole grants a permission to a role
|
||||||
|
func AddPermissionToRole(ctx context.Context, tx bun.Tx, roleID, permissionID int) error {
|
||||||
|
if roleID <= 0 {
|
||||||
|
return errors.New("roleID must be positive")
|
||||||
|
}
|
||||||
|
if permissionID <= 0 {
|
||||||
|
return errors.New("permissionID must be positive")
|
||||||
|
}
|
||||||
|
rolePerm := &RolePermission{
|
||||||
|
RoleID: roleID,
|
||||||
|
PermissionID: permissionID,
|
||||||
|
}
|
||||||
|
_, err := tx.NewInsert().
|
||||||
|
Model(rolePerm).
|
||||||
|
On("CONFLICT (role_id, permission_id) DO NOTHING").
|
||||||
|
Exec(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(err, "tx.NewInsert")
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RemovePermissionFromRole revokes a permission from a role
|
||||||
|
func RemovePermissionFromRole(ctx context.Context, tx bun.Tx, roleID, permissionID int) error {
|
||||||
|
if roleID <= 0 {
|
||||||
|
return errors.New("roleID must be positive")
|
||||||
|
}
|
||||||
|
if permissionID <= 0 {
|
||||||
|
return errors.New("permissionID must be positive")
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := tx.NewDelete().
|
||||||
|
Model((*RolePermission)(nil)).
|
||||||
|
Where("role_id = ?", roleID).
|
||||||
|
Where("permission_id = ?", permissionID).
|
||||||
|
Exec(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(err, "tx.NewDelete")
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -23,7 +23,7 @@ type Season struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type SeasonList struct {
|
type SeasonList struct {
|
||||||
Seasons []Season
|
Seasons []*Season
|
||||||
Total int
|
Total int
|
||||||
PageOpts PageOpts
|
PageOpts PageOpts
|
||||||
}
|
}
|
||||||
@@ -50,24 +50,10 @@ func NewSeason(ctx context.Context, tx bun.Tx, name, shortname string, start tim
|
|||||||
}
|
}
|
||||||
|
|
||||||
func ListSeasons(ctx context.Context, tx bun.Tx, pageOpts *PageOpts) (*SeasonList, error) {
|
func ListSeasons(ctx context.Context, tx bun.Tx, pageOpts *PageOpts) (*SeasonList, error) {
|
||||||
if pageOpts == nil {
|
pageOpts = setDefaultPageOpts(pageOpts, 1, 10, bun.OrderDesc, "start_date")
|
||||||
pageOpts = &PageOpts{}
|
seasons := new([]*Season)
|
||||||
}
|
|
||||||
if pageOpts.Page == 0 {
|
|
||||||
pageOpts.Page = 1
|
|
||||||
}
|
|
||||||
if pageOpts.PerPage == 0 {
|
|
||||||
pageOpts.PerPage = 10
|
|
||||||
}
|
|
||||||
if pageOpts.Order == "" {
|
|
||||||
pageOpts.Order = bun.OrderDesc
|
|
||||||
}
|
|
||||||
if pageOpts.OrderBy == "" {
|
|
||||||
pageOpts.OrderBy = "name"
|
|
||||||
}
|
|
||||||
seasons := []Season{}
|
|
||||||
err := tx.NewSelect().
|
err := tx.NewSelect().
|
||||||
Model(&seasons).
|
Model(seasons).
|
||||||
OrderBy(pageOpts.OrderBy, pageOpts.Order).
|
OrderBy(pageOpts.OrderBy, pageOpts.Order).
|
||||||
Offset(pageOpts.PerPage * (pageOpts.Page - 1)).
|
Offset(pageOpts.PerPage * (pageOpts.Page - 1)).
|
||||||
Limit(pageOpts.PerPage).
|
Limit(pageOpts.PerPage).
|
||||||
@@ -76,13 +62,13 @@ func ListSeasons(ctx context.Context, tx bun.Tx, pageOpts *PageOpts) (*SeasonLis
|
|||||||
return nil, errors.Wrap(err, "tx.NewSelect")
|
return nil, errors.Wrap(err, "tx.NewSelect")
|
||||||
}
|
}
|
||||||
total, err := tx.NewSelect().
|
total, err := tx.NewSelect().
|
||||||
Model(&seasons).
|
Model(seasons).
|
||||||
Count(ctx)
|
Count(ctx)
|
||||||
if err != nil && err != sql.ErrNoRows {
|
if err != nil {
|
||||||
return nil, errors.Wrap(err, "tx.NewSelect")
|
return nil, errors.Wrap(err, "tx.NewSelect")
|
||||||
}
|
}
|
||||||
sl := &SeasonList{
|
sl := &SeasonList{
|
||||||
Seasons: seasons,
|
Seasons: *seasons,
|
||||||
Total: total,
|
Total: total,
|
||||||
PageOpts: *pageOpts,
|
PageOpts: *pageOpts,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,10 +2,13 @@ package db
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"database/sql"
|
||||||
"fmt"
|
"fmt"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.haelnorr.com/h/golib/hwsauth"
|
"git.haelnorr.com/h/golib/hwsauth"
|
||||||
|
"git.haelnorr.com/h/oslstats/internal/permissions"
|
||||||
|
"git.haelnorr.com/h/oslstats/internal/roles"
|
||||||
"github.com/bwmarrin/discordgo"
|
"github.com/bwmarrin/discordgo"
|
||||||
"github.com/pkg/errors"
|
"github.com/pkg/errors"
|
||||||
"github.com/uptrace/bun"
|
"github.com/uptrace/bun"
|
||||||
@@ -20,10 +23,18 @@ type User struct {
|
|||||||
Username string `bun:"username,unique"` // Username (unique)
|
Username string `bun:"username,unique"` // Username (unique)
|
||||||
CreatedAt int64 `bun:"created_at"` // Epoch timestamp when the user was added to the database
|
CreatedAt int64 `bun:"created_at"` // Epoch timestamp when the user was added to the database
|
||||||
DiscordID string `bun:"discord_id,unique"`
|
DiscordID string `bun:"discord_id,unique"`
|
||||||
|
|
||||||
|
Roles []*Role `bun:"m2m:user_roles,join:User=Role"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (user *User) GetID() int {
|
type Users struct {
|
||||||
return user.ID
|
Users []*User
|
||||||
|
Total int
|
||||||
|
PageOpts PageOpts
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u *User) GetID() int {
|
||||||
|
return u.ID
|
||||||
}
|
}
|
||||||
|
|
||||||
// CreateUser creates a new user with the given username and password
|
// CreateUser creates a new user with the given username and password
|
||||||
@@ -114,3 +125,103 @@ func IsUsernameUnique(ctx context.Context, tx bun.Tx, username string) (bool, er
|
|||||||
}
|
}
|
||||||
return count == 0, nil
|
return count == 0, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetRoles loads all the roles for this user
|
||||||
|
func (u *User) GetRoles(ctx context.Context, tx bun.Tx) ([]*Role, error) {
|
||||||
|
if u == nil {
|
||||||
|
return nil, errors.New("user cannot be nil")
|
||||||
|
}
|
||||||
|
|
||||||
|
err := tx.NewSelect().
|
||||||
|
Model(u).
|
||||||
|
Relation("Roles").
|
||||||
|
Where("id = ?", u.ID).
|
||||||
|
Scan(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.Wrap(err, "tx.NewSelect")
|
||||||
|
}
|
||||||
|
return u.Roles, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetPermissions loads and returns all permissions for this user
|
||||||
|
func (u *User) GetPermissions(ctx context.Context, tx bun.Tx) ([]*Permission, error) {
|
||||||
|
if u == nil {
|
||||||
|
return nil, errors.New("user cannot be nil")
|
||||||
|
}
|
||||||
|
|
||||||
|
var permissions []*Permission
|
||||||
|
err := tx.NewSelect().
|
||||||
|
Model(&permissions).
|
||||||
|
Join("JOIN role_permissions AS rp on rp.permission_id = p.id").
|
||||||
|
Join("JOIN user_roles AS ur ON ur.role_id = rp.role_id").
|
||||||
|
Where("ur.user_id = ?", u.ID).
|
||||||
|
Scan(ctx)
|
||||||
|
if err != nil && err != sql.ErrNoRows {
|
||||||
|
return nil, errors.Wrap(err, "tx.NewSelect")
|
||||||
|
}
|
||||||
|
return permissions, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// HasPermission checks if user has a specific permission (including wildcard check)
|
||||||
|
func (u *User) HasPermission(ctx context.Context, tx bun.Tx, permissionName permissions.Permission) (bool, error) {
|
||||||
|
if u == nil {
|
||||||
|
return false, errors.New("user cannot be nil")
|
||||||
|
}
|
||||||
|
if permissionName == "" {
|
||||||
|
return false, errors.New("permissionName cannot be empty")
|
||||||
|
}
|
||||||
|
|
||||||
|
perms, err := u.GetPermissions(ctx, tx)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, p := range perms {
|
||||||
|
if p.Name == permissionName || p.Name == permissions.Wildcard {
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// HasRole checks if user has a specific role
|
||||||
|
func (u *User) HasRole(ctx context.Context, tx bun.Tx, roleName roles.Role) (bool, error) {
|
||||||
|
if u == nil {
|
||||||
|
return false, errors.New("user cannot be nil")
|
||||||
|
}
|
||||||
|
return HasRole(ctx, tx, u.ID, roleName)
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsAdmin is a convenience method to check if user has admin role
|
||||||
|
func (u *User) IsAdmin(ctx context.Context, tx bun.Tx) (bool, error) {
|
||||||
|
if u == nil {
|
||||||
|
return false, errors.New("user cannot be nil")
|
||||||
|
}
|
||||||
|
return u.HasRole(ctx, tx, "admin")
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetUsers(ctx context.Context, tx bun.Tx, pageOpts *PageOpts) (*Users, error) {
|
||||||
|
pageOpts = setDefaultPageOpts(pageOpts, 1, 50, bun.OrderAsc, "id")
|
||||||
|
users := new([]*User)
|
||||||
|
err := tx.NewSelect().
|
||||||
|
Model(users).
|
||||||
|
OrderBy(pageOpts.OrderBy, pageOpts.Order).
|
||||||
|
Limit(pageOpts.PerPage).
|
||||||
|
Offset(pageOpts.PerPage * (pageOpts.Page - 1)).
|
||||||
|
Scan(ctx)
|
||||||
|
if err != nil && err != sql.ErrNoRows {
|
||||||
|
return nil, errors.Wrap(err, "tx.NewSelect")
|
||||||
|
}
|
||||||
|
total, err := tx.NewSelect().
|
||||||
|
Model(users).
|
||||||
|
Count(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.Wrap(err, "tx.NewSelect")
|
||||||
|
}
|
||||||
|
list := &Users{
|
||||||
|
Users: *users,
|
||||||
|
Total: total,
|
||||||
|
PageOpts: *pageOpts,
|
||||||
|
}
|
||||||
|
return list, nil
|
||||||
|
}
|
||||||
|
|||||||
86
internal/db/userrole.go
Normal file
86
internal/db/userrole.go
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
package db
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"git.haelnorr.com/h/oslstats/internal/roles"
|
||||||
|
"github.com/pkg/errors"
|
||||||
|
"github.com/uptrace/bun"
|
||||||
|
)
|
||||||
|
|
||||||
|
type UserRole struct {
|
||||||
|
UserID int `bun:",pk"`
|
||||||
|
User *User `bun:"rel:belongs-to,join:user_id=id"`
|
||||||
|
RoleID int `bun:",pk"`
|
||||||
|
Role *Role `bun:"rel:belongs-to,join:role_id=id"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// AssignRole grants a role to a user
|
||||||
|
func AssignRole(ctx context.Context, tx bun.Tx, userID, roleID int) error {
|
||||||
|
if userID <= 0 {
|
||||||
|
return errors.New("userID must be positive")
|
||||||
|
}
|
||||||
|
if roleID <= 0 {
|
||||||
|
return errors.New("roleID must be positive")
|
||||||
|
}
|
||||||
|
|
||||||
|
userRole := &UserRole{
|
||||||
|
UserID: userID,
|
||||||
|
RoleID: roleID,
|
||||||
|
}
|
||||||
|
_, err := tx.NewInsert().
|
||||||
|
Model(userRole).
|
||||||
|
On("CONFLICT (user_id, role_id) DO NOTHING").
|
||||||
|
Exec(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(err, "tx.NewInsert")
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RevokeRole removes a role from a user
|
||||||
|
func RevokeRole(ctx context.Context, tx bun.Tx, userID, roleID int) error {
|
||||||
|
if userID <= 0 {
|
||||||
|
return errors.New("userID must be positive")
|
||||||
|
}
|
||||||
|
if roleID <= 0 {
|
||||||
|
return errors.New("roleID must be positive")
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := tx.NewDelete().
|
||||||
|
Model((*UserRole)(nil)).
|
||||||
|
Where("user_id = ?", userID).
|
||||||
|
Where("role_id = ?", roleID).
|
||||||
|
Exec(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(err, "tx.NewDelete")
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// HasRole checks if a user has a specific role
|
||||||
|
func HasRole(ctx context.Context, tx bun.Tx, userID int, roleName roles.Role) (bool, error) {
|
||||||
|
if userID <= 0 {
|
||||||
|
return false, errors.New("userID must be positive")
|
||||||
|
}
|
||||||
|
if roleName == "" {
|
||||||
|
return false, errors.New("roleName cannot be empty")
|
||||||
|
}
|
||||||
|
user := new(User)
|
||||||
|
err := tx.NewSelect().
|
||||||
|
Model(user).
|
||||||
|
Relation("Roles").
|
||||||
|
Where("u.id = ? ", userID).
|
||||||
|
Scan(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return false, errors.Wrap(err, "tx.NewSelect")
|
||||||
|
}
|
||||||
|
for _, role := range user.Roles {
|
||||||
|
if role.Name == roleName {
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
// Package embedfs creates an embedded filesystem with the static web assets
|
||||||
package embedfs
|
package embedfs
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -7,12 +8,12 @@ import (
|
|||||||
"github.com/pkg/errors"
|
"github.com/pkg/errors"
|
||||||
)
|
)
|
||||||
|
|
||||||
//go:embed files/*
|
//go:embed web/*
|
||||||
var embeddedFiles embed.FS
|
var embeddedFiles embed.FS
|
||||||
|
|
||||||
// Gets the embedded files
|
// GetEmbeddedFS gets the embedded files
|
||||||
func GetEmbeddedFS() (fs.FS, error) {
|
func GetEmbeddedFS() (fs.FS, error) {
|
||||||
subFS, err := fs.Sub(embeddedFiles, "files")
|
subFS, err := fs.Sub(embeddedFiles, "web")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, errors.Wrap(err, "fs.Sub")
|
return nil, errors.Wrap(err, "fs.Sub")
|
||||||
}
|
}
|
||||||
|
Before Width: | Height: | Size: 21 KiB After Width: | Height: | Size: 21 KiB |
BIN
internal/embedfs/web/assets/favicon.ico
Normal file
BIN
internal/embedfs/web/assets/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 140 KiB |
BIN
internal/embedfs/web/assets/logo.png
Normal file
BIN
internal/embedfs/web/assets/logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 65 KiB |
@@ -318,6 +318,9 @@
|
|||||||
.mb-2 {
|
.mb-2 {
|
||||||
margin-bottom: calc(var(--spacing) * 2);
|
margin-bottom: calc(var(--spacing) * 2);
|
||||||
}
|
}
|
||||||
|
.mb-3 {
|
||||||
|
margin-bottom: calc(var(--spacing) * 3);
|
||||||
|
}
|
||||||
.mb-4 {
|
.mb-4 {
|
||||||
margin-bottom: calc(var(--spacing) * 4);
|
margin-bottom: calc(var(--spacing) * 4);
|
||||||
}
|
}
|
||||||
@@ -439,9 +442,6 @@
|
|||||||
.flex-1 {
|
.flex-1 {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
}
|
}
|
||||||
.flex-shrink-0 {
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
.shrink-0 {
|
.shrink-0 {
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
36
internal/handlers/admin_dashboard.go
Normal file
36
internal/handlers/admin_dashboard.go
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.haelnorr.com/h/golib/hws"
|
||||||
|
"git.haelnorr.com/h/oslstats/internal/db"
|
||||||
|
"git.haelnorr.com/h/oslstats/internal/view/page"
|
||||||
|
"github.com/pkg/errors"
|
||||||
|
"github.com/uptrace/bun"
|
||||||
|
)
|
||||||
|
|
||||||
|
func AdminDashboard(s *hws.Server, conn *bun.DB) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
tx, err := conn.BeginTx(ctx, nil)
|
||||||
|
if err != nil {
|
||||||
|
throwInternalServiceError(s, w, r, "Database error", errors.Wrap(err, "conn.BeginTx"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer func() { _ = tx.Rollback() }()
|
||||||
|
|
||||||
|
users, err := db.GetUsers(ctx, tx, nil)
|
||||||
|
if err != nil {
|
||||||
|
throwInternalServiceError(s, w, r, "Database error", errors.Wrap(err, "db.GetUsers"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_ = tx.Commit()
|
||||||
|
|
||||||
|
renderSafely(page.AdminDashboard(users), s, r, w)
|
||||||
|
})
|
||||||
|
}
|
||||||
44
internal/handlers/admin_users.go
Normal file
44
internal/handlers/admin_users.go
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.haelnorr.com/h/golib/hws"
|
||||||
|
"git.haelnorr.com/h/oslstats/internal/db"
|
||||||
|
"git.haelnorr.com/h/oslstats/internal/view/component/admin"
|
||||||
|
"github.com/pkg/errors"
|
||||||
|
"github.com/uptrace/bun"
|
||||||
|
)
|
||||||
|
|
||||||
|
// AdminUsersList shows all users
|
||||||
|
func AdminUsersList(s *hws.Server, conn *bun.DB) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
tx, err := conn.BeginTx(ctx, nil)
|
||||||
|
if err != nil {
|
||||||
|
throwInternalServiceError(s, w, r, "DB Transaction failed", errors.Wrap(err, "conn.BeginTx"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer func() { _ = tx.Rollback() }()
|
||||||
|
|
||||||
|
// Get all users
|
||||||
|
pageOpts, err := pageOptsFromForm(r)
|
||||||
|
if err != nil {
|
||||||
|
throwBadRequest(s, w, r, "invalid form data", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
users, err := db.GetUsers(ctx, tx, pageOpts)
|
||||||
|
if err != nil {
|
||||||
|
throwInternalServiceError(s, w, r, "Failed to load users", errors.Wrap(err, "db.GetUsers"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
_ = tx.Commit()
|
||||||
|
|
||||||
|
renderSafely(admin.UserList(users), s, r, w)
|
||||||
|
})
|
||||||
|
}
|
||||||
56
internal/handlers/auth_helpers.go
Normal file
56
internal/handlers/auth_helpers.go
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"git.haelnorr.com/h/oslstats/internal/db"
|
||||||
|
"git.haelnorr.com/h/oslstats/internal/rbac"
|
||||||
|
"git.haelnorr.com/h/oslstats/internal/roles"
|
||||||
|
"github.com/pkg/errors"
|
||||||
|
"github.com/uptrace/bun"
|
||||||
|
)
|
||||||
|
|
||||||
|
// shouldGrantAdmin checks if user's Discord ID is in admin list
|
||||||
|
func shouldGrantAdmin(user *db.User, cfg *rbac.Config) bool {
|
||||||
|
if cfg == nil || user == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if user.DiscordID == cfg.AdminDiscordID {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// ensureUserHasAdminRole grants admin role if not already granted
|
||||||
|
func ensureUserHasAdminRole(ctx context.Context, tx bun.Tx, user *db.User) error {
|
||||||
|
if user == nil {
|
||||||
|
return errors.New("user cannot be nil")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if user already has admin role
|
||||||
|
hasAdmin, err := user.HasRole(ctx, tx, roles.Admin)
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(err, "user.HasRole")
|
||||||
|
}
|
||||||
|
|
||||||
|
if hasAdmin {
|
||||||
|
return nil // Already admin
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get admin role
|
||||||
|
adminRole, err := db.GetRoleByName(ctx, tx, roles.Admin)
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(err, "db.GetRoleByName")
|
||||||
|
}
|
||||||
|
if adminRole == nil {
|
||||||
|
return errors.New("admin role not found in database")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Grant admin role
|
||||||
|
err = db.AssignRole(ctx, tx, user.ID, adminRole.ID)
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(err, "db.AssignRole")
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -193,6 +193,15 @@ func login(
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, errors.Wrap(err, "user.UpdateDiscordToken")
|
return nil, errors.Wrap(err, "user.UpdateDiscordToken")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check if user should be granted admin role (environment-based)
|
||||||
|
if shouldGrantAdmin(user, cfg.RBAC) {
|
||||||
|
err := ensureUserHasAdminRole(ctx, tx, user)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.Wrap(err, "ensureUserHasAdminRole")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
err := auth.Login(w, r, user, true)
|
err := auth.Login(w, r, user, true)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, errors.Wrap(err, "auth.Login")
|
return nil, errors.Wrap(err, "auth.Login")
|
||||||
|
|||||||
@@ -20,16 +20,13 @@ func throwError(
|
|||||||
err error,
|
err error,
|
||||||
level hws.ErrorLevel,
|
level hws.ErrorLevel,
|
||||||
) {
|
) {
|
||||||
err = s.ThrowError(w, r, hws.HWSError{
|
s.ThrowError(w, r, hws.HWSError{
|
||||||
StatusCode: statusCode,
|
StatusCode: statusCode,
|
||||||
Message: msg,
|
Message: msg,
|
||||||
Error: err,
|
Error: err,
|
||||||
Level: level,
|
Level: level,
|
||||||
RenderErrorPage: true, // throw* family always renders error pages
|
RenderErrorPage: true, // throw* family always renders error pages
|
||||||
})
|
})
|
||||||
if err != nil {
|
|
||||||
s.ThrowFatal(w, err)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// throwInternalServiceError handles 500 errors (server failures)
|
// throwInternalServiceError handles 500 errors (server failures)
|
||||||
|
|||||||
@@ -180,7 +180,7 @@ func NewSeasonSubmit(
|
|||||||
// Helper function to validate alphanumeric strings
|
// Helper function to validate alphanumeric strings
|
||||||
func isAlphanumeric(s string) bool {
|
func isAlphanumeric(s string) bool {
|
||||||
for _, r := range s {
|
for _, r := range s {
|
||||||
if !((r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9')) {
|
if ((r < 'A') || (r > 'Z')) && ((r < '0') || (r > '9')) {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
68
internal/handlers/page_opt_helpers.go
Normal file
68
internal/handlers/page_opt_helpers.go
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"git.haelnorr.com/h/oslstats/internal/db"
|
||||||
|
"github.com/pkg/errors"
|
||||||
|
"github.com/uptrace/bun"
|
||||||
|
)
|
||||||
|
|
||||||
|
func pageOptsFromForm(r *http.Request) (*db.PageOpts, error) {
|
||||||
|
var pageNum, perPage int
|
||||||
|
var order bun.Order
|
||||||
|
var orderBy string
|
||||||
|
var err error
|
||||||
|
|
||||||
|
if pageStr := r.FormValue("page"); pageStr != "" {
|
||||||
|
pageNum, err = strconv.Atoi(pageStr)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.Wrap(err, "invalid page number")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if perPageStr := r.FormValue("per_page"); perPageStr != "" {
|
||||||
|
perPage, err = strconv.Atoi(perPageStr)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.Wrap(err, "invalid per_page number")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
order = bun.Order(r.FormValue("order"))
|
||||||
|
orderBy = r.FormValue("order_by")
|
||||||
|
|
||||||
|
pageOpts := &db.PageOpts{
|
||||||
|
Page: pageNum,
|
||||||
|
PerPage: perPage,
|
||||||
|
Order: order,
|
||||||
|
OrderBy: orderBy,
|
||||||
|
}
|
||||||
|
return pageOpts, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func pageOptsFromQuery(r *http.Request) (*db.PageOpts, error) {
|
||||||
|
var pageNum, perPage int
|
||||||
|
var order bun.Order
|
||||||
|
var orderBy string
|
||||||
|
var err error
|
||||||
|
if pageStr := r.URL.Query().Get("page"); pageStr != "" {
|
||||||
|
pageNum, err = strconv.Atoi(pageStr)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.Wrap(err, "invalid page number")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if perPageStr := r.URL.Query().Get("per_page"); perPageStr != "" {
|
||||||
|
perPage, err = strconv.Atoi(perPageStr)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.Wrap(err, "invalid per_page number")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
order = bun.Order(r.URL.Query().Get("order"))
|
||||||
|
orderBy = r.URL.Query().Get("order_by")
|
||||||
|
pageOpts := &db.PageOpts{
|
||||||
|
Page: pageNum,
|
||||||
|
PerPage: perPage,
|
||||||
|
Order: order,
|
||||||
|
OrderBy: orderBy,
|
||||||
|
}
|
||||||
|
return pageOpts, nil
|
||||||
|
}
|
||||||
24
internal/handlers/permtest.go
Normal file
24
internal/handlers/permtest.go
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"git.haelnorr.com/h/golib/hws"
|
||||||
|
"git.haelnorr.com/h/oslstats/internal/db"
|
||||||
|
"git.haelnorr.com/h/oslstats/internal/roles"
|
||||||
|
"github.com/uptrace/bun"
|
||||||
|
)
|
||||||
|
|
||||||
|
func PermTester(s *hws.Server, conn *bun.DB) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
user := db.CurrentUser(r.Context())
|
||||||
|
tx, _ := conn.BeginTx(r.Context(), nil)
|
||||||
|
isAdmin, err := user.HasRole(r.Context(), tx, roles.Admin)
|
||||||
|
tx.Rollback()
|
||||||
|
if err != nil {
|
||||||
|
throwInternalServiceError(s, w, r, "Error", err)
|
||||||
|
}
|
||||||
|
_, _ = w.Write([]byte(strconv.FormatBool(isAdmin)))
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -2,9 +2,7 @@ package handlers
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.haelnorr.com/h/golib/hws"
|
"git.haelnorr.com/h/golib/hws"
|
||||||
@@ -27,38 +25,18 @@ func SeasonsPage(
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
defer tx.Rollback()
|
defer tx.Rollback()
|
||||||
var pageNum, perPage int
|
pageOpts, err := pageOptsFromQuery(r)
|
||||||
var order bun.Order
|
|
||||||
var orderBy string
|
|
||||||
if pageStr := r.URL.Query().Get("page"); pageStr != "" {
|
|
||||||
pageNum, err = strconv.Atoi(pageStr)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
throwBadRequest(s, w, r, "Invalid page number", err)
|
throwBadRequest(s, w, r, "invalid query", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
|
||||||
if perPageStr := r.URL.Query().Get("per_page"); perPageStr != "" {
|
|
||||||
perPage, err = strconv.Atoi(perPageStr)
|
|
||||||
if err != nil {
|
|
||||||
throwBadRequest(s, w, r, "Invalid per_page number", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
order = bun.Order(r.URL.Query().Get("order"))
|
|
||||||
orderBy = r.URL.Query().Get("order_by")
|
|
||||||
pageOpts := &db.PageOpts{
|
|
||||||
Page: pageNum,
|
|
||||||
PerPage: perPage,
|
|
||||||
Order: order,
|
|
||||||
OrderBy: orderBy,
|
|
||||||
}
|
|
||||||
seasons, err := db.ListSeasons(ctx, tx, pageOpts)
|
seasons, err := db.ListSeasons(ctx, tx, pageOpts)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
throwInternalServiceError(s, w, r, "Database error", errors.Wrap(err, "db.ListSeasons"))
|
throwInternalServiceError(s, w, r, "Database error", errors.Wrap(err, "db.ListSeasons"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
tx.Commit()
|
tx.Commit()
|
||||||
renderSafely(page.SeasonsList(seasons), s, r, w)
|
renderSafely(page.SeasonsPage(seasons), s, r, w)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -76,36 +54,11 @@ func SeasonsList(
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Extract pagination/sort params from form
|
pageOpts, err := pageOptsFromForm(r)
|
||||||
var pageNum, perPage int
|
|
||||||
var order bun.Order
|
|
||||||
var orderBy string
|
|
||||||
var err error
|
|
||||||
|
|
||||||
if pageStr := r.FormValue("page"); pageStr != "" {
|
|
||||||
pageNum, err = strconv.Atoi(pageStr)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
throwBadRequest(s, w, r, "Invalid page number", err)
|
throwBadRequest(s, w, r, "invalid form data", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
|
||||||
if perPageStr := r.FormValue("per_page"); perPageStr != "" {
|
|
||||||
perPage, err = strconv.Atoi(perPageStr)
|
|
||||||
if err != nil {
|
|
||||||
throwBadRequest(s, w, r, "Invalid per_page number", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
order = bun.Order(r.FormValue("order"))
|
|
||||||
orderBy = r.FormValue("order_by")
|
|
||||||
|
|
||||||
pageOpts := &db.PageOpts{
|
|
||||||
Page: pageNum,
|
|
||||||
PerPage: perPage,
|
|
||||||
Order: order,
|
|
||||||
OrderBy: orderBy,
|
|
||||||
}
|
|
||||||
fmt.Println(pageOpts)
|
|
||||||
|
|
||||||
// Database query
|
// Database query
|
||||||
tx, err := conn.BeginTx(ctx, nil)
|
tx, err := conn.BeginTx(ctx, nil)
|
||||||
|
|||||||
23
internal/permissions/constants.go
Normal file
23
internal/permissions/constants.go
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
// Package permissions provides constants for RBAC
|
||||||
|
package permissions
|
||||||
|
|
||||||
|
type Permission string
|
||||||
|
|
||||||
|
func (p Permission) String() string {
|
||||||
|
return string(p)
|
||||||
|
}
|
||||||
|
|
||||||
|
const (
|
||||||
|
// Wildcard - grants all permissions
|
||||||
|
Wildcard Permission = "*"
|
||||||
|
|
||||||
|
// Seasons permissions
|
||||||
|
SeasonsCreate Permission = "seasons.create"
|
||||||
|
SeasonsUpdate Permission = "seasons.update"
|
||||||
|
SeasonsDelete Permission = "seasons.delete"
|
||||||
|
|
||||||
|
// Users permissions
|
||||||
|
UsersUpdate Permission = "users.update"
|
||||||
|
UsersBan Permission = "users.ban"
|
||||||
|
UsersManageRoles Permission = "users.manage_roles"
|
||||||
|
)
|
||||||
95
internal/rbac/cache_middleware.go
Normal file
95
internal/rbac/cache_middleware.go
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
package rbac
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.haelnorr.com/h/golib/hws"
|
||||||
|
"git.haelnorr.com/h/oslstats/internal/contexts"
|
||||||
|
"git.haelnorr.com/h/oslstats/internal/db"
|
||||||
|
"git.haelnorr.com/h/oslstats/internal/permissions"
|
||||||
|
"git.haelnorr.com/h/oslstats/internal/roles"
|
||||||
|
"github.com/pkg/errors"
|
||||||
|
)
|
||||||
|
|
||||||
|
// LoadPermissionsMiddleware loads user permissions into context after authentication
|
||||||
|
// MUST run AFTER auth.Authenticate() middleware
|
||||||
|
func (c *Checker) LoadPermissionsMiddleware() hws.Middleware {
|
||||||
|
return func(next http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
user := db.CurrentUser(r.Context())
|
||||||
|
|
||||||
|
if user == nil {
|
||||||
|
// No authenticated user - continue without permissions
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start transaction for loading permissions
|
||||||
|
ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
tx, err := c.conn.BeginTx(ctx, nil)
|
||||||
|
if err != nil {
|
||||||
|
// Log but don't block - permission checks will fail gracefully
|
||||||
|
c.s.LogError(hws.HWSError{
|
||||||
|
Message: "Failed to start database transaction",
|
||||||
|
Error: errors.Wrap(err, "c.conn.BeginTx"),
|
||||||
|
Level: hws.ErrorERROR,
|
||||||
|
})
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer func() { _ = tx.Rollback() }()
|
||||||
|
|
||||||
|
// Load user's roles_ and permissions
|
||||||
|
roles_, err := user.GetRoles(ctx, tx)
|
||||||
|
if err != nil {
|
||||||
|
c.s.LogError(hws.HWSError{
|
||||||
|
Message: "Failed to get user roles",
|
||||||
|
Error: errors.Wrap(err, "user.GetRoles"),
|
||||||
|
Level: hws.ErrorERROR,
|
||||||
|
})
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
perms, err := user.GetPermissions(ctx, tx)
|
||||||
|
if err != nil {
|
||||||
|
c.s.LogError(hws.HWSError{
|
||||||
|
Message: "Failed to get user permissions",
|
||||||
|
Error: errors.Wrap(err, "user.GetPermissions"),
|
||||||
|
Level: hws.ErrorERROR,
|
||||||
|
})
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
_ = tx.Commit() // read only transaction
|
||||||
|
|
||||||
|
// Build permission cache
|
||||||
|
cache := &contexts.PermissionCache{
|
||||||
|
Permissions: make(map[permissions.Permission]bool),
|
||||||
|
Roles: make(map[roles.Role]bool),
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for wildcard permission
|
||||||
|
hasWildcard := false
|
||||||
|
for _, perm := range perms {
|
||||||
|
cache.Permissions[perm.Name] = true
|
||||||
|
if perm.Name == permissions.Wildcard {
|
||||||
|
hasWildcard = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
cache.HasWildcard = hasWildcard
|
||||||
|
|
||||||
|
for _, role := range roles_ {
|
||||||
|
cache.Roles[role.Name] = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add cache to context (type-safe)
|
||||||
|
ctx = context.WithValue(ctx, contexts.PermissionCacheKey, cache)
|
||||||
|
next.ServeHTTP(w, r.WithContext(ctx))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
111
internal/rbac/checker.go
Normal file
111
internal/rbac/checker.go
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
package rbac
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"git.haelnorr.com/h/golib/hws"
|
||||||
|
"git.haelnorr.com/h/oslstats/internal/contexts"
|
||||||
|
"git.haelnorr.com/h/oslstats/internal/db"
|
||||||
|
"git.haelnorr.com/h/oslstats/internal/permissions"
|
||||||
|
"git.haelnorr.com/h/oslstats/internal/roles"
|
||||||
|
"github.com/pkg/errors"
|
||||||
|
"github.com/uptrace/bun"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Checker struct {
|
||||||
|
conn *bun.DB
|
||||||
|
s *hws.Server
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewChecker(conn *bun.DB, s *hws.Server) (*Checker, error) {
|
||||||
|
if conn == nil {
|
||||||
|
return nil, errors.New("conn cannot be nil")
|
||||||
|
}
|
||||||
|
if s == nil {
|
||||||
|
return nil, errors.New("server cannot be nil")
|
||||||
|
}
|
||||||
|
return &Checker{conn: conn, s: s}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// UserHasPermission checks if user has a specific permission (uses cache)
|
||||||
|
func (c *Checker) UserHasPermission(ctx context.Context, user *db.User, permission permissions.Permission) (bool, error) {
|
||||||
|
if user == nil {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try cache first
|
||||||
|
cache := contexts.Permissions(ctx)
|
||||||
|
if cache != nil {
|
||||||
|
if cache.HasWildcard {
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
if has, exists := cache.Permissions[permission]; exists {
|
||||||
|
return has, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback to database
|
||||||
|
tx, err := c.conn.BeginTx(ctx, nil)
|
||||||
|
if err != nil {
|
||||||
|
return false, errors.Wrap(err, "conn.BeginTx")
|
||||||
|
}
|
||||||
|
defer func() { _ = tx.Rollback() }()
|
||||||
|
|
||||||
|
has, err := user.HasPermission(ctx, tx, permission)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return has, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// UserHasRole checks if user has a specific role (uses cache)
|
||||||
|
func (c *Checker) UserHasRole(ctx context.Context, user *db.User, role roles.Role) (bool, error) {
|
||||||
|
if user == nil {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
cache := contexts.Permissions(ctx)
|
||||||
|
if cache != nil {
|
||||||
|
if has, exists := cache.Roles[role]; exists {
|
||||||
|
return has, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback to database
|
||||||
|
tx, err := c.conn.BeginTx(ctx, nil)
|
||||||
|
if err != nil {
|
||||||
|
return false, errors.Wrap(err, "conn.BeginTx")
|
||||||
|
}
|
||||||
|
defer func() { _ = tx.Rollback() }()
|
||||||
|
|
||||||
|
return user.HasRole(ctx, tx, role)
|
||||||
|
}
|
||||||
|
|
||||||
|
// UserHasAnyPermission checks if user has ANY of the given permissions
|
||||||
|
func (c *Checker) UserHasAnyPermission(ctx context.Context, user *db.User, permissions ...permissions.Permission) (bool, error) {
|
||||||
|
for _, perm := range permissions {
|
||||||
|
has, err := c.UserHasPermission(ctx, user, perm)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
if has {
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// UserHasAllPermissions checks if user has ALL of the given permissions
|
||||||
|
func (c *Checker) UserHasAllPermissions(ctx context.Context, user *db.User, permissions ...permissions.Permission) (bool, error) {
|
||||||
|
for _, perm := range permissions {
|
||||||
|
has, err := c.UserHasPermission(ctx, user, perm)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
if !has {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
22
internal/rbac/config.go
Normal file
22
internal/rbac/config.go
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
// Package rbac provides Role-Based Access Control functionality
|
||||||
|
package rbac
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
|
||||||
|
"git.haelnorr.com/h/golib/env"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Config struct {
|
||||||
|
AdminDiscordID string // ENV ADMIN_DISCORD_ID: Discord ID to grant admin role on first login (required)
|
||||||
|
}
|
||||||
|
|
||||||
|
func ConfigFromEnv() (any, error) {
|
||||||
|
cfg := &Config{
|
||||||
|
AdminDiscordID: env.String("ADMIN_DISCORD_ID", ""),
|
||||||
|
}
|
||||||
|
if cfg.AdminDiscordID == "" {
|
||||||
|
return nil, errors.New("env var not set: ADMIN_DISCORD_ID")
|
||||||
|
}
|
||||||
|
return cfg, nil
|
||||||
|
}
|
||||||
41
internal/rbac/ezconf.go
Normal file
41
internal/rbac/ezconf.go
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
package rbac
|
||||||
|
|
||||||
|
import (
|
||||||
|
"runtime"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// EZConfIntegration provides integration with ezconf for automatic configuration
|
||||||
|
type EZConfIntegration struct {
|
||||||
|
configFunc func() (any, error)
|
||||||
|
name string
|
||||||
|
}
|
||||||
|
|
||||||
|
// PackagePath returns the path to the config package for source parsing
|
||||||
|
func (e EZConfIntegration) PackagePath() string {
|
||||||
|
_, filename, _, _ := runtime.Caller(0)
|
||||||
|
// Return directory of this file
|
||||||
|
return filename[:len(filename)-len("/ezconf.go")]
|
||||||
|
}
|
||||||
|
|
||||||
|
// ConfigFunc returns the ConfigFromEnv function for ezconf
|
||||||
|
func (e EZConfIntegration) ConfigFunc() func() (any, error) {
|
||||||
|
return func() (any, error) {
|
||||||
|
return e.configFunc()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Name returns the name to use when registering with ezconf
|
||||||
|
func (e EZConfIntegration) Name() string {
|
||||||
|
return strings.ToLower(e.name)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GroupName returns the display name for grouping environment variables
|
||||||
|
func (e EZConfIntegration) GroupName() string {
|
||||||
|
return e.name
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewEZConfIntegration creates a new EZConf integration helper
|
||||||
|
func NewEZConfIntegration() EZConfIntegration {
|
||||||
|
return EZConfIntegration{name: "RBAC", configFunc: ConfigFromEnv}
|
||||||
|
}
|
||||||
101
internal/rbac/protection_middleware.go
Normal file
101
internal/rbac/protection_middleware.go
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
package rbac
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"git.haelnorr.com/h/golib/cookies"
|
||||||
|
"git.haelnorr.com/h/golib/hws"
|
||||||
|
"git.haelnorr.com/h/oslstats/internal/db"
|
||||||
|
"git.haelnorr.com/h/oslstats/internal/permissions"
|
||||||
|
"git.haelnorr.com/h/oslstats/internal/roles"
|
||||||
|
)
|
||||||
|
|
||||||
|
// RequirePermission creates middleware that requires a specific permission
|
||||||
|
func (c *Checker) RequirePermission(server *hws.Server, permission permissions.Permission) func(http.Handler) http.Handler {
|
||||||
|
return func(next http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
user := db.CurrentUser(r.Context())
|
||||||
|
if user == nil {
|
||||||
|
// Not logged in - redirect to login with page_from
|
||||||
|
cookies.SetPageFrom(w, r, r.URL.Path)
|
||||||
|
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
has, err := c.UserHasPermission(r.Context(), user, permission)
|
||||||
|
if err != nil {
|
||||||
|
// Log error and return 500
|
||||||
|
server.ThrowError(w, r, hws.HWSError{
|
||||||
|
StatusCode: http.StatusInternalServerError,
|
||||||
|
Message: "Permission check failed",
|
||||||
|
Error: err,
|
||||||
|
Level: hws.ErrorERROR,
|
||||||
|
RenderErrorPage: true,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if !has {
|
||||||
|
// User lacks permission - return 403
|
||||||
|
server.ThrowError(w, r, hws.HWSError{
|
||||||
|
StatusCode: http.StatusForbidden,
|
||||||
|
Message: "You don't have permission to access this resource",
|
||||||
|
Error: nil,
|
||||||
|
Level: hws.ErrorDEBUG,
|
||||||
|
RenderErrorPage: true,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// RequireRole creates middleware that requires a specific role
|
||||||
|
func (c *Checker) RequireRole(server *hws.Server, role roles.Role) func(http.Handler) http.Handler {
|
||||||
|
return func(next http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
user := db.CurrentUser(r.Context())
|
||||||
|
if user == nil {
|
||||||
|
// Not logged in - redirect to login
|
||||||
|
cookies.SetPageFrom(w, r, r.URL.Path)
|
||||||
|
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
has, err := c.UserHasRole(r.Context(), user, role)
|
||||||
|
if err != nil {
|
||||||
|
// Log error and return 500
|
||||||
|
hwserr := hws.HWSError{
|
||||||
|
StatusCode: http.StatusInternalServerError,
|
||||||
|
Message: "Role check failed",
|
||||||
|
Error: err,
|
||||||
|
Level: hws.ErrorERROR,
|
||||||
|
RenderErrorPage: true,
|
||||||
|
}
|
||||||
|
server.ThrowError(w, r, hwserr)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if !has {
|
||||||
|
// User lacks role - return 403
|
||||||
|
server.ThrowError(w, r, hws.HWSError{
|
||||||
|
StatusCode: http.StatusForbidden,
|
||||||
|
Message: "You don't have the required role to access this resource",
|
||||||
|
Error: nil,
|
||||||
|
Level: hws.ErrorDEBUG,
|
||||||
|
RenderErrorPage: true,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// RequireAdmin is a convenience middleware for admin-only routes
|
||||||
|
func (c *Checker) RequireAdmin(server *hws.Server) func(http.Handler) http.Handler {
|
||||||
|
return c.RequireRole(server, roles.Admin)
|
||||||
|
}
|
||||||
13
internal/roles/constants.go
Normal file
13
internal/roles/constants.go
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
// Package roles provides constants for the RBAC
|
||||||
|
package roles
|
||||||
|
|
||||||
|
type Role string
|
||||||
|
|
||||||
|
func (r Role) String() string {
|
||||||
|
return string(r)
|
||||||
|
}
|
||||||
|
|
||||||
|
const (
|
||||||
|
Admin Role = "admin"
|
||||||
|
User Role = "user"
|
||||||
|
)
|
||||||
6
internal/view/component/admin/user_list.templ
Normal file
6
internal/view/component/admin/user_list.templ
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
package admin
|
||||||
|
|
||||||
|
import "git.haelnorr.com/h/oslstats/internal/db"
|
||||||
|
|
||||||
|
templ UserList(users *db.Users) {
|
||||||
|
}
|
||||||
@@ -1,6 +1,10 @@
|
|||||||
package nav
|
package nav
|
||||||
|
|
||||||
import "git.haelnorr.com/h/oslstats/internal/db"
|
import (
|
||||||
|
"context"
|
||||||
|
"git.haelnorr.com/h/oslstats/internal/contexts"
|
||||||
|
"git.haelnorr.com/h/oslstats/internal/db"
|
||||||
|
)
|
||||||
|
|
||||||
type ProfileItem struct {
|
type ProfileItem struct {
|
||||||
name string // Label to display
|
name string // Label to display
|
||||||
@@ -8,8 +12,8 @@ type ProfileItem struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Return the list of profile links
|
// Return the list of profile links
|
||||||
func getProfileItems() []ProfileItem {
|
func getProfileItems(ctx context.Context) []ProfileItem {
|
||||||
return []ProfileItem{
|
items := []ProfileItem{
|
||||||
{
|
{
|
||||||
name: "Profile",
|
name: "Profile",
|
||||||
href: "/profile",
|
href: "/profile",
|
||||||
@@ -19,12 +23,23 @@ func getProfileItems() []ProfileItem {
|
|||||||
href: "/account",
|
href: "/account",
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Add admin link if user has admin role
|
||||||
|
cache := contexts.Permissions(ctx)
|
||||||
|
if cache != nil && cache.Roles["admin"] {
|
||||||
|
items = append(items, ProfileItem{
|
||||||
|
name: "Admin Panel",
|
||||||
|
href: "/admin",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return items
|
||||||
}
|
}
|
||||||
|
|
||||||
// Returns the right portion of the navbar
|
// Returns the right portion of the navbar
|
||||||
templ navRight() {
|
templ navRight() {
|
||||||
{{ user := db.CurrentUser(ctx) }}
|
{{ user := db.CurrentUser(ctx) }}
|
||||||
{{ items := getProfileItems() }}
|
{{ items := getProfileItems(ctx) }}
|
||||||
<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">
|
||||||
if user != nil {
|
if user != nil {
|
||||||
|
|||||||
71
internal/view/component/season/status.templ
Normal file
71
internal/view/component/season/status.templ
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
package season
|
||||||
|
|
||||||
|
import "git.haelnorr.com/h/oslstats/internal/db"
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
// StatusBadge renders a season status badge
|
||||||
|
// Parameters:
|
||||||
|
// - season: pointer to db.Season
|
||||||
|
// - compact: bool - true for list view (text only, small), false for detail view (icon + text, large)
|
||||||
|
// - useShortLabels: bool - true for "Active/Finals", false for "In Progress/Finals in Progress"
|
||||||
|
templ StatusBadge(season *db.Season, compact bool, useShortLabels bool) {
|
||||||
|
{{
|
||||||
|
now := time.Now()
|
||||||
|
status := ""
|
||||||
|
statusColor := ""
|
||||||
|
statusBg := ""
|
||||||
|
|
||||||
|
// Determine status based on dates
|
||||||
|
if now.Before(season.StartDate) {
|
||||||
|
status = "Upcoming"
|
||||||
|
statusColor = "text-blue"
|
||||||
|
statusBg = "bg-blue/10 border-blue"
|
||||||
|
} else if !season.EndDate.IsZero() && now.After(season.EndDate.Time) {
|
||||||
|
status = "Completed"
|
||||||
|
statusColor = "text-green"
|
||||||
|
statusBg = "bg-green/10 border-green"
|
||||||
|
} else if !season.FinalsStartDate.IsZero() && now.After(season.FinalsStartDate.Time) {
|
||||||
|
if !season.FinalsEndDate.IsZero() && now.After(season.FinalsEndDate.Time) {
|
||||||
|
status = "Completed"
|
||||||
|
statusColor = "text-green"
|
||||||
|
statusBg = "bg-green/10 border-green"
|
||||||
|
} else {
|
||||||
|
if useShortLabels {
|
||||||
|
status = "Finals"
|
||||||
|
} else {
|
||||||
|
status = "Finals in Progress"
|
||||||
|
}
|
||||||
|
statusColor = "text-yellow"
|
||||||
|
statusBg = "bg-yellow/10 border-yellow"
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if useShortLabels {
|
||||||
|
status = "Active"
|
||||||
|
} else {
|
||||||
|
status = "In Progress"
|
||||||
|
}
|
||||||
|
statusColor = "text-green"
|
||||||
|
statusBg = "bg-green/10 border-green"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine size classes
|
||||||
|
var sizeClasses string
|
||||||
|
var textSize string
|
||||||
|
var iconSize string
|
||||||
|
if compact {
|
||||||
|
sizeClasses = "px-2 py-1"
|
||||||
|
textSize = "text-xs"
|
||||||
|
iconSize = "text-sm"
|
||||||
|
} else {
|
||||||
|
sizeClasses = "px-4 py-2"
|
||||||
|
textSize = "text-lg"
|
||||||
|
iconSize = "text-2xl"
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
<div class={ "rounded-lg border-2 inline-flex items-center gap-2 " + sizeClasses + " " + statusBg }>
|
||||||
|
if !compact {
|
||||||
|
<span class={ iconSize + " " + statusColor }>●</span>
|
||||||
|
}
|
||||||
|
<span class={ textSize + " font-semibold " + statusColor }>{ status }</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
8
internal/view/layout/admin_dashboard.templ
Normal file
8
internal/view/layout/admin_dashboard.templ
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
package layout
|
||||||
|
|
||||||
|
templ AdminDashboard() {
|
||||||
|
@Global("Admin")
|
||||||
|
<div>
|
||||||
|
{ children... }
|
||||||
|
</div>
|
||||||
|
}
|
||||||
@@ -3,7 +3,7 @@ package layout
|
|||||||
import "git.haelnorr.com/h/oslstats/internal/view/component/popup"
|
import "git.haelnorr.com/h/oslstats/internal/view/component/popup"
|
||||||
import "git.haelnorr.com/h/oslstats/internal/view/component/nav"
|
import "git.haelnorr.com/h/oslstats/internal/view/component/nav"
|
||||||
import "git.haelnorr.com/h/oslstats/internal/view/component/footer"
|
import "git.haelnorr.com/h/oslstats/internal/view/component/footer"
|
||||||
import "git.haelnorr.com/h/oslstats/pkg/contexts"
|
import "git.haelnorr.com/h/oslstats/internal/contexts"
|
||||||
|
|
||||||
// Global page layout. Includes HTML document settings, header tags
|
// Global page layout. Includes HTML document settings, header tags
|
||||||
// navbar and footer
|
// navbar and footer
|
||||||
@@ -22,7 +22,7 @@ templ Global(title string) {
|
|||||||
<meta charset="UTF-8"/>
|
<meta charset="UTF-8"/>
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1"/>
|
<meta name="viewport" content="width=device-width, initial-scale=1"/>
|
||||||
<title>{ title }</title>
|
<title>{ title }</title>
|
||||||
<link rel="icon" type="image/x-icon" href="/static/favicon.ico"/>
|
<link rel="icon" type="image/x-icon" href="/static/assets/favicon.ico"/>
|
||||||
<link href="/static/css/output.css" rel="stylesheet"/>
|
<link href="/static/css/output.css" rel="stylesheet"/>
|
||||||
<script src="/static/vendored/htmx@2.0.8.min.js"></script>
|
<script src="/static/vendored/htmx@2.0.8.min.js"></script>
|
||||||
<script src="/static/vendored/htmx-ext-ws.min.js"></script>
|
<script src="/static/vendored/htmx-ext-ws.min.js"></script>
|
||||||
|
|||||||
10
internal/view/page/admin_dashboard.templ
Normal file
10
internal/view/page/admin_dashboard.templ
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
package page
|
||||||
|
|
||||||
|
import "git.haelnorr.com/h/oslstats/internal/view/layout"
|
||||||
|
import "git.haelnorr.com/h/oslstats/internal/view/component/admin"
|
||||||
|
import "git.haelnorr.com/h/oslstats/internal/db"
|
||||||
|
|
||||||
|
templ AdminDashboard(users *db.Users) {
|
||||||
|
@layout.AdminDashboard()
|
||||||
|
@admin.UserList(users)
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ package page
|
|||||||
|
|
||||||
import "git.haelnorr.com/h/oslstats/internal/db"
|
import "git.haelnorr.com/h/oslstats/internal/db"
|
||||||
import "git.haelnorr.com/h/oslstats/internal/view/layout"
|
import "git.haelnorr.com/h/oslstats/internal/view/layout"
|
||||||
|
import seasoncomp "git.haelnorr.com/h/oslstats/internal/view/component/season"
|
||||||
import "time"
|
import "time"
|
||||||
import "strconv"
|
import "strconv"
|
||||||
|
|
||||||
@@ -54,14 +55,14 @@ templ SeasonDetails(season *db.Season) {
|
|||||||
<div class="flex justify-between items-center">
|
<div class="flex justify-between items-center">
|
||||||
<span class="text-subtext0">Start Date:</span>
|
<span class="text-subtext0">Start Date:</span>
|
||||||
<span class="text-text font-semibold">
|
<span class="text-text font-semibold">
|
||||||
{ formatDate(season.StartDate) }
|
{ formatDateLong(season.StartDate) }
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex justify-between items-center">
|
<div class="flex justify-between items-center">
|
||||||
<span class="text-subtext0">End Date:</span>
|
<span class="text-subtext0">End Date:</span>
|
||||||
<span class="text-text font-semibold">
|
<span class="text-text font-semibold">
|
||||||
if !season.EndDate.IsZero() {
|
if !season.EndDate.IsZero() {
|
||||||
{ formatDate(season.EndDate.Time) }
|
{ formatDateLong(season.EndDate.Time) }
|
||||||
} else {
|
} else {
|
||||||
<span class="text-subtext1 italic">Not set</span>
|
<span class="text-subtext1 italic">Not set</span>
|
||||||
}
|
}
|
||||||
@@ -90,7 +91,7 @@ templ SeasonDetails(season *db.Season) {
|
|||||||
<span class="text-subtext0">Start Date:</span>
|
<span class="text-subtext0">Start Date:</span>
|
||||||
<span class="text-text font-semibold">
|
<span class="text-text font-semibold">
|
||||||
if !season.FinalsStartDate.IsZero() {
|
if !season.FinalsStartDate.IsZero() {
|
||||||
{ formatDate(season.FinalsStartDate.Time) }
|
{ formatDateLong(season.FinalsStartDate.Time) }
|
||||||
} else {
|
} else {
|
||||||
<span class="text-subtext1 italic">Not set</span>
|
<span class="text-subtext1 italic">Not set</span>
|
||||||
}
|
}
|
||||||
@@ -100,7 +101,7 @@ templ SeasonDetails(season *db.Season) {
|
|||||||
<span class="text-subtext0">End Date:</span>
|
<span class="text-subtext0">End Date:</span>
|
||||||
<span class="text-text font-semibold">
|
<span class="text-text font-semibold">
|
||||||
if !season.FinalsEndDate.IsZero() {
|
if !season.FinalsEndDate.IsZero() {
|
||||||
{ formatDate(season.FinalsEndDate.Time) }
|
{ formatDateLong(season.FinalsEndDate.Time) }
|
||||||
} else {
|
} else {
|
||||||
<span class="text-subtext1 italic">Not set</span>
|
<span class="text-subtext1 italic">Not set</span>
|
||||||
}
|
}
|
||||||
@@ -124,51 +125,14 @@ templ SeasonDetails(season *db.Season) {
|
|||||||
<div class="bg-surface0 border border-surface1 rounded-lg p-6">
|
<div class="bg-surface0 border border-surface1 rounded-lg p-6">
|
||||||
<h2 class="text-2xl font-bold text-text mb-4">Status</h2>
|
<h2 class="text-2xl font-bold text-text mb-4">Status</h2>
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3">
|
||||||
@SeasonStatus(season)
|
@seasoncomp.StatusBadge(season, false, false)
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
templ SeasonStatus(season *db.Season) {
|
func formatDateLong(t time.Time) string {
|
||||||
{{
|
|
||||||
now := time.Now()
|
|
||||||
status := ""
|
|
||||||
statusColor := ""
|
|
||||||
statusBg := ""
|
|
||||||
|
|
||||||
if now.Before(season.StartDate) {
|
|
||||||
status = "Upcoming"
|
|
||||||
statusColor = "text-blue"
|
|
||||||
statusBg = "bg-blue/10 border-blue"
|
|
||||||
} else if !season.EndDate.IsZero() && now.After(season.EndDate.Time) {
|
|
||||||
status = "Completed"
|
|
||||||
statusColor = "text-green"
|
|
||||||
statusBg = "bg-green/10 border-green"
|
|
||||||
} else if !season.FinalsStartDate.IsZero() && now.After(season.FinalsStartDate.Time) {
|
|
||||||
if !season.FinalsEndDate.IsZero() && now.After(season.FinalsEndDate.Time) {
|
|
||||||
status = "Completed"
|
|
||||||
statusColor = "text-green"
|
|
||||||
statusBg = "bg-green/10 border-green"
|
|
||||||
} else {
|
|
||||||
status = "Finals in Progress"
|
|
||||||
statusColor = "text-yellow"
|
|
||||||
statusBg = "bg-yellow/10 border-yellow"
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
status = "In Progress"
|
|
||||||
statusColor = "text-green"
|
|
||||||
statusBg = "bg-green/10 border-green"
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
<div class={ "px-4 py-2 rounded-lg border-2 inline-flex items-center gap-2 " + statusBg }>
|
|
||||||
<span class={ "text-2xl " + statusColor }>●</span>
|
|
||||||
<span class={ "text-lg font-semibold " + statusColor }>{ status }</span>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
|
|
||||||
func formatDate(t time.Time) string {
|
|
||||||
return t.Format("January 2, 2006")
|
return t.Format("January 2, 2006")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,9 @@ import "git.haelnorr.com/h/oslstats/internal/db"
|
|||||||
import "git.haelnorr.com/h/oslstats/internal/view/layout"
|
import "git.haelnorr.com/h/oslstats/internal/view/layout"
|
||||||
import "git.haelnorr.com/h/oslstats/internal/view/component/pagination"
|
import "git.haelnorr.com/h/oslstats/internal/view/component/pagination"
|
||||||
import "git.haelnorr.com/h/oslstats/internal/view/component/sort"
|
import "git.haelnorr.com/h/oslstats/internal/view/component/sort"
|
||||||
|
import "git.haelnorr.com/h/oslstats/internal/view/component/season"
|
||||||
import "fmt"
|
import "fmt"
|
||||||
|
import "time"
|
||||||
import "github.com/uptrace/bun"
|
import "github.com/uptrace/bun"
|
||||||
|
|
||||||
templ SeasonsPage(seasons *db.SeasonList) {
|
templ SeasonsPage(seasons *db.SeasonList) {
|
||||||
@@ -18,6 +20,16 @@ templ SeasonsPage(seasons *db.SeasonList) {
|
|||||||
templ SeasonsList(seasons *db.SeasonList) {
|
templ SeasonsList(seasons *db.SeasonList) {
|
||||||
{{
|
{{
|
||||||
sortOpts := []db.OrderOpts{
|
sortOpts := []db.OrderOpts{
|
||||||
|
{
|
||||||
|
Order: bun.OrderDesc,
|
||||||
|
OrderBy: "start_date",
|
||||||
|
Label: "Start Date (Newest First)",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Order: bun.OrderAsc,
|
||||||
|
OrderBy: "start_date",
|
||||||
|
Label: "Start Date (Oldest First)",
|
||||||
|
},
|
||||||
{
|
{
|
||||||
Order: bun.OrderAsc,
|
Order: bun.OrderAsc,
|
||||||
OrderBy: "name",
|
OrderBy: "name",
|
||||||
@@ -28,16 +40,6 @@ templ SeasonsList(seasons *db.SeasonList) {
|
|||||||
OrderBy: "name",
|
OrderBy: "name",
|
||||||
Label: "Name (Z-A)",
|
Label: "Name (Z-A)",
|
||||||
},
|
},
|
||||||
{
|
|
||||||
Order: bun.OrderAsc,
|
|
||||||
OrderBy: "short_name",
|
|
||||||
Label: "Short Name (A-Z)",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Order: bun.OrderDesc,
|
|
||||||
OrderBy: "short_name",
|
|
||||||
Label: "Short Name (Z-A)",
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
<div id="seasons-list-container">
|
<div id="seasons-list-container">
|
||||||
@@ -74,12 +76,25 @@ templ SeasonsList(seasons *db.SeasonList) {
|
|||||||
} else {
|
} else {
|
||||||
<!-- Card grid -->
|
<!-- Card grid -->
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
for _, season := range seasons.Seasons {
|
for _, s := range seasons.Seasons {
|
||||||
<a
|
<a
|
||||||
class="bg-mantle border border-surface1 rounded-lg p-6 hover:bg-surface0 transition-colors"
|
class="bg-mantle border border-surface1 rounded-lg p-6 hover:bg-surface0 transition-colors"
|
||||||
href={ fmt.Sprintf("/seasons/%s", season.ShortName) }
|
href={ fmt.Sprintf("/seasons/%s", s.ShortName) }
|
||||||
>
|
>
|
||||||
<h3 class="text-xl font-bold text-text mb-2">{ season.Name }</h3>
|
<!-- Header: Name + Status Badge -->
|
||||||
|
<div class="flex justify-between items-start mb-3">
|
||||||
|
<h3 class="text-xl font-bold text-text">{ s.Name }</h3>
|
||||||
|
@season.StatusBadge(s, true, true)
|
||||||
|
</div>
|
||||||
|
<!-- Info Row: Short Name + Start Date -->
|
||||||
|
<div class="flex items-center gap-3 text-sm">
|
||||||
|
<span class="px-2 py-1 bg-surface1 rounded text-subtext0 font-mono">
|
||||||
|
{ s.ShortName }
|
||||||
|
</span>
|
||||||
|
<span class="text-subtext0">
|
||||||
|
Started: { formatDate(s.StartDate) }
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
</a>
|
</a>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
@@ -90,3 +105,7 @@ templ SeasonsList(seasons *db.SeasonList) {
|
|||||||
<script src="/static/js/pagination.js"></script>
|
<script src="/static/js/pagination.js"></script>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func formatDate(t time.Time) string {
|
||||||
|
return t.Format("02/01/2006") // DD/MM/YYYY
|
||||||
|
}
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 834 B |
@@ -1,3 +1,4 @@
|
|||||||
|
// Package oauth provides OAuth utilities for generating and checking secure state tokens
|
||||||
package oauth
|
package oauth
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
|||||||
Reference in New Issue
Block a user