initial commit
This commit is contained in:
52
.air.toml
Normal file
52
.air.toml
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
root = "."
|
||||||
|
testdata_dir = "testdata"
|
||||||
|
tmp_dir = "tmp"
|
||||||
|
|
||||||
|
[build]
|
||||||
|
args_bin = []
|
||||||
|
bin = "./tmp/main"
|
||||||
|
cmd = "go build -o ./tmp/main ./cmd/oslstats"
|
||||||
|
delay = 1000
|
||||||
|
exclude_dir = []
|
||||||
|
exclude_file = []
|
||||||
|
exclude_regex = ["_test.go"]
|
||||||
|
exclude_unchanged = false
|
||||||
|
follow_symlink = false
|
||||||
|
full_bin = ""
|
||||||
|
include_dir = []
|
||||||
|
include_ext = ["go", "templ"]
|
||||||
|
include_file = []
|
||||||
|
kill_delay = "0s"
|
||||||
|
log = "build-errors.log"
|
||||||
|
poll = false
|
||||||
|
poll_interval = 0
|
||||||
|
post_cmd = []
|
||||||
|
pre_cmd = []
|
||||||
|
rerun = false
|
||||||
|
rerun_delay = 500
|
||||||
|
send_interrupt = false
|
||||||
|
stop_on_error = false
|
||||||
|
|
||||||
|
[color]
|
||||||
|
app = ""
|
||||||
|
build = "yellow"
|
||||||
|
main = "magenta"
|
||||||
|
runner = "green"
|
||||||
|
watcher = "cyan"
|
||||||
|
|
||||||
|
[log]
|
||||||
|
main_only = false
|
||||||
|
silent = false
|
||||||
|
time = false
|
||||||
|
|
||||||
|
[misc]
|
||||||
|
clean_on_exit = false
|
||||||
|
|
||||||
|
[proxy]
|
||||||
|
app_port = 3333
|
||||||
|
enabled = true
|
||||||
|
proxy_port = 3000
|
||||||
|
|
||||||
|
[screen]
|
||||||
|
clear_on_rebuild = false
|
||||||
|
keep_scroll = true
|
||||||
9
.gitignore
vendored
Normal file
9
.gitignore
vendored
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
.env
|
||||||
|
*.db*
|
||||||
|
.logs/
|
||||||
|
server.log
|
||||||
|
bin/
|
||||||
|
tmp/
|
||||||
|
static/css/output.css
|
||||||
|
internal/view/**/*_templ.go
|
||||||
|
internal/view/**/*_templ.txt
|
||||||
38
Makefile
Normal file
38
Makefile
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
# Makefile
|
||||||
|
.PHONY: build
|
||||||
|
|
||||||
|
BINARY_NAME=oslstats
|
||||||
|
|
||||||
|
build:
|
||||||
|
./scripts/generate-css-sources.sh && \
|
||||||
|
tailwindcss -i ./pkg/embedfs/files/css/input.css -o ./pkg/embedfs/files/css/output.css && \
|
||||||
|
go mod tidy && \
|
||||||
|
templ generate && \
|
||||||
|
go generate ./cmd/${BINARY_NAME} && \
|
||||||
|
go build -ldflags="-w -s" -o ./bin/${BINARY_NAME}${SUFFIX} ./cmd/${BINARY_NAME}
|
||||||
|
|
||||||
|
run:
|
||||||
|
make build
|
||||||
|
./bin/${BINARY_NAME}${SUFFIX}
|
||||||
|
|
||||||
|
dev:
|
||||||
|
./scripts/generate-css-sources.sh && \
|
||||||
|
templ generate --watch &\
|
||||||
|
air &\
|
||||||
|
tailwindcss -i ./pkg/embedfs/files/css/input.css -o ./pkg/embedfs/files/css/output.css --watch
|
||||||
|
|
||||||
|
clean:
|
||||||
|
go clean
|
||||||
|
|
||||||
|
genenv:
|
||||||
|
make build
|
||||||
|
./bin/${BINARY_NAME} --genenv ${OUT}
|
||||||
|
|
||||||
|
envdoc:
|
||||||
|
make build
|
||||||
|
./bin/${BINARY_NAME} --envdoc
|
||||||
|
|
||||||
|
showenv:
|
||||||
|
make build
|
||||||
|
./bin/${BINARY_NAME} --showenv
|
||||||
|
|
||||||
44
cmd/oslstats/auth.go
Normal file
44
cmd/oslstats/auth.go
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"git.haelnorr.com/h/golib/hlog"
|
||||||
|
"git.haelnorr.com/h/golib/hws"
|
||||||
|
"git.haelnorr.com/h/golib/hwsauth"
|
||||||
|
"git.haelnorr.com/h/oslstats/internal/db"
|
||||||
|
"git.haelnorr.com/h/oslstats/internal/handlers"
|
||||||
|
"git.haelnorr.com/h/oslstats/pkg/contexts"
|
||||||
|
"github.com/pkg/errors"
|
||||||
|
"github.com/uptrace/bun"
|
||||||
|
)
|
||||||
|
|
||||||
|
func setupAuth(
|
||||||
|
config *hwsauth.Config,
|
||||||
|
logger *hlog.Logger,
|
||||||
|
conn *bun.DB,
|
||||||
|
server *hws.Server,
|
||||||
|
ignoredPaths []string,
|
||||||
|
) (*hwsauth.Authenticator[*db.User, bun.Tx], error) {
|
||||||
|
beginTx := func(ctx context.Context) (hwsauth.DBTransaction, error) {
|
||||||
|
tx, err := conn.BeginTx(ctx, nil)
|
||||||
|
return tx, err
|
||||||
|
}
|
||||||
|
auth, err := hwsauth.NewAuthenticator(
|
||||||
|
config,
|
||||||
|
db.GetUserByID,
|
||||||
|
server,
|
||||||
|
beginTx,
|
||||||
|
logger,
|
||||||
|
handlers.ErrorPage,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.Wrap(err, "hwsauth.NewAuthenticator")
|
||||||
|
}
|
||||||
|
|
||||||
|
auth.IgnorePaths(ignoredPaths...)
|
||||||
|
|
||||||
|
contexts.CurrentUser = auth.CurrentModel
|
||||||
|
|
||||||
|
return auth, nil
|
||||||
|
}
|
||||||
53
cmd/oslstats/db.go
Normal file
53
cmd/oslstats/db.go
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"git.haelnorr.com/h/oslstats/internal/config"
|
||||||
|
"git.haelnorr.com/h/oslstats/internal/db"
|
||||||
|
"github.com/pkg/errors"
|
||||||
|
"github.com/uptrace/bun"
|
||||||
|
"github.com/uptrace/bun/dialect/pgdialect"
|
||||||
|
"github.com/uptrace/bun/driver/pgdriver"
|
||||||
|
)
|
||||||
|
|
||||||
|
func setupBun(ctx context.Context, cfg *config.Config) (conn *bun.DB, close func() error, err error) {
|
||||||
|
dsn := fmt.Sprintf("postgres://%s:%s@%s:%v/%s?sslmode=%s",
|
||||||
|
cfg.DB.User, cfg.DB.Password, cfg.DB.Host, cfg.DB.Port, cfg.DB.DB, cfg.DB.SSL)
|
||||||
|
sqldb := sql.OpenDB(pgdriver.NewConnector(pgdriver.WithDSN(dsn)))
|
||||||
|
conn = bun.NewDB(sqldb, pgdialect.New())
|
||||||
|
close = sqldb.Close
|
||||||
|
|
||||||
|
err = loadModels(ctx, conn, cfg.Flags.ResetDB)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, errors.Wrap(err, "loadModels")
|
||||||
|
}
|
||||||
|
|
||||||
|
return conn, close, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadModels(ctx context.Context, conn *bun.DB, resetDB bool) error {
|
||||||
|
models := []any{
|
||||||
|
(*db.User)(nil),
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, model := range models {
|
||||||
|
_, err := conn.NewCreateTable().
|
||||||
|
Model(model).
|
||||||
|
IfNotExists().
|
||||||
|
Exec(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(err, "db.NewCreateTable")
|
||||||
|
}
|
||||||
|
if resetDB {
|
||||||
|
err = conn.ResetModel(ctx, model)
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(err, "db.ResetModel")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
67
cmd/oslstats/httpserver.go
Normal file
67
cmd/oslstats/httpserver.go
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"io/fs"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"git.haelnorr.com/h/golib/hws"
|
||||||
|
"git.haelnorr.com/h/oslstats/internal/config"
|
||||||
|
"git.haelnorr.com/h/oslstats/internal/handlers"
|
||||||
|
|
||||||
|
"git.haelnorr.com/h/golib/hlog"
|
||||||
|
"github.com/pkg/errors"
|
||||||
|
"github.com/uptrace/bun"
|
||||||
|
)
|
||||||
|
|
||||||
|
func setupHttpServer(
|
||||||
|
staticFS *fs.FS,
|
||||||
|
config *config.Config,
|
||||||
|
logger *hlog.Logger,
|
||||||
|
bun *bun.DB,
|
||||||
|
) (server *hws.Server, err error) {
|
||||||
|
if staticFS == nil {
|
||||||
|
return nil, errors.New("No filesystem provided")
|
||||||
|
}
|
||||||
|
fs := http.FS(*staticFS)
|
||||||
|
httpServer, err := hws.NewServer(config.HWS)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.Wrap(err, "hws.NewServer")
|
||||||
|
}
|
||||||
|
|
||||||
|
ignoredPaths := []string{
|
||||||
|
"/static/css/output.css",
|
||||||
|
"/static/favicon.ico",
|
||||||
|
}
|
||||||
|
|
||||||
|
auth, err := setupAuth(config.HWSAuth, logger, bun, httpServer, ignoredPaths)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.Wrap(err, "setupAuth")
|
||||||
|
}
|
||||||
|
|
||||||
|
err = httpServer.AddErrorPage(handlers.ErrorPage)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.Wrap(err, "httpServer.AddErrorPage")
|
||||||
|
}
|
||||||
|
|
||||||
|
err = httpServer.AddLogger(logger)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.Wrap(err, "httpServer.AddLogger")
|
||||||
|
}
|
||||||
|
|
||||||
|
err = httpServer.LoggerIgnorePaths(ignoredPaths...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.Wrap(err, "httpServer.LoggerIgnorePaths")
|
||||||
|
}
|
||||||
|
|
||||||
|
err = addRoutes(httpServer, &fs, config, logger, bun, auth)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.Wrap(err, "addRoutes")
|
||||||
|
}
|
||||||
|
|
||||||
|
err = addMiddleware(httpServer, auth)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.Wrap(err, "httpServer.AddMiddleware")
|
||||||
|
}
|
||||||
|
|
||||||
|
return httpServer, nil
|
||||||
|
}
|
||||||
36
cmd/oslstats/main.go
Normal file
36
cmd/oslstats/main.go
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
|
||||||
|
"git.haelnorr.com/h/oslstats/internal/config"
|
||||||
|
"github.com/pkg/errors"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
flags := config.SetupFlags()
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
cfg, loader, err := config.GetConfig(flags)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "%s\n", errors.Wrap(err, "Failed to load config"))
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
if flags.EnvDoc || flags.ShowEnv {
|
||||||
|
loader.PrintEnvVarsStdout(flags.ShowEnv)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if flags.GenEnv != "" {
|
||||||
|
loader.GenerateEnvFile(flags.GenEnv, true)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := run(ctx, os.Stdout, cfg); err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "%s\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
24
cmd/oslstats/middleware.go
Normal file
24
cmd/oslstats/middleware.go
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"git.haelnorr.com/h/golib/hws"
|
||||||
|
"git.haelnorr.com/h/golib/hwsauth"
|
||||||
|
"git.haelnorr.com/h/oslstats/internal/db"
|
||||||
|
|
||||||
|
"github.com/pkg/errors"
|
||||||
|
"github.com/uptrace/bun"
|
||||||
|
)
|
||||||
|
|
||||||
|
func addMiddleware(
|
||||||
|
server *hws.Server,
|
||||||
|
auth *hwsauth.Authenticator[*db.User, bun.Tx],
|
||||||
|
) error {
|
||||||
|
|
||||||
|
err := server.AddMiddleware(
|
||||||
|
auth.Authenticate(),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(err, "server.AddMiddleware")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
45
cmd/oslstats/routes.go
Normal file
45
cmd/oslstats/routes.go
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"git.haelnorr.com/h/golib/hws"
|
||||||
|
"git.haelnorr.com/h/golib/hwsauth"
|
||||||
|
"git.haelnorr.com/h/oslstats/internal/config"
|
||||||
|
"git.haelnorr.com/h/oslstats/internal/db"
|
||||||
|
"git.haelnorr.com/h/oslstats/internal/handlers"
|
||||||
|
|
||||||
|
"git.haelnorr.com/h/golib/hlog"
|
||||||
|
"github.com/pkg/errors"
|
||||||
|
"github.com/uptrace/bun"
|
||||||
|
)
|
||||||
|
|
||||||
|
func addRoutes(
|
||||||
|
server *hws.Server,
|
||||||
|
staticFS *http.FileSystem,
|
||||||
|
config *config.Config,
|
||||||
|
logger *hlog.Logger,
|
||||||
|
conn *bun.DB,
|
||||||
|
auth *hwsauth.Authenticator[*db.User, bun.Tx],
|
||||||
|
) error {
|
||||||
|
// Create the routes
|
||||||
|
routes := []hws.Route{
|
||||||
|
{
|
||||||
|
Path: "/static/",
|
||||||
|
Method: hws.MethodGET,
|
||||||
|
Handler: http.StripPrefix("/static/", handlers.StaticFS(staticFS, server)),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Path: "/",
|
||||||
|
Method: hws.MethodGET,
|
||||||
|
Handler: handlers.Index(server),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// Register the routes with the server
|
||||||
|
err := server.AddRoutes(routes...)
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(err, "server.AddRoutes")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
72
cmd/oslstats/run.go
Normal file
72
cmd/oslstats/run.go
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"os/signal"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.haelnorr.com/h/golib/hlog"
|
||||||
|
"git.haelnorr.com/h/oslstats/internal/config"
|
||||||
|
"git.haelnorr.com/h/oslstats/pkg/embedfs"
|
||||||
|
"github.com/pkg/errors"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Initializes and runs the server
|
||||||
|
func run(ctx context.Context, w io.Writer, config *config.Config) error {
|
||||||
|
ctx, cancel := signal.NotifyContext(ctx, os.Interrupt)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
// Setup the logger
|
||||||
|
logger, err := hlog.NewLogger(config.HLOG, w)
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(err, "hlog.NewLogger")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Setup the database connection
|
||||||
|
logger.Debug().Msg("Config loaded and logger started")
|
||||||
|
logger.Debug().Msg("Connecting to database")
|
||||||
|
bun, closedb, err := setupBun(ctx, config)
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(err, "setupDBConn")
|
||||||
|
}
|
||||||
|
defer closedb()
|
||||||
|
|
||||||
|
// Setup embedded files
|
||||||
|
logger.Debug().Msg("Getting embedded files")
|
||||||
|
staticFS, err := embedfs.GetEmbeddedFS()
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(err, "getStaticFiles")
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.Debug().Msg("Setting up HTTP server")
|
||||||
|
httpServer, err := setupHttpServer(&staticFS, config, logger, bun)
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(err, "setupHttpServer")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Runs the http server
|
||||||
|
logger.Debug().Msg("Starting up the HTTP server")
|
||||||
|
err = httpServer.Start(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(err, "httpServer.Start")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handles graceful shutdown
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
wg.Go(func() {
|
||||||
|
<-ctx.Done()
|
||||||
|
shutdownCtx := context.Background()
|
||||||
|
shutdownCtx, cancel := context.WithTimeout(shutdownCtx, 10*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
err := httpServer.Shutdown(shutdownCtx)
|
||||||
|
if err != nil {
|
||||||
|
logger.Error().Err(err).Msg("Graceful shutdown failed")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
wg.Wait()
|
||||||
|
logger.Info().Msg("Shutting down")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
41
go.mod
Normal file
41
go.mod
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
module git.haelnorr.com/h/oslstats
|
||||||
|
|
||||||
|
go 1.25.5
|
||||||
|
|
||||||
|
require (
|
||||||
|
git.haelnorr.com/h/golib/env v0.9.1
|
||||||
|
git.haelnorr.com/h/golib/ezconf v0.1.1
|
||||||
|
git.haelnorr.com/h/golib/hlog v0.10.4
|
||||||
|
git.haelnorr.com/h/golib/hws v0.2.3
|
||||||
|
git.haelnorr.com/h/golib/hwsauth v0.3.4
|
||||||
|
github.com/a-h/templ v0.3.977
|
||||||
|
github.com/joho/godotenv v1.5.1
|
||||||
|
github.com/pkg/errors v0.9.1
|
||||||
|
github.com/uptrace/bun v1.2.16
|
||||||
|
github.com/uptrace/bun/dialect/pgdialect v1.2.16
|
||||||
|
github.com/uptrace/bun/driver/pgdriver v1.2.16
|
||||||
|
golang.org/x/crypto v0.45.0
|
||||||
|
)
|
||||||
|
|
||||||
|
require (
|
||||||
|
git.haelnorr.com/h/golib/cookies v0.9.0 // indirect
|
||||||
|
git.haelnorr.com/h/golib/jwt v0.10.0 // indirect
|
||||||
|
github.com/go-logr/logr v1.4.3 // indirect
|
||||||
|
github.com/golang-jwt/jwt v3.2.2+incompatible // indirect
|
||||||
|
github.com/google/uuid v1.6.0 // indirect
|
||||||
|
github.com/jinzhu/inflection v1.0.0 // indirect
|
||||||
|
github.com/mattn/go-colorable v0.1.14 // indirect
|
||||||
|
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||||
|
github.com/puzpuzpuz/xsync/v3 v3.5.1 // indirect
|
||||||
|
github.com/rs/zerolog v1.34.0 // indirect
|
||||||
|
github.com/tmthrgd/go-hex v0.0.0-20190904060850-447a3041c3bc // indirect
|
||||||
|
github.com/vmihailenco/msgpack/v5 v5.4.1 // indirect
|
||||||
|
github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect
|
||||||
|
go.opentelemetry.io/otel v1.38.0 // indirect
|
||||||
|
go.opentelemetry.io/otel/trace v1.38.0 // indirect
|
||||||
|
golang.org/x/sys v0.40.0 // indirect
|
||||||
|
k8s.io/apimachinery v0.35.0 // indirect
|
||||||
|
k8s.io/klog/v2 v2.130.1 // indirect
|
||||||
|
k8s.io/utils v0.0.0-20260108192941-914a6e750570 // indirect
|
||||||
|
mellium.im/sasl v0.3.2 // indirect
|
||||||
|
)
|
||||||
85
go.sum
Normal file
85
go.sum
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
git.haelnorr.com/h/golib/cookies v0.9.0 h1:Vf+eX1prHkKuGrQon1BHY87yaPc1H+HJFRXDOV/AuWs=
|
||||||
|
git.haelnorr.com/h/golib/cookies v0.9.0/go.mod h1:y1385YExI9gLwckCVDCYVcsFXr6N7T3brJjnJD2QIuo=
|
||||||
|
git.haelnorr.com/h/golib/env v0.9.1 h1:2Vsj+mJKnO5f1Md1GO5v9ggLN5zWa0baCewcSHTjoNY=
|
||||||
|
git.haelnorr.com/h/golib/env v0.9.1/go.mod h1:glUQVdA1HMKX1avTDyTyuhcr36SSxZtlJxKDT5KTztg=
|
||||||
|
git.haelnorr.com/h/golib/ezconf v0.1.1 h1:4euTSDb9jvuQQkVq+x5gHoYPYyUZPWxoOSlWCIxTZOs=
|
||||||
|
git.haelnorr.com/h/golib/ezconf v0.1.1/go.mod h1:rETDcjpcEyyeBgCiZSU617wc0XycwZSC5+IAOtXmwP8=
|
||||||
|
git.haelnorr.com/h/golib/hlog v0.10.4 h1:vpCsV/OddjIYx8F48U66WxojjmhEbeLGQAOBG4ViSRQ=
|
||||||
|
git.haelnorr.com/h/golib/hlog v0.10.4/go.mod h1:+wJ8vecQY/JITTXKmI3JfkHiUGyMs7N6wooj2wuWZbc=
|
||||||
|
git.haelnorr.com/h/golib/hws v0.2.3 h1:gZQkBciXKh3jYw05vZncSR2lvIqi0H2MVfIWySySsmw=
|
||||||
|
git.haelnorr.com/h/golib/hws v0.2.3/go.mod h1:6ZlRKnt8YMpv5XcMXmyBGmD1/euvBo3d1azEvHJjOLo=
|
||||||
|
git.haelnorr.com/h/golib/hwsauth v0.3.4 h1:wwYBb6cQQ+x9hxmYuZBF4mVmCv/n4PjJV//e1+SgPOo=
|
||||||
|
git.haelnorr.com/h/golib/hwsauth v0.3.4/go.mod h1:LI7Qz68GPNIW8732Zwptb//ybjiFJOoXf4tgUuUEqHI=
|
||||||
|
git.haelnorr.com/h/golib/jwt v0.10.0 h1:8cI8mSnb8X+EmJtrBO/5UZwuBMtib0IE9dv85gkm94E=
|
||||||
|
git.haelnorr.com/h/golib/jwt v0.10.0/go.mod h1:fbuPrfucT9lL0faV5+Q5Gk9WFJxPlwzRPpbMQKYZok4=
|
||||||
|
github.com/DATA-DOG/go-sqlmock v1.5.2 h1:OcvFkGmslmlZibjAjaHm3L//6LiuBgolP7OputlJIzU=
|
||||||
|
github.com/DATA-DOG/go-sqlmock v1.5.2/go.mod h1:88MAG/4G7SMwSE3CeA0ZKzrT5CiOU3OJ+JlNzwDqpNU=
|
||||||
|
github.com/a-h/templ v0.3.977 h1:kiKAPXTZE2Iaf8JbtM21r54A8bCNsncrfnokZZSrSDg=
|
||||||
|
github.com/a-h/templ v0.3.977/go.mod h1:oCZcnKRf5jjsGpf2yELzQfodLphd2mwecwG4Crk5HBo=
|
||||||
|
github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
|
||||||
|
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
|
||||||
|
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||||
|
github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
|
||||||
|
github.com/golang-jwt/jwt v3.2.2+incompatible h1:IfV12K8xAKAnZqdXVzCZ+TOjboZ2keLg81eXfW3O+oY=
|
||||||
|
github.com/golang-jwt/jwt v3.2.2+incompatible/go.mod h1:8pz2t5EyA70fFQQSrl6XZXzqecmYZeUEB8OUGHkxJ+I=
|
||||||
|
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||||
|
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||||
|
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||||
|
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
|
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
|
||||||
|
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
|
||||||
|
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
|
||||||
|
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
|
||||||
|
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
|
||||||
|
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
|
||||||
|
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
|
||||||
|
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
|
||||||
|
github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||||
|
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||||
|
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||||
|
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||||
|
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
|
github.com/puzpuzpuz/xsync/v3 v3.5.1 h1:GJYJZwO6IdxN/IKbneznS6yPkVC+c3zyY/j19c++5Fg=
|
||||||
|
github.com/puzpuzpuz/xsync/v3 v3.5.1/go.mod h1:VjzYrABPabuM4KyBh1Ftq6u8nhwY5tBPKP9jpmh0nnA=
|
||||||
|
github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0=
|
||||||
|
github.com/rs/zerolog v1.34.0 h1:k43nTLIwcTVQAncfCw4KZ2VY6ukYoZaBPNOE8txlOeY=
|
||||||
|
github.com/rs/zerolog v1.34.0/go.mod h1:bJsvje4Z08ROH4Nhs5iH600c3IkWhwp44iRc54W6wYQ=
|
||||||
|
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||||
|
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||||
|
github.com/tmthrgd/go-hex v0.0.0-20190904060850-447a3041c3bc h1:9lRDQMhESg+zvGYmW5DyG0UqvY96Bu5QYsTLvCHdrgo=
|
||||||
|
github.com/tmthrgd/go-hex v0.0.0-20190904060850-447a3041c3bc/go.mod h1:bciPuU6GHm1iF1pBvUfxfsH0Wmnc2VbpgvbI9ZWuIRs=
|
||||||
|
github.com/uptrace/bun v1.2.16 h1:QlObi6ZIK5Ao7kAALnh91HWYNZUBbVwye52fmlQM9kc=
|
||||||
|
github.com/uptrace/bun v1.2.16/go.mod h1:jMoNg2n56ckaawi/O/J92BHaECmrz6IRjuMWqlMaMTM=
|
||||||
|
github.com/uptrace/bun/dialect/pgdialect v1.2.16 h1:KFNZ0LxAyczKNfK/IJWMyaleO6eI9/Z5tUv3DE1NVL4=
|
||||||
|
github.com/uptrace/bun/dialect/pgdialect v1.2.16/go.mod h1:IJdMeV4sLfh0LDUZl7TIxLI0LipF1vwTK3hBC7p5qLo=
|
||||||
|
github.com/uptrace/bun/driver/pgdriver v1.2.16 h1:b1kpXKUxtTSGYow5Vlsb+dKV3z0R7aSAJNfMfKp61ZU=
|
||||||
|
github.com/uptrace/bun/driver/pgdriver v1.2.16/go.mod h1:H6lUZ9CBfp1X5Vq62YGSV7q96/v94ja9AYFjKvdoTk0=
|
||||||
|
github.com/vmihailenco/msgpack/v5 v5.4.1 h1:cQriyiUvjTwOHg8QZaPihLWeRAAVoCpE00IUPn0Bjt8=
|
||||||
|
github.com/vmihailenco/msgpack/v5 v5.4.1/go.mod h1:GaZTsDaehaPpQVyxrf5mtQlH+pc21PIudVV/E3rRQok=
|
||||||
|
github.com/vmihailenco/tagparser/v2 v2.0.0 h1:y09buUbR+b5aycVFQs/g70pqKVZNBmxwAhO7/IwNM9g=
|
||||||
|
github.com/vmihailenco/tagparser/v2 v2.0.0/go.mod h1:Wri+At7QHww0WTrCBeu4J6bNtoV6mEfg5OIWRZA9qds=
|
||||||
|
go.opentelemetry.io/otel v1.38.0 h1:RkfdswUDRimDg0m2Az18RKOsnI8UDzppJAtj01/Ymk8=
|
||||||
|
go.opentelemetry.io/otel v1.38.0/go.mod h1:zcmtmQ1+YmQM9wrNsTGV/q/uyusom3P8RxwExxkZhjM=
|
||||||
|
go.opentelemetry.io/otel/trace v1.38.0 h1:Fxk5bKrDZJUH+AMyyIXGcFAPah0oRcT+LuNtJrmcNLE=
|
||||||
|
go.opentelemetry.io/otel/trace v1.38.0/go.mod h1:j1P9ivuFsTceSWe1oY+EeW3sc+Pp42sO++GHkg4wwhs=
|
||||||
|
golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q=
|
||||||
|
golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4=
|
||||||
|
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ=
|
||||||
|
golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
|
k8s.io/apimachinery v0.35.0 h1:Z2L3IHvPVv/MJ7xRxHEtk6GoJElaAqDCCU0S6ncYok8=
|
||||||
|
k8s.io/apimachinery v0.35.0/go.mod h1:jQCgFZFR1F4Ik7hvr2g84RTJSZegBc8yHgFWKn//hns=
|
||||||
|
k8s.io/klog/v2 v2.130.1 h1:n9Xl7H1Xvksem4KFG4PYbdQCQxqc/tTUyrgXaOhHSzk=
|
||||||
|
k8s.io/klog/v2 v2.130.1/go.mod h1:3Jpz1GvMt720eyJH1ckRHK1EDfpxISzJ7I9OYgaDtPE=
|
||||||
|
k8s.io/utils v0.0.0-20260108192941-914a6e750570 h1:JT4W8lsdrGENg9W+YwwdLJxklIuKWdRm+BC+xt33FOY=
|
||||||
|
k8s.io/utils v0.0.0-20260108192941-914a6e750570/go.mod h1:xDxuJ0whA3d0I4mf/C4ppKHxXynQ+fxnkmQH0vTHnuk=
|
||||||
|
mellium.im/sasl v0.3.2 h1:PT6Xp7ccn9XaXAnJ03FcEjmAn7kK1x7aoXV6F+Vmrl0=
|
||||||
|
mellium.im/sasl v0.3.2/go.mod h1:NKXDi1zkr+BlMHLQjY3ofYuU4KSPFxknb8mfEu6SveY=
|
||||||
77
internal/config/config.go
Normal file
77
internal/config/config.go
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"git.haelnorr.com/h/golib/ezconf"
|
||||||
|
"git.haelnorr.com/h/golib/hlog"
|
||||||
|
"git.haelnorr.com/h/golib/hws"
|
||||||
|
"git.haelnorr.com/h/golib/hwsauth"
|
||||||
|
"git.haelnorr.com/h/oslstats/internal/db"
|
||||||
|
"github.com/joho/godotenv"
|
||||||
|
"github.com/pkg/errors"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Config struct {
|
||||||
|
DB *db.Config
|
||||||
|
HWS *hws.Config
|
||||||
|
HWSAuth *hwsauth.Config
|
||||||
|
HLOG *hlog.Config
|
||||||
|
Flags *Flags
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load the application configuration and get a pointer to the Config object
|
||||||
|
// If doconly is specified, only the loader will be returned
|
||||||
|
func GetConfig(flags *Flags) (*Config, *ezconf.ConfigLoader, error) {
|
||||||
|
err := godotenv.Load(flags.EnvFile)
|
||||||
|
if err != nil && flags.GenEnv == "" && !flags.EnvDoc {
|
||||||
|
return nil, nil, errors.Wrap(err, "gotgodotenv.Load")
|
||||||
|
}
|
||||||
|
|
||||||
|
loader := ezconf.New()
|
||||||
|
loader.RegisterIntegrations(
|
||||||
|
hlog.NewEZConfIntegration(),
|
||||||
|
hws.NewEZConfIntegration(),
|
||||||
|
hwsauth.NewEZConfIntegration(),
|
||||||
|
db.NewEZConfIntegration(),
|
||||||
|
)
|
||||||
|
if err := loader.ParseEnvVars(); err != nil {
|
||||||
|
return nil, nil, errors.Wrap(err, "loader.ParseEnvVars")
|
||||||
|
}
|
||||||
|
|
||||||
|
if flags.GenEnv != "" || flags.EnvDoc {
|
||||||
|
return nil, loader, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := loader.LoadConfigs(); err != nil {
|
||||||
|
return nil, nil, errors.Wrap(err, "loader.LoadConfigs")
|
||||||
|
}
|
||||||
|
|
||||||
|
hwscfg, ok := loader.GetConfig("hws")
|
||||||
|
if !ok {
|
||||||
|
return nil, nil, errors.New("HWS Config not loaded")
|
||||||
|
}
|
||||||
|
|
||||||
|
hwsauthcfg, ok := loader.GetConfig("hwsauth")
|
||||||
|
if !ok {
|
||||||
|
return nil, nil, errors.New("HWSAuth Config not loaded")
|
||||||
|
}
|
||||||
|
|
||||||
|
hlogcfg, ok := loader.GetConfig("hlog")
|
||||||
|
if !ok {
|
||||||
|
return nil, nil, errors.New("HLog Config not loaded")
|
||||||
|
}
|
||||||
|
|
||||||
|
dbcfg, ok := loader.GetConfig("db")
|
||||||
|
if !ok {
|
||||||
|
return nil, nil, errors.New("DB Config not loaded")
|
||||||
|
}
|
||||||
|
|
||||||
|
config := &Config{
|
||||||
|
DB: dbcfg.(*db.Config),
|
||||||
|
HWS: hwscfg.(*hws.Config),
|
||||||
|
HWSAuth: hwsauthcfg.(*hwsauth.Config),
|
||||||
|
HLOG: hlogcfg.(*hlog.Config),
|
||||||
|
Flags: flags,
|
||||||
|
}
|
||||||
|
|
||||||
|
return config, loader, nil
|
||||||
|
}
|
||||||
32
internal/config/flags.go
Normal file
32
internal/config/flags.go
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"flag"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Flags struct {
|
||||||
|
ResetDB bool
|
||||||
|
EnvDoc bool
|
||||||
|
ShowEnv bool
|
||||||
|
GenEnv string
|
||||||
|
EnvFile string
|
||||||
|
}
|
||||||
|
|
||||||
|
func SetupFlags() *Flags {
|
||||||
|
// Parse commandline args
|
||||||
|
resetDB := flag.Bool("resetdb", false, "Reset all the database tables with the updated models")
|
||||||
|
envDoc := flag.Bool("envdoc", false, "Print all environment variables and their documentation")
|
||||||
|
showEnv := flag.Bool("showenv", false, "Print all environment variable values and their documentation")
|
||||||
|
genEnv := flag.String("genenv", "", "Generate a .env file with all environment variables (specify filename)")
|
||||||
|
envfile := flag.String("envfile", ".env", "Specify a .env file to use for the configuration")
|
||||||
|
flag.Parse()
|
||||||
|
|
||||||
|
flags := &Flags{
|
||||||
|
ResetDB: *resetDB,
|
||||||
|
EnvDoc: *envDoc,
|
||||||
|
ShowEnv: *showEnv,
|
||||||
|
GenEnv: *genEnv,
|
||||||
|
EnvFile: *envfile,
|
||||||
|
}
|
||||||
|
return flags
|
||||||
|
}
|
||||||
55
internal/db/config.go
Normal file
55
internal/db/config.go
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
package db
|
||||||
|
|
||||||
|
import (
|
||||||
|
"git.haelnorr.com/h/golib/env"
|
||||||
|
"github.com/pkg/errors"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Config struct {
|
||||||
|
User string // ENV DB_USER: Database user for authentication (required)
|
||||||
|
Password string // ENV DB_PASSWORD: Database password for authentication (required)
|
||||||
|
Host string // ENV DB_HOST: Database host address (required)
|
||||||
|
Port uint16 // ENV DB_PORT: Database port (default: 5432)
|
||||||
|
DB string // ENV DB_NAME: Database name to connect to (required)
|
||||||
|
SSL string // ENV DB_SSL: SSL mode for connection (default: disable)
|
||||||
|
}
|
||||||
|
|
||||||
|
func ConfigFromEnv() (any, error) {
|
||||||
|
cfg := &Config{
|
||||||
|
User: env.String("DB_USER", ""),
|
||||||
|
Password: env.String("DB_PASSWORD", ""),
|
||||||
|
Host: env.String("DB_HOST", ""),
|
||||||
|
Port: env.UInt16("DB_PORT", 5432),
|
||||||
|
DB: env.String("DB_NAME", ""),
|
||||||
|
SSL: env.String("DB_SSL", "disable"),
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate SSL mode
|
||||||
|
validSSLModes := map[string]bool{
|
||||||
|
"disable": true,
|
||||||
|
"require": true,
|
||||||
|
"verify-ca": true,
|
||||||
|
"verify-full": true,
|
||||||
|
"allow": true,
|
||||||
|
"prefer": true,
|
||||||
|
}
|
||||||
|
if !validSSLModes[cfg.SSL] {
|
||||||
|
return nil, errors.Errorf("Invalid DB_SSL value: %s. Must be one of: disable, allow, prefer, require, verify-ca, verify-full", cfg.SSL)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check required fields
|
||||||
|
if cfg.User == "" {
|
||||||
|
return nil, errors.New("Envar not set: DB_USER")
|
||||||
|
}
|
||||||
|
if cfg.Password == "" {
|
||||||
|
return nil, errors.New("Envar not set: DB_PASSWORD")
|
||||||
|
}
|
||||||
|
if cfg.Host == "" {
|
||||||
|
return nil, errors.New("Envar not set: DB_HOST")
|
||||||
|
}
|
||||||
|
if cfg.DB == "" {
|
||||||
|
return nil, errors.New("Envar not set: DB_NAME")
|
||||||
|
}
|
||||||
|
|
||||||
|
return cfg, nil
|
||||||
|
}
|
||||||
41
internal/db/ezconf.go
Normal file
41
internal/db/ezconf.go
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
package db
|
||||||
|
|
||||||
|
import (
|
||||||
|
"runtime"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// EZConfIntegration provides integration with ezconf for automatic configuration
|
||||||
|
type EZConfIntegration struct {
|
||||||
|
configFunc func() (any, error)
|
||||||
|
name string
|
||||||
|
}
|
||||||
|
|
||||||
|
// PackagePath returns the path to the config package for source parsing
|
||||||
|
func (e EZConfIntegration) PackagePath() string {
|
||||||
|
_, filename, _, _ := runtime.Caller(0)
|
||||||
|
// Return directory of this file
|
||||||
|
return filename[:len(filename)-len("/ezconf.go")]
|
||||||
|
}
|
||||||
|
|
||||||
|
// ConfigFunc returns the ConfigFromEnv function for ezconf
|
||||||
|
func (e EZConfIntegration) ConfigFunc() func() (any, error) {
|
||||||
|
return func() (any, error) {
|
||||||
|
return e.configFunc()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Name returns the name to use when registering with ezconf
|
||||||
|
func (e EZConfIntegration) Name() string {
|
||||||
|
return strings.ToLower(e.name)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GroupName returns the display name for grouping environment variables
|
||||||
|
func (e EZConfIntegration) GroupName() string {
|
||||||
|
return e.name
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewEZConfIntegration creates a new EZConf integration helper
|
||||||
|
func NewEZConfIntegration() EZConfIntegration {
|
||||||
|
return EZConfIntegration{name: "db", configFunc: ConfigFromEnv}
|
||||||
|
}
|
||||||
163
internal/db/user.go
Normal file
163
internal/db/user.go
Normal file
@@ -0,0 +1,163 @@
|
|||||||
|
package db
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"github.com/pkg/errors"
|
||||||
|
"github.com/uptrace/bun"
|
||||||
|
"golang.org/x/crypto/bcrypt"
|
||||||
|
)
|
||||||
|
|
||||||
|
type User struct {
|
||||||
|
bun.BaseModel `bun:"table:users,alias:u"`
|
||||||
|
|
||||||
|
ID int `bun:"id,pk,autoincrement"` // Integer ID (index primary key)
|
||||||
|
Username string `bun:"username,unique"` // Username (unique)
|
||||||
|
PasswordHash string `bun:"password_hash,nullzero"` // Bcrypt hashed password (not exported in JSON)
|
||||||
|
CreatedAt int64 `bun:"created_at"` // Epoch timestamp when the user was added to the database
|
||||||
|
Bio string `bun:"bio"` // Short byline set by the user
|
||||||
|
}
|
||||||
|
|
||||||
|
func (user *User) GetID() int {
|
||||||
|
return user.ID
|
||||||
|
}
|
||||||
|
|
||||||
|
// Uses bcrypt to set the users password_hash from the given password
|
||||||
|
func (user *User) SetPassword(ctx context.Context, tx bun.Tx, password string) error {
|
||||||
|
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(err, "bcrypt.GenerateFromPassword")
|
||||||
|
}
|
||||||
|
newPassword := string(hashedPassword)
|
||||||
|
|
||||||
|
_, err = tx.NewUpdate().
|
||||||
|
Model(user).
|
||||||
|
Set("password_hash = ?", newPassword).
|
||||||
|
Where("id = ?", user.ID).
|
||||||
|
Exec(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(err, "tx.Update")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Uses bcrypt to check if the given password matches the users password_hash
|
||||||
|
func (user *User) CheckPassword(ctx context.Context, tx bun.Tx, password string) error {
|
||||||
|
var hashedPassword string
|
||||||
|
err := tx.NewSelect().
|
||||||
|
Table("users").
|
||||||
|
Column("password_hash").
|
||||||
|
Where("id = ?", user.ID).
|
||||||
|
Limit(1).
|
||||||
|
Scan(ctx, &hashedPassword)
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(err, "tx.Select")
|
||||||
|
}
|
||||||
|
|
||||||
|
err = bcrypt.CompareHashAndPassword([]byte(hashedPassword), []byte(password))
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(err, "Username or password incorrect")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Change the user's username
|
||||||
|
func (user *User) ChangeUsername(ctx context.Context, tx bun.Tx, newUsername string) error {
|
||||||
|
_, err := tx.NewUpdate().
|
||||||
|
Model(user).
|
||||||
|
Set("username = ?", newUsername).
|
||||||
|
Where("id = ?", user.ID).
|
||||||
|
Exec(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(err, "tx.Update")
|
||||||
|
}
|
||||||
|
user.Username = newUsername
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Change the user's bio
|
||||||
|
func (user *User) ChangeBio(ctx context.Context, tx bun.Tx, newBio string) error {
|
||||||
|
_, err := tx.NewUpdate().
|
||||||
|
Model(user).
|
||||||
|
Set("bio = ?", newBio).
|
||||||
|
Where("id = ?", user.ID).
|
||||||
|
Exec(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(err, "tx.Update")
|
||||||
|
}
|
||||||
|
user.Bio = newBio
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateUser creates a new user with the given username and password
|
||||||
|
func CreateUser(ctx context.Context, tx bun.Tx, username, password string) (*User, error) {
|
||||||
|
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.Wrap(err, "bcrypt.GenerateFromPassword")
|
||||||
|
}
|
||||||
|
|
||||||
|
user := &User{
|
||||||
|
Username: username,
|
||||||
|
PasswordHash: string(hashedPassword),
|
||||||
|
CreatedAt: 0, // You may want to set this to time.Now().Unix()
|
||||||
|
Bio: "",
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = tx.NewInsert().
|
||||||
|
Model(user).
|
||||||
|
Exec(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.Wrap(err, "tx.Insert")
|
||||||
|
}
|
||||||
|
|
||||||
|
return user, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetUserByID queries the database for a user matching the given ID
|
||||||
|
// Returns nil, nil if no user is found
|
||||||
|
func GetUserByID(ctx context.Context, tx bun.Tx, id int) (*User, error) {
|
||||||
|
user := new(User)
|
||||||
|
err := tx.NewSelect().
|
||||||
|
Model(user).
|
||||||
|
Where("id = ?", id).
|
||||||
|
Limit(1).
|
||||||
|
Scan(ctx)
|
||||||
|
if err != nil {
|
||||||
|
if err.Error() == "sql: no rows in result set" {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
return nil, errors.Wrap(err, "tx.Select")
|
||||||
|
}
|
||||||
|
return user, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetUserByUsername queries the database for a user matching the given username
|
||||||
|
// Returns nil, nil if no user is found
|
||||||
|
func GetUserByUsername(ctx context.Context, tx bun.Tx, username string) (*User, error) {
|
||||||
|
user := new(User)
|
||||||
|
err := tx.NewSelect().
|
||||||
|
Model(user).
|
||||||
|
Where("username = ?", username).
|
||||||
|
Limit(1).
|
||||||
|
Scan(ctx)
|
||||||
|
if err != nil {
|
||||||
|
if err.Error() == "sql: no rows in result set" {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
return nil, errors.Wrap(err, "tx.Select")
|
||||||
|
}
|
||||||
|
return user, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsUsernameUnique checks if the given username is unique (not already taken)
|
||||||
|
// Returns true if the username is available, false if it's taken
|
||||||
|
func IsUsernameUnique(ctx context.Context, tx bun.Tx, username string) (bool, error) {
|
||||||
|
count, err := tx.NewSelect().
|
||||||
|
Model((*User)(nil)).
|
||||||
|
Where("username = ?", username).
|
||||||
|
Count(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return false, errors.Wrap(err, "tx.Count")
|
||||||
|
}
|
||||||
|
return count == 0, nil
|
||||||
|
}
|
||||||
28
internal/handlers/errorpage.go
Normal file
28
internal/handlers/errorpage.go
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"git.haelnorr.com/h/golib/hws"
|
||||||
|
"git.haelnorr.com/h/oslstats/internal/view/page"
|
||||||
|
"github.com/pkg/errors"
|
||||||
|
)
|
||||||
|
|
||||||
|
func ErrorPage(
|
||||||
|
errorCode int,
|
||||||
|
) (hws.ErrorPage, error) {
|
||||||
|
messages := map[int]string{
|
||||||
|
400: "The request you made was malformed or unexpected.",
|
||||||
|
401: "You need to login to view this page.",
|
||||||
|
403: "You do not have permission to view this page.",
|
||||||
|
404: "The page or resource you have requested does not exist.",
|
||||||
|
500: `An error occured on the server. Please try again, and if this
|
||||||
|
continues to happen contact an administrator.`,
|
||||||
|
503: "The server is currently down for maintenance and should be back soon. =)",
|
||||||
|
}
|
||||||
|
msg, exists := messages[errorCode]
|
||||||
|
if !exists {
|
||||||
|
return nil, errors.New("No valid message for the given code")
|
||||||
|
}
|
||||||
|
return page.Error(errorCode, http.StatusText(errorCode), msg), nil
|
||||||
|
}
|
||||||
49
internal/handlers/index.go
Normal file
49
internal/handlers/index.go
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"git.haelnorr.com/h/oslstats/internal/view/page"
|
||||||
|
|
||||||
|
"git.haelnorr.com/h/golib/hws"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Handles responses to the / path. Also serves a 404 Page for paths that
|
||||||
|
// don't have explicit handlers
|
||||||
|
func Index(server *hws.Server) http.Handler {
|
||||||
|
return http.HandlerFunc(
|
||||||
|
func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.URL.Path != "/" {
|
||||||
|
page, err := ErrorPage(http.StatusNotFound)
|
||||||
|
if err != nil {
|
||||||
|
err = server.ThrowError(w, r, hws.HWSError{
|
||||||
|
StatusCode: http.StatusInternalServerError,
|
||||||
|
Message: "An error occured trying to generate the error page",
|
||||||
|
Error: err,
|
||||||
|
Level: hws.ErrorLevel("error"),
|
||||||
|
RenderErrorPage: false,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
server.ThrowFatal(w, err)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
err = page.Render(r.Context(), w)
|
||||||
|
if err != nil {
|
||||||
|
err = server.ThrowError(w, r, hws.HWSError{
|
||||||
|
StatusCode: http.StatusInternalServerError,
|
||||||
|
Message: "An error occured trying to render the error page",
|
||||||
|
Error: err,
|
||||||
|
Level: hws.ErrorLevel("error"),
|
||||||
|
RenderErrorPage: false,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
server.ThrowFatal(w, err)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
page.Index().Render(r.Context(), w)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
70
internal/handlers/static.go
Normal file
70
internal/handlers/static.go
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"git.haelnorr.com/h/golib/hws"
|
||||||
|
"net/http"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Handles requests for static files, without allowing access to the
|
||||||
|
// directory viewer and returning 404 if an exact file is not found
|
||||||
|
func StaticFS(staticFS *http.FileSystem, server *hws.Server) http.Handler {
|
||||||
|
// Create the file server once, not on every request
|
||||||
|
fs, err := hws.SafeFileServer(staticFS)
|
||||||
|
if err != nil {
|
||||||
|
// If we can't create the file server, return a handler that always errors
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
err = server.ThrowError(w, r, hws.HWSError{
|
||||||
|
StatusCode: http.StatusInternalServerError,
|
||||||
|
Message: "An error occured trying to load the file system",
|
||||||
|
Error: err,
|
||||||
|
Level: hws.ErrorLevel("error"),
|
||||||
|
RenderErrorPage: true,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
server.ThrowFatal(w, err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return http.HandlerFunc(
|
||||||
|
func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
// Explicitly set Content-Type for CSS files
|
||||||
|
if strings.HasSuffix(r.URL.Path, ".css") {
|
||||||
|
w.Header().Set("Content-Type", "text/css; charset=utf-8")
|
||||||
|
} else if strings.HasSuffix(r.URL.Path, ".js") {
|
||||||
|
w.Header().Set("Content-Type", "application/javascript; charset=utf-8")
|
||||||
|
} else if strings.HasSuffix(r.URL.Path, ".ico") {
|
||||||
|
w.Header().Set("Content-Type", "image/x-icon")
|
||||||
|
} else {
|
||||||
|
// Let Go detect the content type for other files
|
||||||
|
ext := filepath.Ext(r.URL.Path)
|
||||||
|
if contentType := mimeTypes[ext]; contentType != "" {
|
||||||
|
w.Header().Set("Content-Type", contentType)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fs.ServeHTTP(w, r)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Common MIME types for static files
|
||||||
|
var mimeTypes = map[string]string{
|
||||||
|
".html": "text/html; charset=utf-8",
|
||||||
|
".css": "text/css; charset=utf-8",
|
||||||
|
".js": "application/javascript; charset=utf-8",
|
||||||
|
".json": "application/json; charset=utf-8",
|
||||||
|
".xml": "application/xml; charset=utf-8",
|
||||||
|
".png": "image/png",
|
||||||
|
".jpg": "image/jpeg",
|
||||||
|
".jpeg": "image/jpeg",
|
||||||
|
".gif": "image/gif",
|
||||||
|
".svg": "image/svg+xml",
|
||||||
|
".ico": "image/x-icon",
|
||||||
|
".webp": "image/webp",
|
||||||
|
".woff": "font/woff",
|
||||||
|
".woff2": "font/woff2",
|
||||||
|
".ttf": "font/ttf",
|
||||||
|
".eot": "application/vnd.ms-fontobject",
|
||||||
|
}
|
||||||
116
internal/view/component/footer/footer.templ
Normal file
116
internal/view/component/footer/footer.templ
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
package footer
|
||||||
|
|
||||||
|
type FooterItem struct {
|
||||||
|
name string
|
||||||
|
href string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Specify the links to show in the footer
|
||||||
|
func getFooterItems() []FooterItem {
|
||||||
|
return []FooterItem{
|
||||||
|
{
|
||||||
|
name: "About",
|
||||||
|
href: "/about",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Returns the template fragment for the Footer
|
||||||
|
templ Footer() {
|
||||||
|
<footer class="bg-mantle mt-10">
|
||||||
|
<div
|
||||||
|
class="relative mx-auto max-w-screen-xl px-4 py-8 sm:px-6 lg:px-8"
|
||||||
|
>
|
||||||
|
<div class="absolute end-4 top-4 sm:end-6 lg:end-8">
|
||||||
|
<a
|
||||||
|
class="inline-block rounded-full bg-teal p-2 text-crust
|
||||||
|
shadow-sm transition hover:bg-teal/75"
|
||||||
|
href="#main-content"
|
||||||
|
>
|
||||||
|
<span class="sr-only">Back to top</span>
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
class="size-5"
|
||||||
|
viewBox="0 0 20 20"
|
||||||
|
fill="currentColor"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
fill-rule="evenodd"
|
||||||
|
d="M14.707 12.707a1 1 0 01-1.414 0L10 9.414l-3.293
|
||||||
|
3.293a1 1 0 01-1.414-1.414l4-4a1 1 0 011.414 0l4
|
||||||
|
4a1 1 0 010 1.414z"
|
||||||
|
clip-rule="evenodd"
|
||||||
|
></path>
|
||||||
|
</svg>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<div class="lg:flex lg:items-end lg:justify-between">
|
||||||
|
<div>
|
||||||
|
<div class="flex justify-center text-text lg:justify-start">
|
||||||
|
// TODO: logo/branding here
|
||||||
|
<span class="text-2xl">OSL Stats</span>
|
||||||
|
</div>
|
||||||
|
<p
|
||||||
|
class="mx-auto max-w-md text-center leading-relaxed
|
||||||
|
text-subtext0"
|
||||||
|
>placeholder text</p>
|
||||||
|
</div>
|
||||||
|
<ul
|
||||||
|
class="mt-12 flex flex-wrap justify-center gap-6 md:gap-8
|
||||||
|
lg:mt-0 lg:justify-end lg:gap-12"
|
||||||
|
>
|
||||||
|
for _, item := range getFooterItems() {
|
||||||
|
<li>
|
||||||
|
<a
|
||||||
|
class="transition hover:text-subtext1"
|
||||||
|
href={ templ.SafeURL(item.href) }
|
||||||
|
>{ item.name }</a>
|
||||||
|
</li>
|
||||||
|
}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div class="lg:flex lg:items-end lg:justify-between">
|
||||||
|
<div>
|
||||||
|
<p class="mt-4 text-center text-sm text-overlay0">
|
||||||
|
by Haelnorr | placeholder text
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="mt-2 text-center">
|
||||||
|
<label
|
||||||
|
for="theme-select"
|
||||||
|
class="hidden lg:inline"
|
||||||
|
>Theme</label>
|
||||||
|
<select
|
||||||
|
name="ThemeSelect"
|
||||||
|
id="theme-select"
|
||||||
|
class="mt-1.5 inline rounded-lg bg-surface0 p-2 w-fit"
|
||||||
|
x-model="theme"
|
||||||
|
>
|
||||||
|
<template
|
||||||
|
x-for="themeopt in [
|
||||||
|
'dark',
|
||||||
|
'light',
|
||||||
|
'system',
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
<option
|
||||||
|
x-text="displayThemeName(themeopt)"
|
||||||
|
:value="themeopt"
|
||||||
|
:selected="theme === themeopt"
|
||||||
|
></option>
|
||||||
|
</template>
|
||||||
|
</select>
|
||||||
|
<script>
|
||||||
|
const displayThemeName = (value) => {
|
||||||
|
if (value === "dark") return "Dark (Mocha)";
|
||||||
|
if (value === "light") return "Light (Latte)";
|
||||||
|
if (value === "system") return "System";
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
}
|
||||||
36
internal/view/component/nav/navbar.templ
Normal file
36
internal/view/component/nav/navbar.templ
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
package nav
|
||||||
|
|
||||||
|
type NavItem struct {
|
||||||
|
name string // Label to display
|
||||||
|
href string // Link reference
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return the list of navbar links
|
||||||
|
func getNavItems() []NavItem {
|
||||||
|
return []NavItem{}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Returns the navbar template fragment
|
||||||
|
templ Navbar() {
|
||||||
|
{{ navItems := getNavItems() }}
|
||||||
|
<div x-data="{ open: false }">
|
||||||
|
<header class="bg-crust">
|
||||||
|
<div
|
||||||
|
class="mx-auto flex h-16 max-w-7xl items-center gap-8
|
||||||
|
px-4 sm:px-6 lg:px-8"
|
||||||
|
>
|
||||||
|
<a class="block" href="/">
|
||||||
|
<!-- logo here -->
|
||||||
|
<span class="text-3xl font-bold transition hover:text-green">
|
||||||
|
OSL Stats
|
||||||
|
</span>
|
||||||
|
</a>
|
||||||
|
<div class="flex flex-1 items-center justify-end sm:justify-between">
|
||||||
|
@navLeft(navItems)
|
||||||
|
@navRight()
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
@sideNav(navItems)
|
||||||
|
</div>
|
||||||
|
}
|
||||||
19
internal/view/component/nav/navbarleft.templ
Normal file
19
internal/view/component/nav/navbarleft.templ
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
package nav
|
||||||
|
|
||||||
|
// Returns the left portion of the navbar
|
||||||
|
templ navLeft(navItems []NavItem) {
|
||||||
|
<nav aria-label="Global" class="hidden sm:block">
|
||||||
|
<ul class="flex items-center gap-6 text-xl">
|
||||||
|
for _, item := range navItems {
|
||||||
|
<li>
|
||||||
|
<a
|
||||||
|
class="text-subtext1 hover:text-green transition"
|
||||||
|
href={ templ.SafeURL(item.href) }
|
||||||
|
>
|
||||||
|
{ item.name }
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
}
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
}
|
||||||
115
internal/view/component/nav/navbarright.templ
Normal file
115
internal/view/component/nav/navbarright.templ
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
package nav
|
||||||
|
|
||||||
|
import "git.haelnorr.com/h/oslstats/pkg/contexts"
|
||||||
|
|
||||||
|
type ProfileItem struct {
|
||||||
|
name string // Label to display
|
||||||
|
href string // Link reference
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return the list of profile links
|
||||||
|
func getProfileItems() []ProfileItem {
|
||||||
|
return []ProfileItem{
|
||||||
|
{
|
||||||
|
name: "Profile",
|
||||||
|
href: "/profile",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Account",
|
||||||
|
href: "/account",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Returns the right portion of the navbar
|
||||||
|
templ navRight() {
|
||||||
|
{{ user := contexts.CurrentUser(ctx) }}
|
||||||
|
{{ items := getProfileItems() }}
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<div class="sm:flex sm:gap-2">
|
||||||
|
if user != nil {
|
||||||
|
<div x-data="{ isActive: false }" class="relative">
|
||||||
|
<div
|
||||||
|
class="inline-flex items-center overflow-hidden
|
||||||
|
rounded-lg bg-sapphire hover:bg-sapphire/75 transition"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
x-on:click="isActive = !isActive"
|
||||||
|
class="h-full py-2 px-4 text-mantle hover:cursor-pointer"
|
||||||
|
>
|
||||||
|
<span class="sr-only">Profile</span>
|
||||||
|
{ user.Username }
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="absolute end-0 z-10 mt-2 w-36 divide-y
|
||||||
|
divide-surface2 rounded-lg border border-surface1
|
||||||
|
bg-surface0 shadow-lg"
|
||||||
|
role="menu"
|
||||||
|
x-cloak
|
||||||
|
x-transition
|
||||||
|
x-show="isActive"
|
||||||
|
x-on:click.away="isActive = false"
|
||||||
|
x-on:keydown.escape.window="isActive = false"
|
||||||
|
>
|
||||||
|
<div class="p-2">
|
||||||
|
for _, item := range items {
|
||||||
|
<a
|
||||||
|
href={ templ.SafeURL(item.href) }
|
||||||
|
class="block rounded-lg px-4 py-2 text-md
|
||||||
|
hover:bg-crust"
|
||||||
|
role="menuitem"
|
||||||
|
>
|
||||||
|
{ item.name }
|
||||||
|
</a>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<div class="p-2">
|
||||||
|
<form hx-post="/logout">
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="flex w-full items-center gap-2
|
||||||
|
rounded-lg px-4 py-2 text-md text-red
|
||||||
|
hover:bg-red/25 hover:cursor-pointer"
|
||||||
|
role="menuitem"
|
||||||
|
@click="isActive=false"
|
||||||
|
>
|
||||||
|
Logout
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
} else {
|
||||||
|
<a
|
||||||
|
class="hidden rounded-lg px-4 py-2 sm:block
|
||||||
|
bg-green hover:bg-green/75 text-mantle transition"
|
||||||
|
href="/login"
|
||||||
|
>
|
||||||
|
Login
|
||||||
|
</a>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
@click="open = !open"
|
||||||
|
class="block rounded-lg p-2.5 sm:hidden transition
|
||||||
|
bg-surface0 text-subtext0 hover:text-overlay2/75"
|
||||||
|
>
|
||||||
|
<span class="sr-only">Toggle menu</span>
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
class="size-5"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
d="M4 6h16M4 12h16M4 18h16"
|
||||||
|
></path>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
45
internal/view/component/nav/sidenav.templ
Normal file
45
internal/view/component/nav/sidenav.templ
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
package nav
|
||||||
|
|
||||||
|
import "git.haelnorr.com/h/oslstats/pkg/contexts"
|
||||||
|
|
||||||
|
// Returns the mobile version of the navbar thats only visible when activated
|
||||||
|
templ sideNav(navItems []NavItem) {
|
||||||
|
{{ user := contexts.CurrentUser(ctx) }}
|
||||||
|
<div
|
||||||
|
x-show="open"
|
||||||
|
x-transition
|
||||||
|
class="absolute w-full bg-mantle sm:hidden z-10"
|
||||||
|
>
|
||||||
|
<div class="px-4 py-6">
|
||||||
|
<ul class="space-y-1">
|
||||||
|
for _, item := range navItems {
|
||||||
|
<li>
|
||||||
|
<a
|
||||||
|
href={ templ.SafeURL(item.href) }
|
||||||
|
class="block rounded-lg px-4 py-2 text-lg
|
||||||
|
bg-surface0 text-text transition hover:bg-surface2"
|
||||||
|
>
|
||||||
|
{ item.name }
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
if user == nil {
|
||||||
|
<div class="px-4 pb-6">
|
||||||
|
<ul class="space-y-1">
|
||||||
|
<li class="flex justify-center items-center gap-2">
|
||||||
|
<a
|
||||||
|
class="w-26 px-4 py-2 rounded-lg
|
||||||
|
bg-green text-mantle transition hover:bg-green/75
|
||||||
|
text-center"
|
||||||
|
href="/login"
|
||||||
|
>
|
||||||
|
Login
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
63
internal/view/component/popup/error500Popup.templ
Normal file
63
internal/view/component/popup/error500Popup.templ
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
package popup
|
||||||
|
|
||||||
|
templ Error500Popup() {
|
||||||
|
<div
|
||||||
|
x-cloak
|
||||||
|
x-show="showError500"
|
||||||
|
class="absolute w-82 left-0 right-0 mt-20 mr-5 ml-auto"
|
||||||
|
x-transition:enter="transform translate-x-[100%] opacity-0 duration-200"
|
||||||
|
x-transition:enter-start="opacity-0 translate-x-[100%]"
|
||||||
|
x-transition:enter-end="opacity-100 translate-x-0"
|
||||||
|
x-transition:leave="opacity-0 duration-200"
|
||||||
|
x-transition:leave-start="opacity-100 translate-x-0"
|
||||||
|
x-transition:leave-end="opacity-0 translate-x-[100%]"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
role="alert"
|
||||||
|
class="rounded-sm bg-dark-red p-4"
|
||||||
|
>
|
||||||
|
<div class="flex justify-between">
|
||||||
|
<div class="flex items-center gap-2 text-red w-fit">
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="currentColor"
|
||||||
|
class="size-5"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
fill-rule="evenodd"
|
||||||
|
d="M9.401 3.003c1.155-2 4.043-2 5.197 0l7.355
|
||||||
|
12.748c1.154 2-.29 4.5-2.599 4.5H4.645c-2.309
|
||||||
|
0-3.752-2.5-2.598-4.5L9.4 3.003zM12 8.25a.75.75
|
||||||
|
0 01.75.75v3.75a.75.75 0 01-1.5 0V9a.75.75 0
|
||||||
|
01.75-.75zm0 8.25a.75.75 0 100-1.5.75.75 0 000 1.5z"
|
||||||
|
clip-rule="evenodd"
|
||||||
|
></path>
|
||||||
|
</svg>
|
||||||
|
<strong class="block font-medium">Something went wrong </strong>
|
||||||
|
</div>
|
||||||
|
<div class="flex">
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke-width="1.5"
|
||||||
|
stroke="currentColor"
|
||||||
|
class="size-6 text-subtext0 hover:cursor-pointer"
|
||||||
|
@click="showError500=false"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
d="M6 18L18 6M6 6l12 12"
|
||||||
|
></path>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p class="mt-2 text-sm text-red">
|
||||||
|
An error occured on the server. Please try again later,
|
||||||
|
or contact an administrator
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
63
internal/view/component/popup/error503Popup.templ
Normal file
63
internal/view/component/popup/error503Popup.templ
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
package popup
|
||||||
|
|
||||||
|
templ Error503Popup() {
|
||||||
|
<div
|
||||||
|
x-cloak
|
||||||
|
x-show="showError503"
|
||||||
|
class="absolute w-82 left-0 right-0 mt-20 mr-5 ml-auto"
|
||||||
|
x-transition:enter="transform translate-x-[100%] opacity-0 duration-200"
|
||||||
|
x-transition:enter-start="opacity-0 translate-x-[100%]"
|
||||||
|
x-transition:enter-end="opacity-100 translate-x-0"
|
||||||
|
x-transition:leave="opacity-0 duration-200"
|
||||||
|
x-transition:leave-start="opacity-100 translate-x-0"
|
||||||
|
x-transition:leave-end="opacity-0 translate-x-[100%]"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
role="alert"
|
||||||
|
class="rounded-sm bg-dark-red p-4"
|
||||||
|
>
|
||||||
|
<div class="flex justify-between">
|
||||||
|
<div class="flex items-center gap-2 text-red w-fit">
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="currentColor"
|
||||||
|
class="size-5"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
fill-rule="evenodd"
|
||||||
|
d="M9.401 3.003c1.155-2 4.043-2 5.197 0l7.355
|
||||||
|
12.748c1.154 2-.29 4.5-2.599 4.5H4.645c-2.309
|
||||||
|
0-3.752-2.5-2.598-4.5L9.4 3.003zM12 8.25a.75.75
|
||||||
|
0 01.75.75v3.75a.75.75 0 01-1.5 0V9a.75.75 0
|
||||||
|
01.75-.75zm0 8.25a.75.75 0 100-1.5.75.75 0 000 1.5z"
|
||||||
|
clip-rule="evenodd"
|
||||||
|
></path>
|
||||||
|
</svg>
|
||||||
|
<strong class="block font-medium">Service Unavailable</strong>
|
||||||
|
</div>
|
||||||
|
<div class="flex">
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke-width="1.5"
|
||||||
|
stroke="currentColor"
|
||||||
|
class="size-6 text-subtext0 hover:cursor-pointer"
|
||||||
|
@click="showError503=false"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
d="M6 18L18 6M6 6l12 12"
|
||||||
|
></path>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p class="mt-2 text-sm text-red">
|
||||||
|
The service is currently available. It could be down for maintenance.
|
||||||
|
Please try again later.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
97
internal/view/layout/global.templ
Normal file
97
internal/view/layout/global.templ
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
package layout
|
||||||
|
|
||||||
|
import "git.haelnorr.com/h/oslstats/internal/view/component/popup"
|
||||||
|
import "git.haelnorr.com/h/oslstats/internal/view/component/nav"
|
||||||
|
import "git.haelnorr.com/h/oslstats/internal/view/component/footer"
|
||||||
|
|
||||||
|
// Global page layout. Includes HTML document settings, header tags
|
||||||
|
// navbar and footer
|
||||||
|
templ Global(title string) {
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html
|
||||||
|
lang="en"
|
||||||
|
x-data="{
|
||||||
|
theme: localStorage.getItem('theme')
|
||||||
|
|| 'system'}"
|
||||||
|
x-init="$watch('theme', (val) => localStorage.setItem('theme', val))"
|
||||||
|
x-bind:class="{'dark': theme === 'dark' || (theme === 'system' &&
|
||||||
|
window.matchMedia('(prefers-color-scheme: dark)').matches)}"
|
||||||
|
>
|
||||||
|
<head>
|
||||||
|
<!-- <script src="/static/js/theme.js"></script> -->
|
||||||
|
<meta charset="UTF-8"/>
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1"/>
|
||||||
|
<title>{ title }</title>
|
||||||
|
<link rel="icon" type="image/x-icon" href="/static/favicon.ico"/>
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com"/>
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin="anonymous"/>
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Ubuntu+Mono:ital,wght@0,400;0,700;1,400;1,700&display=swap" rel="stylesheet"/>
|
||||||
|
<link href="/static/css/output.css" rel="stylesheet"/>
|
||||||
|
<script src="https://unpkg.com/htmx.org@2.0.4" integrity="sha384-HGfztofotfshcF7+8n44JQL2oJmowVChPTg48S+jvZoztPfvwD79OC/LTtG6dMp+" crossorigin="anonymous"></script>
|
||||||
|
<script defer src="https://cdn.jsdelivr.net/npm/@alpinejs/persist@3.x.x/dist/cdn.min.js"></script>
|
||||||
|
<script src="https://unpkg.com/alpinejs" defer></script>
|
||||||
|
<script>
|
||||||
|
// uncomment this line to enable logging of htmx events
|
||||||
|
// htmx.logAll();
|
||||||
|
</script>
|
||||||
|
<script>
|
||||||
|
const bodyData = {
|
||||||
|
showError500: false,
|
||||||
|
showError503: false,
|
||||||
|
showConfirmPasswordModal: false,
|
||||||
|
handleHtmxBeforeOnLoad(event) {
|
||||||
|
const requestPath = event.detail.pathInfo.requestPath;
|
||||||
|
if (requestPath === "/reauthenticate") {
|
||||||
|
// handle password incorrect on refresh attempt
|
||||||
|
if (event.detail.xhr.status === 445) {
|
||||||
|
event.detail.shouldSwap = true;
|
||||||
|
event.detail.isError = false;
|
||||||
|
} else if (event.detail.xhr.status === 200) {
|
||||||
|
this.showConfirmPasswordModal = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
// handle errors from the server on HTMX requests
|
||||||
|
handleHtmxError(event) {
|
||||||
|
const errorCode = event.detail.errorInfo.error;
|
||||||
|
|
||||||
|
// internal server error
|
||||||
|
if (errorCode.includes("Code 500")) {
|
||||||
|
this.showError500 = true;
|
||||||
|
setTimeout(() => (this.showError500 = false), 6000);
|
||||||
|
}
|
||||||
|
// service not available error
|
||||||
|
if (errorCode.includes("Code 503")) {
|
||||||
|
this.showError503 = true;
|
||||||
|
setTimeout(() => (this.showError503 = false), 6000);
|
||||||
|
}
|
||||||
|
|
||||||
|
// user is authorized but needs to refresh their login
|
||||||
|
if (errorCode.includes("Code 444")) {
|
||||||
|
this.showConfirmPasswordModal = true;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
</head>
|
||||||
|
<body
|
||||||
|
class="bg-base text-text ubuntu-mono-regular overflow-x-hidden"
|
||||||
|
x-data="bodyData"
|
||||||
|
x-on:htmx:error="handleHtmxError($event)"
|
||||||
|
x-on:htmx:before-on-load="handleHtmxBeforeOnLoad($event)"
|
||||||
|
>
|
||||||
|
@popup.Error500Popup()
|
||||||
|
@popup.Error503Popup()
|
||||||
|
<div
|
||||||
|
id="main-content"
|
||||||
|
class="flex flex-col h-screen justify-between"
|
||||||
|
>
|
||||||
|
@nav.Navbar()
|
||||||
|
<div id="page-content" class="mb-auto md:px-5 md:pt-5">
|
||||||
|
{ children... }
|
||||||
|
</div>
|
||||||
|
@footer.Footer()
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
}
|
||||||
34
internal/view/page/error.templ
Normal file
34
internal/view/page/error.templ
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
package page
|
||||||
|
|
||||||
|
import "git.haelnorr.com/h/oslstats/internal/view/layout"
|
||||||
|
import "strconv"
|
||||||
|
|
||||||
|
// Page template for Error pages. Error code should be a HTTP status code as
|
||||||
|
// a string, and err should be the corresponding response title.
|
||||||
|
// Message is a custom error message displayed below the code and error.
|
||||||
|
templ Error(code int, err string, message string) {
|
||||||
|
@layout.Global(err) {
|
||||||
|
<div
|
||||||
|
class="grid mt-24 left-0 right-0 top-0 bottom-0
|
||||||
|
place-content-center bg-base px-4"
|
||||||
|
>
|
||||||
|
<div class="text-center">
|
||||||
|
<h1
|
||||||
|
class="text-9xl text-text"
|
||||||
|
>{ strconv.Itoa(code) }</h1>
|
||||||
|
<p
|
||||||
|
class="text-2xl font-bold tracking-tight text-subtext1
|
||||||
|
sm:text-4xl"
|
||||||
|
>{ err }</p>
|
||||||
|
<p
|
||||||
|
class="mt-4 text-subtext0"
|
||||||
|
>{ message }</p>
|
||||||
|
<a
|
||||||
|
href="/"
|
||||||
|
class="mt-6 inline-block rounded-lg bg-mauve px-5 py-3
|
||||||
|
text-sm text-crust transition hover:bg-mauve/75"
|
||||||
|
>Go to homepage</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
13
internal/view/page/index.templ
Normal file
13
internal/view/page/index.templ
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
package page
|
||||||
|
|
||||||
|
import "git.haelnorr.com/h/oslstats/internal/view/layout"
|
||||||
|
|
||||||
|
// Page content for the index page
|
||||||
|
templ Index() {
|
||||||
|
@layout.Global("OSL Stats") {
|
||||||
|
<div class="text-center mt-24">
|
||||||
|
<div class="text-4xl lg:text-6xl">OSL Stats</div>
|
||||||
|
<div>Placeholder text</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
8
pkg/contexts/currentuser.go
Normal file
8
pkg/contexts/currentuser.go
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
package contexts
|
||||||
|
|
||||||
|
import (
|
||||||
|
"git.haelnorr.com/h/golib/hwsauth"
|
||||||
|
"git.haelnorr.com/h/oslstats/internal/db"
|
||||||
|
)
|
||||||
|
|
||||||
|
var CurrentUser hwsauth.ContextLoader[*db.User]
|
||||||
7
pkg/contexts/keys.go
Normal file
7
pkg/contexts/keys.go
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
package contexts
|
||||||
|
|
||||||
|
type contextKey string
|
||||||
|
|
||||||
|
func (c contextKey) String() string {
|
||||||
|
return "oslstats context key " + string(c)
|
||||||
|
}
|
||||||
20
pkg/embedfs/embedfs.go
Normal file
20
pkg/embedfs/embedfs.go
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
package embedfs
|
||||||
|
|
||||||
|
import (
|
||||||
|
"embed"
|
||||||
|
"io/fs"
|
||||||
|
|
||||||
|
"github.com/pkg/errors"
|
||||||
|
)
|
||||||
|
|
||||||
|
//go:embed files/*
|
||||||
|
var embeddedFiles embed.FS
|
||||||
|
|
||||||
|
// Gets the embedded files
|
||||||
|
func GetEmbeddedFS() (fs.FS, error) {
|
||||||
|
subFS, err := fs.Sub(embeddedFiles, "files")
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.Wrap(err, "fs.Sub")
|
||||||
|
}
|
||||||
|
return subFS, nil
|
||||||
|
}
|
||||||
BIN
pkg/embedfs/files/assets/error.png
Normal file
BIN
pkg/embedfs/files/assets/error.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 21 KiB |
127
pkg/embedfs/files/css/input.css
Normal file
127
pkg/embedfs/files/css/input.css
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
@import "tailwindcss";
|
||||||
|
|
||||||
|
@source "../../../../internal/view/component/footer/footer.templ";
|
||||||
|
@source "../../../../internal/view/component/nav/navbarleft.templ";
|
||||||
|
@source "../../../../internal/view/component/nav/navbarright.templ";
|
||||||
|
@source "../../../../internal/view/component/nav/navbar.templ";
|
||||||
|
@source "../../../../internal/view/component/nav/sidenav.templ";
|
||||||
|
@source "../../../../internal/view/component/popup/error500Popup.templ";
|
||||||
|
@source "../../../../internal/view/component/popup/error503Popup.templ";
|
||||||
|
@source "../../../../internal/view/layout/global.templ";
|
||||||
|
@source "../../../../internal/view/page/error.templ";
|
||||||
|
@source "../../../../internal/view/page/index.templ";
|
||||||
|
|
||||||
|
[x-cloak] {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
@theme inline {
|
||||||
|
--color-rosewater: var(--rosewater);
|
||||||
|
--color-flamingo: var(--flamingo);
|
||||||
|
--color-pink: var(--pink);
|
||||||
|
--color-mauve: var(--mauve);
|
||||||
|
--color-red: var(--red);
|
||||||
|
--color-dark-red: var(--dark-red);
|
||||||
|
--color-maroon: var(--maroon);
|
||||||
|
--color-peach: var(--peach);
|
||||||
|
--color-yellow: var(--yellow);
|
||||||
|
--color-green: var(--green);
|
||||||
|
--color-teal: var(--teal);
|
||||||
|
--color-sky: var(--sky);
|
||||||
|
--color-sapphire: var(--sapphire);
|
||||||
|
--color-blue: var(--blue);
|
||||||
|
--color-lavender: var(--lavender);
|
||||||
|
--color-text: var(--text);
|
||||||
|
--color-subtext1: var(--subtext1);
|
||||||
|
--color-subtext0: var(--subtext0);
|
||||||
|
--color-overlay2: var(--overlay2);
|
||||||
|
--color-overlay1: var(--overlay1);
|
||||||
|
--color-overlay0: var(--overlay0);
|
||||||
|
--color-surface2: var(--surface2);
|
||||||
|
--color-surface1: var(--surface1);
|
||||||
|
--color-surface0: var(--surface0);
|
||||||
|
--color-base: var(--base);
|
||||||
|
--color-mantle: var(--mantle);
|
||||||
|
--color-crust: var(--crust);
|
||||||
|
}
|
||||||
|
:root {
|
||||||
|
--rosewater: hsl(11, 59%, 67%);
|
||||||
|
--flamingo: hsl(0, 60%, 67%);
|
||||||
|
--pink: hsl(316, 73%, 69%);
|
||||||
|
--mauve: hsl(266, 85%, 58%);
|
||||||
|
--red: hsl(347, 87%, 44%);
|
||||||
|
--dark-red: hsl(343, 50%, 82%);
|
||||||
|
--maroon: hsl(355, 76%, 59%);
|
||||||
|
--peach: hsl(22, 99%, 52%);
|
||||||
|
--yellow: hsl(35, 77%, 49%);
|
||||||
|
--green: hsl(109, 58%, 40%);
|
||||||
|
--teal: hsl(183, 74%, 35%);
|
||||||
|
--sky: hsl(197, 97%, 46%);
|
||||||
|
--sapphire: hsl(189, 70%, 42%);
|
||||||
|
--blue: hsl(220, 91%, 54%);
|
||||||
|
--lavender: hsl(231, 97%, 72%);
|
||||||
|
--text: hsl(234, 16%, 35%);
|
||||||
|
--subtext1: hsl(233, 13%, 41%);
|
||||||
|
--subtext0: hsl(233, 10%, 47%);
|
||||||
|
--overlay2: hsl(232, 10%, 53%);
|
||||||
|
--overlay1: hsl(231, 10%, 59%);
|
||||||
|
--overlay0: hsl(228, 11%, 65%);
|
||||||
|
--surface2: hsl(227, 12%, 71%);
|
||||||
|
--surface1: hsl(225, 14%, 77%);
|
||||||
|
--surface0: hsl(223, 16%, 83%);
|
||||||
|
--base: hsl(220, 23%, 95%);
|
||||||
|
--mantle: hsl(220, 22%, 92%);
|
||||||
|
--crust: hsl(220, 21%, 89%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark {
|
||||||
|
--rosewater: hsl(10, 56%, 91%);
|
||||||
|
--flamingo: hsl(0, 59%, 88%);
|
||||||
|
--pink: hsl(316, 72%, 86%);
|
||||||
|
--mauve: hsl(267, 84%, 81%);
|
||||||
|
--red: hsl(343, 81%, 75%);
|
||||||
|
--dark-red: hsl(316, 19%, 27%);
|
||||||
|
--maroon: hsl(350, 65%, 77%);
|
||||||
|
--peach: hsl(23, 92%, 75%);
|
||||||
|
--yellow: hsl(41, 86%, 83%);
|
||||||
|
--green: hsl(115, 54%, 76%);
|
||||||
|
--teal: hsl(170, 57%, 73%);
|
||||||
|
--sky: hsl(189, 71%, 73%);
|
||||||
|
--sapphire: hsl(199, 76%, 69%);
|
||||||
|
--blue: hsl(217, 92%, 76%);
|
||||||
|
--lavender: hsl(232, 97%, 85%);
|
||||||
|
--text: hsl(226, 64%, 88%);
|
||||||
|
--subtext1: hsl(227, 35%, 80%);
|
||||||
|
--subtext0: hsl(228, 24%, 72%);
|
||||||
|
--overlay2: hsl(228, 17%, 64%);
|
||||||
|
--overlay1: hsl(230, 13%, 55%);
|
||||||
|
--overlay0: hsl(231, 11%, 47%);
|
||||||
|
--surface2: hsl(233, 12%, 39%);
|
||||||
|
--surface1: hsl(234, 13%, 31%);
|
||||||
|
--surface0: hsl(237, 16%, 23%);
|
||||||
|
--base: hsl(240, 21%, 15%);
|
||||||
|
--mantle: hsl(240, 21%, 12%);
|
||||||
|
--crust: hsl(240, 23%, 9%);
|
||||||
|
}
|
||||||
|
.ubuntu-mono-regular {
|
||||||
|
font-family: "Ubuntu Mono", serif;
|
||||||
|
font-weight: 400;
|
||||||
|
font-style: normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ubuntu-mono-bold {
|
||||||
|
font-family: "Ubuntu Mono", serif;
|
||||||
|
font-weight: 700;
|
||||||
|
font-style: normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ubuntu-mono-regular-italic {
|
||||||
|
font-family: "Ubuntu Mono", serif;
|
||||||
|
font-weight: 400;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ubuntu-mono-bold-italic {
|
||||||
|
font-family: "Ubuntu Mono", serif;
|
||||||
|
font-weight: 700;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
1040
pkg/embedfs/files/css/output.css
Normal file
1040
pkg/embedfs/files/css/output.css
Normal file
File diff suppressed because it is too large
Load Diff
BIN
pkg/embedfs/files/favicon.ico
Normal file
BIN
pkg/embedfs/files/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 834 B |
0
pkg/embedfs/files/js/popups.js
Normal file
0
pkg/embedfs/files/js/popups.js
Normal file
13
pkg/embedfs/files/js/theme.js
Normal file
13
pkg/embedfs/files/js/theme.js
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
(function() {
|
||||||
|
let theme = localStorage.getItem("theme") || "system";
|
||||||
|
if (theme === "system") {
|
||||||
|
theme = window.matchMedia("(prefers-color-scheme: dark)").matches
|
||||||
|
? "dark"
|
||||||
|
: "light";
|
||||||
|
}
|
||||||
|
if (theme === "dark") {
|
||||||
|
document.documentElement.classList.add("dark");
|
||||||
|
} else {
|
||||||
|
document.documentElement.classList.remove("dark");
|
||||||
|
}
|
||||||
|
})();
|
||||||
32
scripts/README.md
Normal file
32
scripts/README.md
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
# Scripts
|
||||||
|
|
||||||
|
## generate-css-sources.sh
|
||||||
|
|
||||||
|
Automatically generates the `pkg/embedfs/files/css/input.css` file with `@source` directives for all `.templ` files in the project.
|
||||||
|
|
||||||
|
### Why is this needed?
|
||||||
|
|
||||||
|
Tailwind CSS v4 requires explicit `@source` directives to know which files to scan for utility classes. Glob patterns like `**/*.templ` don't work in `@source` directives, so each file must be listed individually.
|
||||||
|
|
||||||
|
This script:
|
||||||
|
1. Finds all `.templ` files in the `internal/` directory
|
||||||
|
2. Generates `@source` directives with relative paths from the CSS file location
|
||||||
|
3. Adds your custom theme and utility classes
|
||||||
|
|
||||||
|
### When does it run?
|
||||||
|
|
||||||
|
The script runs automatically as part of:
|
||||||
|
- `make build` - Before building the CSS
|
||||||
|
- `make dev` - Before starting watch mode
|
||||||
|
|
||||||
|
### Manual usage
|
||||||
|
|
||||||
|
If you need to regenerate the sources manually:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./scripts/generate-css-sources.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
### Adding new template files
|
||||||
|
|
||||||
|
When you add a new `.templ` file, you don't need to do anything special - just run `make build` or `make dev` and the script will automatically pick up the new file.
|
||||||
140
scripts/generate-css-sources.sh
Executable file
140
scripts/generate-css-sources.sh
Executable file
@@ -0,0 +1,140 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
# Generate @source directives for all .templ files
|
||||||
|
# Paths are relative to pkg/embedfs/files/css/input.css
|
||||||
|
|
||||||
|
INPUT_CSS="pkg/embedfs/files/css/input.css"
|
||||||
|
|
||||||
|
# Start with the base imports
|
||||||
|
cat > "$INPUT_CSS" <<'CSSHEAD'
|
||||||
|
@import "tailwindcss";
|
||||||
|
|
||||||
|
CSSHEAD
|
||||||
|
|
||||||
|
# Find all .templ files and add @source directives
|
||||||
|
find internal -name "*.templ" -type f | sort | while read -r file; do
|
||||||
|
# Convert to relative path from pkg/embedfs/files/css/
|
||||||
|
rel_path="../../../../$file"
|
||||||
|
echo "@source \"$rel_path\";" >> "$INPUT_CSS"
|
||||||
|
done
|
||||||
|
|
||||||
|
# Add the custom theme and utility classes
|
||||||
|
cat >> "$INPUT_CSS" <<'CSSBODY'
|
||||||
|
|
||||||
|
[x-cloak] {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
@theme inline {
|
||||||
|
--color-rosewater: var(--rosewater);
|
||||||
|
--color-flamingo: var(--flamingo);
|
||||||
|
--color-pink: var(--pink);
|
||||||
|
--color-mauve: var(--mauve);
|
||||||
|
--color-red: var(--red);
|
||||||
|
--color-dark-red: var(--dark-red);
|
||||||
|
--color-maroon: var(--maroon);
|
||||||
|
--color-peach: var(--peach);
|
||||||
|
--color-yellow: var(--yellow);
|
||||||
|
--color-green: var(--green);
|
||||||
|
--color-teal: var(--teal);
|
||||||
|
--color-sky: var(--sky);
|
||||||
|
--color-sapphire: var(--sapphire);
|
||||||
|
--color-blue: var(--blue);
|
||||||
|
--color-lavender: var(--lavender);
|
||||||
|
--color-text: var(--text);
|
||||||
|
--color-subtext1: var(--subtext1);
|
||||||
|
--color-subtext0: var(--subtext0);
|
||||||
|
--color-overlay2: var(--overlay2);
|
||||||
|
--color-overlay1: var(--overlay1);
|
||||||
|
--color-overlay0: var(--overlay0);
|
||||||
|
--color-surface2: var(--surface2);
|
||||||
|
--color-surface1: var(--surface1);
|
||||||
|
--color-surface0: var(--surface0);
|
||||||
|
--color-base: var(--base);
|
||||||
|
--color-mantle: var(--mantle);
|
||||||
|
--color-crust: var(--crust);
|
||||||
|
}
|
||||||
|
:root {
|
||||||
|
--rosewater: hsl(11, 59%, 67%);
|
||||||
|
--flamingo: hsl(0, 60%, 67%);
|
||||||
|
--pink: hsl(316, 73%, 69%);
|
||||||
|
--mauve: hsl(266, 85%, 58%);
|
||||||
|
--red: hsl(347, 87%, 44%);
|
||||||
|
--dark-red: hsl(343, 50%, 82%);
|
||||||
|
--maroon: hsl(355, 76%, 59%);
|
||||||
|
--peach: hsl(22, 99%, 52%);
|
||||||
|
--yellow: hsl(35, 77%, 49%);
|
||||||
|
--green: hsl(109, 58%, 40%);
|
||||||
|
--teal: hsl(183, 74%, 35%);
|
||||||
|
--sky: hsl(197, 97%, 46%);
|
||||||
|
--sapphire: hsl(189, 70%, 42%);
|
||||||
|
--blue: hsl(220, 91%, 54%);
|
||||||
|
--lavender: hsl(231, 97%, 72%);
|
||||||
|
--text: hsl(234, 16%, 35%);
|
||||||
|
--subtext1: hsl(233, 13%, 41%);
|
||||||
|
--subtext0: hsl(233, 10%, 47%);
|
||||||
|
--overlay2: hsl(232, 10%, 53%);
|
||||||
|
--overlay1: hsl(231, 10%, 59%);
|
||||||
|
--overlay0: hsl(228, 11%, 65%);
|
||||||
|
--surface2: hsl(227, 12%, 71%);
|
||||||
|
--surface1: hsl(225, 14%, 77%);
|
||||||
|
--surface0: hsl(223, 16%, 83%);
|
||||||
|
--base: hsl(220, 23%, 95%);
|
||||||
|
--mantle: hsl(220, 22%, 92%);
|
||||||
|
--crust: hsl(220, 21%, 89%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark {
|
||||||
|
--rosewater: hsl(10, 56%, 91%);
|
||||||
|
--flamingo: hsl(0, 59%, 88%);
|
||||||
|
--pink: hsl(316, 72%, 86%);
|
||||||
|
--mauve: hsl(267, 84%, 81%);
|
||||||
|
--red: hsl(343, 81%, 75%);
|
||||||
|
--dark-red: hsl(316, 19%, 27%);
|
||||||
|
--maroon: hsl(350, 65%, 77%);
|
||||||
|
--peach: hsl(23, 92%, 75%);
|
||||||
|
--yellow: hsl(41, 86%, 83%);
|
||||||
|
--green: hsl(115, 54%, 76%);
|
||||||
|
--teal: hsl(170, 57%, 73%);
|
||||||
|
--sky: hsl(189, 71%, 73%);
|
||||||
|
--sapphire: hsl(199, 76%, 69%);
|
||||||
|
--blue: hsl(217, 92%, 76%);
|
||||||
|
--lavender: hsl(232, 97%, 85%);
|
||||||
|
--text: hsl(226, 64%, 88%);
|
||||||
|
--subtext1: hsl(227, 35%, 80%);
|
||||||
|
--subtext0: hsl(228, 24%, 72%);
|
||||||
|
--overlay2: hsl(228, 17%, 64%);
|
||||||
|
--overlay1: hsl(230, 13%, 55%);
|
||||||
|
--overlay0: hsl(231, 11%, 47%);
|
||||||
|
--surface2: hsl(233, 12%, 39%);
|
||||||
|
--surface1: hsl(234, 13%, 31%);
|
||||||
|
--surface0: hsl(237, 16%, 23%);
|
||||||
|
--base: hsl(240, 21%, 15%);
|
||||||
|
--mantle: hsl(240, 21%, 12%);
|
||||||
|
--crust: hsl(240, 23%, 9%);
|
||||||
|
}
|
||||||
|
.ubuntu-mono-regular {
|
||||||
|
font-family: "Ubuntu Mono", serif;
|
||||||
|
font-weight: 400;
|
||||||
|
font-style: normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ubuntu-mono-bold {
|
||||||
|
font-family: "Ubuntu Mono", serif;
|
||||||
|
font-weight: 700;
|
||||||
|
font-style: normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ubuntu-mono-regular-italic {
|
||||||
|
font-family: "Ubuntu Mono", serif;
|
||||||
|
font-weight: 400;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ubuntu-mono-bold-italic {
|
||||||
|
font-family: "Ubuntu Mono", serif;
|
||||||
|
font-weight: 700;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
CSSBODY
|
||||||
|
|
||||||
|
echo "Generated $INPUT_CSS with $(grep -c '@source' "$INPUT_CSS") source files"
|
||||||
Reference in New Issue
Block a user