refactor(cmd): add cobra cli libary (#789)

This commit is contained in:
Gabe Cook
2026-04-24 02:14:33 -05:00
committed by GitHub
parent f9b5d6f6bd
commit b6b6ac2449
23 changed files with 829 additions and 552 deletions
+2 -2
View File
@@ -45,12 +45,12 @@ The core functionality involves serving WebP images to devices, generated by ren
1. **Dependencies:** Ensure Go 1.26+ and C build tools (gcc) are installed.
2. **Run with `go run` (for development):**
```bash
go run ./cmd/server -db data/tronbyt.db -data .
go run ./cmd/server --db data/tronbyt.db --data .
```
3. **Build and Run (for production/deployment):**
```bash
go build -o tronbyt-server ./cmd/server
./tronbyt-server -db data/tronbyt.db -data .
./tronbyt-server --db data/tronbyt.db --data .
```
### Testing
+12 -6
View File
@@ -40,9 +40,17 @@ ARG BUILD_DATE=unknown
# Build all Go binaries in a single layer
RUN --mount=type=cache,target=/go/pkg/mod \
--mount=type=cache,target=/root/.cache/go-build \
CGO_ENABLED=0 xx-go build -ldflags="-w -s -extldflags '-static'" -o boot ./cmd/boot && \
CGO_ENABLED=1 xx-go build -ldflags="-w -s -extldflags '-static' -X 'tronbyt-server/internal/version.Version=${VERSION}' -X 'tronbyt-server/internal/version.Commit=${COMMIT}' -X 'tronbyt-server/internal/version.BuildDate=${BUILD_DATE}'" -tags gzip_fonts -o tronbyt-server ./cmd/server && \
CGO_ENABLED=1 xx-go build -ldflags="-w -s -extldflags '-static'" -o migrate ./cmd/migrate
set -x \
&& CGO_ENABLED=1 xx-go build \
-ldflags="-w -s -extldflags '-static' -X 'tronbyt-server/internal/version.Version=${VERSION}' -X 'tronbyt-server/internal/version.Commit=${COMMIT}' -X 'tronbyt-server/internal/version.BuildDate=${BUILD_DATE}'" \
-tags gzip_fonts \
-o build/app/tronbyt-server \
./cmd/server
WORKDIR /app/build
RUN ln -s /app/tronbyt-server boot \
&& ln -s /app/tronbyt-server app/migrate
# --- Runtime Stage ---
FROM scratch
@@ -53,9 +61,7 @@ WORKDIR /app
COPY --from=build-production /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ca-certificates.crt
# Copy compiled binaries from builder
COPY --from=build-production /app/boot /boot
COPY --from=build-production /app/tronbyt-server /app/tronbyt-server
COPY --from=build-production /app/migrate /app/migrate
COPY --from=build-production /app/build /
# Expose port
EXPOSE 8000
-3
View File
@@ -46,7 +46,4 @@ if ($LASTEXITCODE -ne 0) {
exit 1
}
# 5. Build 'migrate' (Database tool)
go build -ldflags="-w -s" -o migrate.exe ./cmd/migrate
Write-Host "Build Complete!" -ForegroundColor Green
-24
View File
@@ -1,24 +0,0 @@
//go:build !windows
package main
import (
"log/slog"
"os"
"syscall"
)
func dropPrivileges(uid, gid int) {
if err := syscall.Setgroups([]int{gid}); err != nil {
slog.Error("Failed to set groups", "error", err)
os.Exit(1)
}
if err := syscall.Setgid(gid); err != nil {
slog.Error("Failed to set gid", "error", err)
os.Exit(1)
}
if err := syscall.Setuid(uid); err != nil {
slog.Error("Failed to set uid", "error", err)
os.Exit(1)
}
}
-24
View File
@@ -1,24 +0,0 @@
package main
import (
"flag"
"log/slog"
"os"
"tronbyt-server/internal/migration"
)
func main() {
oldDBPath := flag.String("old", "users/tronbyt.db", "Path to legacy SQLite database")
newDBPath := flag.String("new", "data/tronbyt.db", "Path to new GORM database (or DSN)")
dataDir := flag.String("data", "data", "Path to data directory for files")
flag.Parse()
logger := slog.New(slog.NewTextHandler(os.Stdout, nil))
slog.SetDefault(logger)
if err := migration.MigrateLegacyDB(*oldDBPath, *newDBPath, *dataDir); err != nil {
slog.Error("Database migration failed", "error", err)
os.Exit(1)
}
}
+32 -14
View File
@@ -1,17 +1,34 @@
package main
package boot
import (
"errors"
"fmt"
"log/slog"
"os"
"os/exec"
"path/filepath"
"strconv"
"syscall"
"github.com/spf13/cobra"
)
func main() {
uid := 1000
gid := 1000
const Name = "boot"
func New() *cobra.Command {
cmd := &cobra.Command{
Use: Name,
Short: "Boot wrapper",
RunE: run,
SilenceUsage: true,
}
cmd.Hidden = true
return cmd
}
func run(_ *cobra.Command, args []string) error {
uid, gid := 1000, 1000
if s := os.Getenv("PUID"); s != "" {
if i, err := strconv.Atoi(s); err != nil {
@@ -20,6 +37,7 @@ func main() {
uid = i
}
}
if s := os.Getenv("PGID"); s != "" {
if i, err := strconv.Atoi(s); err != nil {
slog.Warn("Invalid PGID value, using default", "pgid", s, "error", err)
@@ -70,25 +88,25 @@ func main() {
}
}
dropPrivileges(uid, gid)
if err := dropPrivileges(uid, gid); err != nil {
return fmt.Errorf("failed to drop privileges: %w", err)
}
}
if len(os.Args) < 2 {
slog.Error("No command provided to boot wrapper")
os.Exit(1)
if len(args) == 0 {
return errors.New("no command provided to boot wrapper")
}
cmdName := os.Args[1]
cmdArgs := os.Args[1:]
cmdName := args[0]
cmdArgs := args[0:]
cmdPath, err := exec.LookPath(cmdName)
if err != nil {
slog.Error("Command not found", "cmd", cmdName, "error", err)
os.Exit(1)
return fmt.Errorf("%s: command not found: %w", cmdName, err)
}
if err := syscall.Exec(cmdPath, cmdArgs, os.Environ()); err != nil {
slog.Error("Failed to exec command", "cmd", cmdName, "error", err)
os.Exit(1)
return fmt.Errorf("%s: exec: %w", cmdName, err)
}
return nil
}
+21
View File
@@ -0,0 +1,21 @@
//go:build !windows
package boot
import (
"fmt"
"syscall"
)
func dropPrivileges(uid, gid int) error {
if err := syscall.Setgroups([]int{gid}); err != nil {
return fmt.Errorf("failed to set groups: %w", err)
}
if err := syscall.Setgid(gid); err != nil {
return fmt.Errorf("failed to set gid: %w", err)
}
if err := syscall.Setuid(uid); err != nil {
return fmt.Errorf("failed to set uid: %w", err)
}
return nil
}
@@ -1,7 +1,8 @@
//go:build windows
package main
package boot
func dropPrivileges(uid, gid int) {
func dropPrivileges(uid, gid int) error {
// No-op on Windows - privilege management works differently
return nil
}
+84
View File
@@ -0,0 +1,84 @@
package main
import (
"fmt"
"log/slog"
"strings"
"tronbyt-server/cmd/server/boot"
"tronbyt-server/cmd/server/health"
"tronbyt-server/cmd/server/migrate"
"tronbyt-server/cmd/server/resetpassword"
"tronbyt-server/cmd/server/serve"
"tronbyt-server/internal/config"
"github.com/spf13/cobra"
)
func New() *cobra.Command {
cobra.MousetrapHelpText = ""
cmd := &cobra.Command{
Use: "tronbyt-server",
Short: "Manage your apps on your Tronbyt completely locally",
PersistentPreRunE: preRun,
}
fs := cmd.PersistentFlags()
fs.String(config.FlagDB, "data/tronbyt.db", "Database DSN (sqlite file path or connection string)")
fs.String(config.FlagData, "data", "Path to data directory")
cmd.AddCommand(
boot.New(),
serve.New(),
migrate.New(),
resetpassword.New(),
health.New(),
)
return cmd
}
func preRun(cmd *cobra.Command, _ []string) error {
// Initialize slog before anything else that might log
slog.SetDefault(slog.New(slog.NewTextHandler(cmd.ErrOrStderr(), nil)))
// Load configuration early to get default DB path
cfg, err := config.LoadSettings()
if err != nil {
return fmt.Errorf("failed to load settings: %w", err)
}
fs := cmd.Flags()
if f := fs.Lookup(config.FlagDB); f != nil && f.Changed {
cfg.DBDSN = f.Value.String()
}
if f := fs.Lookup(config.FlagData); f != nil && f.Changed {
cfg.DataDir = f.Value.String()
}
// Re-initialize logger with configured log level
var level slog.Level
cfg.LogLevel = strings.ToUpper(cfg.LogLevel)
cfg.LogLevel = strings.Replace(cfg.LogLevel, "WARNING", "WARN", 1)
if err := level.UnmarshalText([]byte(cfg.LogLevel)); err != nil {
slog.Warn("Invalid LOG_LEVEL, defaulting to INFO", "level", cfg.LogLevel)
level = slog.LevelInfo
}
// Create handler options with the parsed level
loggerHandlerOpts := &slog.HandlerOptions{
Level: level,
}
var logHandler slog.Handler
if cfg.LogFormat == "json" {
logHandler = slog.NewJSONHandler(cmd.ErrOrStderr(), loggerHandlerOpts)
} else {
logHandler = slog.NewTextHandler(cmd.ErrOrStderr(), loggerHandlerOpts)
}
slog.SetDefault(slog.New(logHandler))
slog.Debug("Logger initialized", "level", level)
cmd.SetContext(config.NewContext(cmd.Context(), cfg))
return nil
}
+55
View File
@@ -0,0 +1,55 @@
package health
import (
"context"
"fmt"
"io"
"net/http"
"github.com/spf13/cobra"
)
func New() *cobra.Command {
cmd := &cobra.Command{
Use: "health [url]",
Short: "Perform a health check",
RunE: run,
SilenceUsage: true,
}
return cmd
}
func run(cmd *cobra.Command, args []string) error {
url := "http://localhost:8000/health"
if len(args) > 0 {
url = args[0]
}
if err := runHealthCheck(cmd.Context(), url); err != nil {
return fmt.Errorf("health check failed: %w", err)
}
return nil
}
func runHealthCheck(ctx context.Context, url string) error {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return fmt.Errorf("failed to create request: %w", err)
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
return fmt.Errorf("failed to perform request: %w", err)
}
defer func() {
_, _ = io.Copy(io.Discard, resp.Body)
_ = resp.Body.Close()
}()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("status code: %d", resp.StatusCode)
}
return nil
}
+21 -477
View File
@@ -1,496 +1,40 @@
package main
import (
"context"
"crypto/tls"
"flag"
"fmt"
"log/slog"
"net"
"net/http"
"net/url"
"os"
"path/filepath"
"strings"
"time"
_ "time/tzdata"
"tronbyt-server/cmd/server/boot"
"tronbyt-server/cmd/server/migrate"
"tronbyt-server/cmd/server/serve"
"tronbyt-server/internal/auth"
"tronbyt-server/internal/config"
"tronbyt-server/internal/data"
"tronbyt-server/internal/gitutils"
"tronbyt-server/internal/migration"
"tronbyt-server/internal/server"
"github.com/quic-go/quic-go/http3"
"github.com/tronbyt/pixlet/runtime"
"gorm.io/driver/mysql"
"gorm.io/driver/postgres"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
"gorm.io/gorm/logger"
"gorm.io/plugin/prometheus"
"github.com/spf13/cobra"
"github.com/spf13/pflag"
)
func runHealthCheck(url string) error {
resp, err := http.Get(url)
if err != nil {
return err
}
defer func() {
if err := resp.Body.Close(); err != nil {
slog.Error("Failed to close health check response body", "error", err)
}
}()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("status code: %d", resp.StatusCode)
}
return nil
}
func openDB(dsn, logLevel string) (*gorm.DB, error) {
var db *gorm.DB
var err error
var gormLogLevel logger.LogLevel
switch strings.ToUpper(logLevel) {
case "DEBUG":
gormLogLevel = logger.Info // GORM Info includes SQL queries
case "INFO":
gormLogLevel = logger.Warn
case "WARN", "WARNING":
gormLogLevel = logger.Error
case "ERROR":
gormLogLevel = logger.Error
default:
gormLogLevel = logger.Warn
}
gormConfig := &gorm.Config{
Logger: data.NewGORMSlogLogger(gormLogLevel, 200*time.Millisecond, true),
PrepareStmt: false, // Disable prepared statement caching to avoid SQLite locking issues
}
if strings.HasPrefix(dsn, "postgres") || strings.Contains(dsn, "host=") {
slog.Info("Using Postgres DB")
db, err = gorm.Open(postgres.Open(dsn), gormConfig)
} else if strings.Contains(dsn, "@tcp(") || strings.Contains(dsn, "@unix(") {
slog.Info("Using MySQL DB")
db, err = gorm.Open(mysql.Open(dsn), gormConfig)
} else {
slog.Info("Using SQLite DB", "path", dsn)
db, err = gorm.Open(sqlite.Open(dsn), gormConfig)
if err == nil {
if err := db.Exec("PRAGMA journal_mode=WAL;").Error; err != nil {
slog.Warn("Failed to set WAL mode for SQLite", "error", err)
}
if err := db.Exec("PRAGMA busy_timeout=5000;").Error; err != nil {
slog.Warn("Failed to set busy timeout for SQLite", "error", err)
}
}
}
if err == nil {
if err := db.Use(prometheus.New(prometheus.Config{
DBName: "tronbyt",
RefreshInterval: 15,
StartServer: false,
HTTPServerPort: 8080,
})); err != nil {
slog.Warn("Failed to register GORM prometheus plugin", "error", err)
}
}
return db, err
}
func resetPassword(dsn, username, password string) error {
db, err := openDB(dsn, "INFO")
if err != nil {
return err
}
hashedPassword, err := auth.HashPassword(password)
if err != nil {
return fmt.Errorf("failed to hash password: %w", err)
}
rowsAffected, err := gorm.G[data.User](db).Where("username = ?", username).Update(context.Background(), "password", hashedPassword)
if err != nil {
return err
}
if rowsAffected == 0 {
return fmt.Errorf("user not found")
}
return nil
}
func sanitizeDB(db *gorm.DB) {
// Sanitize data before migration (fixes v2.0.x empty email constraint issue)
if db.Migrator().HasTable(&data.User{}) {
if _, err := gorm.G[data.User](db).Where("email IN ?", []string{"", "none"}).Update(context.Background(), "email", nil); err != nil {
slog.Warn("Failed to sanitize empty emails", "error", err)
}
}
// Fix timezone issues
if db.Migrator().HasTable(&data.Device{}) {
devices, err := gorm.G[data.Device](db).Where("location LIKE '%\"timezone\":\"None\"'").Find(context.Background())
if err != nil {
slog.Warn("Failed to get devices with illegal timestamps", "error", err)
} else {
for _, device := range devices {
device.Location.Timezone = ""
if _, err := gorm.G[data.Device](db).Where("id = ?", device.ID).Update(context.Background(), "location", device.Location); err != nil {
slog.Warn("Failed to update device location during sanitization", "device_id", device.ID, "error", err)
}
}
}
}
}
func main() {
// Initialize slog before anything else that might log
logger := slog.New(slog.NewTextHandler(os.Stdout, nil))
slog.SetDefault(logger)
// Load configuration early to get default DB path
cfg, err := config.LoadSettings()
if err != nil {
slog.Error("Failed to load settings", "error", err)
os.Exit(1)
}
// Re-initialize logger with configured log level
var level slog.Level
switch strings.ToUpper(cfg.LogLevel) {
case "DEBUG":
level = slog.LevelDebug
case "INFO":
level = slog.LevelInfo
case "WARN", "WARNING":
level = slog.LevelWarn
case "ERROR":
level = slog.LevelError
// Determine the root command
var root *cobra.Command
switch filepath.Base(os.Args[0]) {
case boot.Name:
root = boot.New()
case migrate.Name:
root = migrate.New()
root.PreRunE = preRun
default:
level = slog.LevelInfo
slog.Warn("Invalid LOG_LEVEL, defaulting to INFO", "level", cfg.LogLevel)
}
// Create handler options with the parsed level
loggerHandlerOpts := &slog.HandlerOptions{
Level: level,
}
var logHandler slog.Handler
if cfg.LogFormat == "json" {
logHandler = slog.NewJSONHandler(os.Stdout, loggerHandlerOpts)
} else {
logHandler = slog.NewTextHandler(os.Stdout, loggerHandlerOpts)
}
logger = slog.New(logHandler)
slog.SetDefault(logger)
slog.Debug("Logger initialized", "level", level)
dbDSN := flag.String("db", cfg.DBDSN, "Database DSN (sqlite file path or connection string)")
dataDir := flag.String("data", cfg.DataDir, "Path to data directory")
flag.Parse()
// Update config with flag values
cfg.DataDir = *dataDir
cfg.DBDSN = *dbDSN
if len(flag.Args()) > 0 {
cmd := flag.Arg(0)
switch cmd {
case "health":
url := "http://localhost:8000/health"
if len(flag.Args()) > 1 {
url = flag.Arg(1)
}
if err := runHealthCheck(url); err != nil {
fmt.Fprintf(os.Stderr, "Health check failed: %v\n", err)
os.Exit(1)
}
os.Exit(0)
case "reset-password":
if len(flag.Args()) < 3 {
fmt.Println("Usage: tronbyt-server reset-password <username> <new_password>")
os.Exit(1)
}
username := flag.Arg(1)
password := flag.Arg(2)
if err := resetPassword(*dbDSN, username, password); err != nil {
slog.Error("Failed to reset password", "error", err)
os.Exit(1)
}
slog.Info("Password reset successfully")
os.Exit(0)
root = New()
subCmd, _, err := root.Find(os.Args[1:])
// Run serve if no command is given
if err == nil && subCmd.Use == root.Use && subCmd.Flags().Parse(os.Args[1:]) != pflag.ErrHelp {
root.SetArgs(append([]string{serve.Name}, os.Args[1:]...))
}
}
if err := os.MkdirAll(cfg.DataDir, 0755); err != nil {
slog.Error("Failed to create data directory", "error", err)
os.Exit(1)
}
root.SilenceErrors = true
// Check for legacy DB for automatic migration
legacyDBPath := filepath.Join("users", "usersdb.sqlite") // Old Python DB path
if _, err := os.Stat(legacyDBPath); err == nil && legacyDBPath != *dbDSN {
slog.Info("Found legacy database, checking if migration is needed", "legacy_db", legacyDBPath, "new_db", *dbDSN)
skipMigration := false
tempDB, err := openDB(*dbDSN, "ERROR")
if err == nil {
if tempDB.Migrator().HasTable(&data.User{}) {
count, err := gorm.G[data.User](tempDB).Count(context.Background(), "*")
if err == nil && count > 0 {
skipMigration = true
slog.Warn("New database already has users, skipping automatic migration.", "new_db", *dbDSN)
}
}
if sqlDB, err := tempDB.DB(); err == nil {
if err := sqlDB.Close(); err != nil {
slog.Error("Failed to close temporary DB connection", "error", err)
}
}
}
if !skipMigration {
// Perform migration
if err := migration.MigrateLegacyDB(legacyDBPath, *dbDSN, *dataDir); err != nil {
slog.Error("Automatic migration failed", "error", err)
os.Exit(1)
}
slog.Info("Automatic migration completed successfully. Renaming legacy DB.", "legacy_db", legacyDBPath)
if err := os.Rename(legacyDBPath, legacyDBPath+".bak"); err != nil {
slog.Error("Failed to rename legacy DB after migration", "error", err)
}
}
}
// Clone/Update System Apps Repo
systemAppsDir := filepath.Join(*dataDir, "system-apps")
shouldUpdate := cfg.Production
if err := gitutils.EnsureRepo(systemAppsDir, cfg.SystemAppsRepo, cfg.GitHubToken, shouldUpdate); err != nil {
slog.Error("Failed to update system apps repo", "error", err)
// Continue anyway
}
// Open DB
db, err := openDB(*dbDSN, cfg.LogLevel)
if err != nil {
slog.Error("Failed to open database", "error", err)
os.Exit(1)
}
// Sanitize data
sanitizeDB(db)
// AutoMigrate (ensure schema exists)
if err := db.AutoMigrate(&data.User{}, &data.Device{}, &data.App{}, &data.WebAuthnCredential{}, &data.Setting{}); err != nil {
slog.Error("Failed to migrate schema", "error", err)
os.Exit(1)
}
// Initialize Pixlet Cache
var cache runtime.Cache
if cfg.RedisURL != "" {
slog.Info("Initializing Pixlet Redis cache", "url", cfg.RedisURL)
ctx, cancel := context.WithTimeout(context.Background(), time.Minute)
var err error
cache, err = runtime.NewRedisCache(ctx, cfg.RedisURL)
if err != nil {
slog.Error("Failed to connect to Redis", "error", err)
os.Exit(1)
}
cancel()
} else {
slog.Info("Initializing Pixlet in-memory cache")
cache = runtime.NewInMemoryCache()
}
defer cache.Close()
runtime.InitHTTP(cache)
runtime.InitCache(cache)
srv := server.NewServer(db, cfg)
// Firmware Update (production only)
if cfg.Production {
go func() {
defer func() {
if r := recover(); r != nil {
slog.Error("Panic during background firmware update", "panic", r)
}
}()
if err := srv.UpdateFirmwareBinaries(); err != nil {
slog.Error("Failed to update firmware binaries in background", "error", err)
}
}()
} else {
slog.Info("Skipping firmware update (dev mode)")
}
// Single User Auto-Login Warning
userCount, err := gorm.G[data.User](db).Count(context.Background(), "*")
if err != nil {
slog.Error("Failed to count users for auto-login warning", "error", err)
} else if cfg.SingleUserAutoLogin && userCount == 1 {
slog.Warn(`
======================================================================
⚠️ SINGLE-USER AUTO-LOGIN MODE IS ENABLED
======================================================================
Authentication is DISABLED for private network connections!
This mode automatically logs in the single user without password.
SECURITY REQUIREMENTS:
✓ Only works when exactly 1 user exists
✓ Only works from trusted networks:
- Localhost (127.0.0.1, ::1)
- Private IPv4 networks (192.168.x.x, 10.x.x.x, 172.16.x.x)
- IPv6 local ranges (Unique Local Addresses fc00::/7, commonly fd00::/8)
- IPv6 link-local (fe80::/10)
✓ Public IP connections still require authentication
To disable: Set SINGLE_USER_AUTO_LOGIN=0 in your .env file
======================================================================`)
}
// Configure Listeners
var listeners []net.Listener
// TCP
if cfg.Host != "" || cfg.Port != "" {
addr := net.JoinHostPort(cfg.Host, cfg.Port)
l, err := net.Listen("tcp", addr)
if err != nil {
slog.Error("Failed to listen on TCP", "addr", addr, "error", err)
os.Exit(1)
}
listeners = append(listeners, l)
slog.Info("Listening on TCP", "addr", addr)
}
// Unix Socket
if cfg.UnixSocket != "" {
if err := os.RemoveAll(cfg.UnixSocket); err != nil {
slog.Warn("Failed to remove old socket", "error", err)
}
l, err := net.Listen("unix", cfg.UnixSocket)
if err != nil {
slog.Error("Failed to listen on Unix socket", "path", cfg.UnixSocket, "error", err)
os.Exit(1)
}
if err := os.Chmod(cfg.UnixSocket, 0666); err != nil {
slog.Warn("Failed to set socket permissions", "error", err)
}
listeners = append(listeners, l)
slog.Info("Listening on Unix socket", "path", cfg.UnixSocket)
}
if len(listeners) == 0 {
slog.Error("No listeners configured")
os.Exit(1)
}
var handler http.Handler = srv
// Determine number of servers to start and create error channel
numServers := len(listeners)
http3Enabled := cfg.SSLCertFile != "" && cfg.SSLKeyFile != "" && (cfg.Host != "" || cfg.Port != "")
if http3Enabled {
numServers++
}
errCh := make(chan error, numServers)
// TLS Config (Shared)
var tlsConfig *tls.Config
if cfg.SSLCertFile != "" && cfg.SSLKeyFile != "" {
cert, err := tls.LoadX509KeyPair(cfg.SSLCertFile, cfg.SSLKeyFile)
if err != nil {
slog.Error("Failed to load TLS certificates", "error", err)
os.Exit(1)
}
tlsConfig = &tls.Config{
Certificates: []tls.Certificate{cert},
NextProtos: []string{"h2", "http/1.1"},
}
slog.Info("TLS certificates loaded", "cert", cfg.SSLCertFile, "key", cfg.SSLKeyFile)
}
// HTTP/3 (QUIC) Support
if http3Enabled {
if tlsConfig == nil {
// This should practically not happen due to http3Enabled check above,
// but good for safety if logic changes.
slog.Error("HTTP/3 enabled but TLS config is nil")
os.Exit(1)
}
addr := net.JoinHostPort(cfg.Host, cfg.Port)
h3Srv := &http3.Server{
Addr: addr,
Handler: srv,
IdleTimeout: 120 * time.Second,
TLSConfig: tlsConfig,
}
go func() {
slog.Info("Serving HTTP/3 (QUIC)", "addr", addr)
err := h3Srv.ListenAndServe()
if err != nil && err != http.ErrServerClosed {
errCh <- fmt.Errorf("HTTP/3 server failed: %w", err)
}
}()
// Wrap handler to set Alt-Svc header for TCP connections
baseHandler := handler
handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Determine the external port to advertise in Alt-Svc.
// Use GetBaseURL to handle X-Forwarded-Host/Proto respecting logic centrally.
baseURL := srv.GetBaseURL(r)
u, err := url.Parse(baseURL)
var port string
if err == nil {
port = u.Port()
}
if port == "" {
// No port in URL, use default based on scheme
// HTTP/3 implies HTTPS, so 443.
port = "443"
}
w.Header().Set("Alt-Svc", fmt.Sprintf(`h3=":%s"; ma=2592000`, port))
baseHandler.ServeHTTP(w, r)
})
}
httpSrv := &http.Server{
Handler: handler,
ReadTimeout: 25 * time.Second,
WriteTimeout: 25 * time.Second,
IdleTimeout: 60 * time.Second,
}
slog.Info("Tronbyt Server starting", "db", *dbDSN, "dataDir", *dataDir)
for _, l := range listeners {
go func(l net.Listener) {
var err error
if tlsConfig != nil {
// Wrap listener with TLS if config is present
l = tls.NewListener(l, tlsConfig)
}
err = httpSrv.Serve(l)
if err != nil && err != http.ErrServerClosed {
errCh <- err
}
}(l)
}
if err := <-errCh; err != nil {
slog.Error("Server failed", "error", err)
if err := root.Execute(); err != nil {
slog.Error(err.Error())
os.Exit(1)
}
}
+48
View File
@@ -0,0 +1,48 @@
package migrate
import (
"fmt"
"tronbyt-server/internal/config"
"tronbyt-server/internal/migration"
"github.com/spf13/cobra"
)
const (
Name = "migrate"
flagOldDBPath = "old"
flagNewDBPath = "new"
)
func New() *cobra.Command {
cmd := &cobra.Command{
Use: Name,
Short: "Migrate legacy SQLite database to GORM database",
RunE: run,
}
fs := cmd.Flags()
fs.String(flagOldDBPath, "users/tronbyt.db", "Path to legacy SQLite database")
fs.String(flagNewDBPath, "", "Path to new GORM database (or DSN)")
_ = fs.MarkHidden(flagNewDBPath)
return cmd
}
func run(cmd *cobra.Command, _ []string) error {
cfg, err := config.FromContext(cmd.Context())
if err != nil {
return fmt.Errorf("failed to load settings: %w", err)
}
oldDBPath := cmd.Flag(flagOldDBPath).Value.String()
if newDBPath := cmd.Flag(flagNewDBPath).Value.String(); newDBPath != "" {
cfg.DBDSN = newDBPath
}
if err := migration.MigrateLegacyDB(oldDBPath, cfg.DBDSN, cfg.DataDir); err != nil {
return fmt.Errorf("database migration failed: %w", err)
}
return nil
}
+63
View File
@@ -0,0 +1,63 @@
package resetpassword
import (
"context"
"fmt"
"log/slog"
"tronbyt-server/internal/auth"
"tronbyt-server/internal/config"
"tronbyt-server/internal/data"
"github.com/spf13/cobra"
"gorm.io/gorm"
)
func New() *cobra.Command {
cmd := &cobra.Command{
Use: "reset-password username new_password",
Short: "Reset a user password",
RunE: run,
Args: cobra.ExactArgs(2),
}
return cmd
}
func run(cmd *cobra.Command, args []string) error {
cfg, err := config.FromContext(cmd.Context())
if err != nil {
return fmt.Errorf("failed to load settings: %w", err)
}
username := args[0]
password := args[1]
if err := resetPassword(cmd.Context(), cfg.DBDSN, username, password); err != nil {
slog.Error("Failed to reset password", "error", err)
return err
}
slog.Info("Password reset successfully")
return nil
}
func resetPassword(ctx context.Context, dsn, username, password string) error {
db, err := data.Open(dsn, "INFO")
if err != nil {
return err
}
hashedPassword, err := auth.HashPassword(password)
if err != nil {
return fmt.Errorf("failed to hash password: %w", err)
}
rowsAffected, err := gorm.G[data.User](db).Where("username = ?", username).Update(ctx, "password", hashedPassword)
if err != nil {
return err
}
if rowsAffected == 0 {
return fmt.Errorf("user not found")
}
return nil
}
+34
View File
@@ -0,0 +1,34 @@
package serve
import (
"context"
"fmt"
"log/slog"
"time"
"github.com/tronbyt/pixlet/runtime"
)
func initCache(redisURL string) (runtime.Cache, error) {
// Initialize Pixlet Cache
var cache runtime.Cache
var err error
if redisURL != "" {
slog.Info("Initializing Pixlet Redis cache", "url", redisURL)
ctx, cancel := context.WithTimeout(context.Background(), time.Minute)
defer cancel()
if cache, err = runtime.NewRedisCache(ctx, redisURL); err != nil {
return nil, fmt.Errorf("failed to connect to Redis: %w", err)
}
} else {
slog.Info("Initializing Pixlet in-memory cache")
cache = runtime.NewInMemoryCache()
}
runtime.InitHTTP(cache)
runtime.InitCache(cache)
return cache, nil
}
+87
View File
@@ -0,0 +1,87 @@
package serve
import (
"fmt"
"log/slog"
"os"
"tronbyt-server/internal/config"
"tronbyt-server/internal/data"
"tronbyt-server/internal/gitutils"
"tronbyt-server/internal/server"
"github.com/spf13/cobra"
)
const Name = "serve"
func New() *cobra.Command {
return &cobra.Command{
Use: Name,
Short: "Run Tronbyt server",
RunE: run,
SilenceUsage: true,
}
}
func run(cmd *cobra.Command, args []string) error {
cfg, err := config.FromContext(cmd.Context())
if err != nil {
return fmt.Errorf("failed to load settings: %w", err)
}
if err := os.MkdirAll(cfg.DataDir, 0755); err != nil {
return fmt.Errorf("failed to create data directory: %w", err)
}
if err := migrateLegacyDB(cmd.Context(), cfg.DBDSN, cfg.DataDir); err != nil {
return fmt.Errorf("failed to migrate legacy database: %w", err)
}
// Clone/Update System Apps Repo
if err := gitutils.EnsureRepo(cfg.SystemAppsDir(), cfg.SystemAppsRepo, cfg.GitHubToken, cfg.Production); err != nil {
slog.Error("Failed to update system apps repo", "error", err)
}
// Open DB
db, err := data.Open(cfg.DBDSN, cfg.LogLevel)
if err != nil {
return fmt.Errorf("failed to open database: %w", err)
}
// Sanitize data
sanitizeDB(cmd.Context(), db)
// AutoMigrate (ensure schema exists)
if err := db.AutoMigrate(&data.User{}, &data.Device{}, &data.App{}, &data.WebAuthnCredential{}, &data.Setting{}); err != nil {
return fmt.Errorf("failed to migrate schema: %w", err)
}
cache, err := initCache(cfg.RedisURL)
if err != nil {
return fmt.Errorf("failed to initialize cache: %w", err)
}
defer cache.Close()
srv := server.NewServer(db, cfg)
// Firmware Update (production only)
if cfg.Production {
go func() {
defer func() {
if r := recover(); r != nil {
slog.Error("Panic during background firmware update", "panic", r)
}
}()
if err := srv.UpdateFirmwareBinaries(); err != nil {
slog.Error("Failed to update firmware binaries in background", "error", err)
}
}()
} else {
slog.Info("Skipping firmware update (dev mode)")
}
singleUserWarning(cmd.Context(), db, cfg.SingleUserAutoLogin)
return serve(cfg, srv)
}
+100
View File
@@ -0,0 +1,100 @@
package serve
import (
"context"
"fmt"
"log/slog"
"os"
"path/filepath"
"tronbyt-server/internal/data"
"tronbyt-server/internal/migration"
"gorm.io/gorm"
)
func migrateLegacyDB(ctx context.Context, dsn, dataDir string) error {
// Check for legacy DB for automatic migration
legacyDBPath := filepath.Join("users", "usersdb.sqlite") // Old Python DB path
if _, err := os.Stat(legacyDBPath); err == nil && legacyDBPath != dsn {
slog.Info("Found legacy database, checking if migration is needed", "legacy_db", legacyDBPath, "new_db", dsn)
tempDB, err := data.Open(dsn, "ERROR")
if err == nil {
if tempDB.Migrator().HasTable(&data.User{}) {
count, err := gorm.G[data.User](tempDB).Count(ctx, "*")
if err == nil && count > 0 {
slog.Warn("New database already has users, skipping automatic migration.", "new_db", dsn)
return nil
}
}
if sqlDB, err := tempDB.DB(); err == nil {
if err := sqlDB.Close(); err != nil {
slog.Error("Failed to close temporary DB connection", "error", err)
}
}
}
// Perform migration
if err := migration.MigrateLegacyDB(legacyDBPath, dsn, dataDir); err != nil {
return fmt.Errorf("automatic migration failed: %w", err)
}
slog.Info("Automatic migration completed successfully. Renaming legacy DB.", "legacy_db", legacyDBPath)
if err := os.Rename(legacyDBPath, legacyDBPath+".bak"); err != nil {
slog.Error("Failed to rename legacy DB after migration", "error", err)
}
}
return nil
}
func sanitizeDB(ctx context.Context, db *gorm.DB) {
// Sanitize data before migration (fixes v2.0.x empty email constraint issue)
if db.Migrator().HasTable(&data.User{}) {
if _, err := gorm.G[data.User](db).Where("email IN ?", []string{"", "none"}).Update(ctx, "email", nil); err != nil {
slog.Warn("Failed to sanitize empty emails", "error", err)
}
}
// Fix timezone issues
if db.Migrator().HasTable(&data.Device{}) {
devices, err := gorm.G[data.Device](db).Where("location LIKE '%\"timezone\":\"None\"'").Find(ctx)
if err != nil {
slog.Warn("Failed to get devices with illegal timestamps", "error", err)
} else {
for _, device := range devices {
device.Location.Timezone = ""
if _, err := gorm.G[data.Device](db).Where("id = ?", device.ID).Update(ctx, "location", device.Location); err != nil {
slog.Warn("Failed to update device location during sanitization", "device_id", device.ID, "error", err)
}
}
}
}
}
func singleUserWarning(ctx context.Context, db *gorm.DB, singleUserAutoLogin bool) {
// Single User Auto-Login Warning
userCount, err := gorm.G[data.User](db).Count(ctx, "*")
if err != nil {
slog.Error("Failed to count users for auto-login warning", "error", err)
} else if singleUserAutoLogin && userCount == 1 {
slog.Warn(`
======================================================================
⚠️ SINGLE-USER AUTO-LOGIN MODE IS ENABLED
======================================================================
Authentication is DISABLED for private network connections!
This mode automatically logs in the single user without password.
SECURITY REQUIREMENTS:
✓ Only works when exactly 1 user exists
✓ Only works from trusted networks:
- Localhost (127.0.0.1, ::1)
- Private IPv4 networks (192.168.x.x, 10.x.x.x, 172.16.x.x)
- IPv6 local ranges (Unique Local Addresses fc00::/7, commonly fd00::/8)
- IPv6 link-local (fe80::/10)
✓ Public IP connections still require authentication
To disable: Set SINGLE_USER_AUTO_LOGIN=0 in your .env file
======================================================================`)
}
}
+150
View File
@@ -0,0 +1,150 @@
package serve
import (
"crypto/tls"
"fmt"
"log/slog"
"net"
"net/http"
"net/url"
"os"
"time"
"tronbyt-server/internal/config"
"tronbyt-server/internal/server"
"github.com/quic-go/quic-go/http3"
)
func serve(cfg *config.Settings, srv *server.Server) error {
// Configure Listeners
var listeners []net.Listener
// TCP
if cfg.Host != "" || cfg.Port != "" {
addr := net.JoinHostPort(cfg.Host, cfg.Port)
l, err := net.Listen("tcp", addr)
if err != nil {
return fmt.Errorf("failed to listen on TCP %s: %w", addr, err)
}
listeners = append(listeners, l)
slog.Info("Listening on TCP", "addr", addr)
}
// Unix Socket
if cfg.UnixSocket != "" {
if err := os.RemoveAll(cfg.UnixSocket); err != nil {
slog.Warn("Failed to remove old socket", "error", err)
}
l, err := net.Listen("unix", cfg.UnixSocket)
if err != nil {
return fmt.Errorf("failed to listen on Unix socket %s: %w", cfg.UnixSocket, err)
}
if err := os.Chmod(cfg.UnixSocket, 0666); err != nil {
slog.Warn("Failed to set socket permissions", "error", err)
}
listeners = append(listeners, l)
slog.Info("Listening on Unix socket", "path", cfg.UnixSocket)
}
if len(listeners) == 0 {
return fmt.Errorf("no listeners configured")
}
var handler http.Handler = srv
// Determine number of servers to start and create error channel
numServers := len(listeners)
http3Enabled := cfg.SSLCertFile != "" && cfg.SSLKeyFile != "" && (cfg.Host != "" || cfg.Port != "")
if http3Enabled {
numServers++
}
errCh := make(chan error, numServers)
// TLS Config (Shared)
var tlsConfig *tls.Config
if cfg.SSLCertFile != "" && cfg.SSLKeyFile != "" {
cert, err := tls.LoadX509KeyPair(cfg.SSLCertFile, cfg.SSLKeyFile)
if err != nil {
return fmt.Errorf("failed to load TLS certificates: %w", err)
}
tlsConfig = &tls.Config{
Certificates: []tls.Certificate{cert},
NextProtos: []string{"h2", "http/1.1"},
}
slog.Info("TLS certificates loaded", "cert", cfg.SSLCertFile, "key", cfg.SSLKeyFile)
}
// HTTP/3 (QUIC) Support
if http3Enabled {
if tlsConfig == nil {
// This should practically not happen due to http3Enabled check above,
// but good for safety if logic changes.
return fmt.Errorf("HTTP/3 enabled but TLS config is nil")
}
addr := net.JoinHostPort(cfg.Host, cfg.Port)
h3Srv := &http3.Server{
Addr: addr,
Handler: srv,
IdleTimeout: 120 * time.Second,
TLSConfig: tlsConfig,
}
go func() {
slog.Info("Serving HTTP/3 (QUIC)", "addr", addr)
err := h3Srv.ListenAndServe()
if err != nil && err != http.ErrServerClosed {
errCh <- fmt.Errorf("HTTP/3 server failed: %w", err)
}
}()
// Wrap handler to set Alt-Svc header for TCP connections
baseHandler := handler
handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Determine the external port to advertise in Alt-Svc.
// Use GetBaseURL to handle X-Forwarded-Host/Proto respecting logic centrally.
baseURL := srv.GetBaseURL(r)
u, err := url.Parse(baseURL)
var port string
if err == nil {
port = u.Port()
}
if port == "" {
// No port in URL, use default based on scheme
// HTTP/3 implies HTTPS, so 443.
port = "443"
}
w.Header().Set("Alt-Svc", fmt.Sprintf(`h3=":%s"; ma=2592000`, port))
baseHandler.ServeHTTP(w, r)
})
}
httpSrv := &http.Server{
Handler: handler,
ReadTimeout: 25 * time.Second,
WriteTimeout: 25 * time.Second,
IdleTimeout: 60 * time.Second,
}
slog.Info("Tronbyt Server starting", "db", cfg.DBDSN, "dataDir", cfg.DataDir)
for _, l := range listeners {
go func(l net.Listener) {
var err error
if tlsConfig != nil {
// Wrap listener with TLS if config is present
l = tls.NewListener(l, tlsConfig)
}
err = httpSrv.Serve(l)
if err != nil && err != http.ErrServerClosed {
errCh <- err
}
}(l)
}
if err := <-errCh; err != nil {
return fmt.Errorf("server failed: %w", err)
}
return nil
}
+3
View File
@@ -15,6 +15,8 @@ require (
github.com/nicksnyder/go-i18n/v2 v2.6.1
github.com/prometheus/client_golang v1.23.2
github.com/quic-go/quic-go v0.59.0
github.com/spf13/cobra v1.10.2
github.com/spf13/pflag v1.0.10
github.com/stretchr/testify v1.11.1
github.com/sumup/aaguids-go v0.0.0-20251118110537-5a71322266f2
github.com/tronbyt/pixlet v0.52.0
@@ -77,6 +79,7 @@ require (
github.com/google/go-tpm v0.9.8 // indirect
github.com/google/tink/go v1.7.0 // indirect
github.com/gorilla/securecookie v1.1.2 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/pgx/v5 v5.9.0 // indirect
+9
View File
@@ -157,6 +157,7 @@ github.com/coreos/go-semver v0.2.0/go.mod h1:nnelYz7RCh+5ahJtPPxZlU+153eP4D4r3Ee
github.com/coreos/go-systemd v0.0.0-20190321100706-95778dfbb74e/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4=
github.com/coreos/pkg v0.0.0-20180928190104-399ea9e2e55f/go.mod h1:E3G3o1h8I7cfcXa63jLwjI0eiQQMgzzUDFVpN/nH/eA=
github.com/cpuguy83/go-md2man/v2 v2.0.0/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU=
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/cyphar/filepath-securejoin v0.6.1 h1:5CeZ1jPXEiYt3+Z6zqprSAgSWiggmpVyciv8syjIpVE=
github.com/cyphar/filepath-securejoin v0.6.1/go.mod h1:A8hd4EnAeyujCJRrICiOWqjS1AX0a9kM5XL+NwKoYSc=
@@ -368,6 +369,8 @@ github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyf
github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ=
github.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc=
github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
@@ -568,6 +571,7 @@ github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncj
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/sergi/go-diff v1.0.0/go.mod h1:0CfEIISq7TuYL3j771MWULgwwjU+GofnZX9QAmXWZgo=
github.com/sergi/go-diff v1.4.0 h1:n/SP9D5ad1fORl+llWyN+D6qoUETXNZARKjyY2/KVCw=
github.com/sergi/go-diff v1.4.0/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4=
@@ -586,8 +590,13 @@ github.com/spf13/cast v1.3.0/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkU
github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY=
github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo=
github.com/spf13/cobra v0.0.6/go.mod h1:/6GTrnGXV9HjY+aR4k0oJ5tcvakLuG6EuKReYlHNrgE=
github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
github.com/spf13/jwalterweatherman v1.0.0/go.mod h1:cQK4TGJAtQXfYWX+Ddv3mKDzgVb68N+wFjFa4jdeBTo=
github.com/spf13/pflag v1.0.3/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4=
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/spf13/viper v1.4.0/go.mod h1:PTJ7Z/lr49W6bUbkmS1V3by4uWynFiR9p7+dSq/yZzE=
github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c h1:km8GpoQut05eY3GiYWEedbTT0qnSxrCjsVbb7yKY1KE=
github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c/go.mod h1:cNQ3dwVJtS5Hmnjxy6AgTPd0Inb3pW05ftPSX7NZO7Q=
+5
View File
@@ -3,6 +3,7 @@ package config
import (
"log/slog"
"os"
"path/filepath"
"github.com/caarlos0/env/v11"
"github.com/joho/godotenv"
@@ -31,6 +32,10 @@ type Settings struct {
EnableUpdateChecks bool `env:"ENABLE_UPDATE_CHECKS" envDefault:"true"`
}
func (s *Settings) SystemAppsDir() string {
return filepath.Join(s.DataDir, "system-apps")
}
// TemplateConfig holds configuration values needed in templates.
type TemplateConfig struct {
EnableUserRegistration bool
+24
View File
@@ -0,0 +1,24 @@
package config
import (
"context"
"errors"
)
type ctxKey uint8
const configKey ctxKey = iota
func NewContext(ctx context.Context, conf *Settings) context.Context {
return context.WithValue(ctx, configKey, conf)
}
var ErrNoContext = errors.New("no config found in context")
func FromContext(ctx context.Context) (*Settings, error) {
conf, ok := ctx.Value(configKey).(*Settings)
if !ok {
return nil, ErrNoContext
}
return conf, nil
}
+6
View File
@@ -0,0 +1,6 @@
package config
const (
FlagDB = "db"
FlagData = "data"
)
+70
View File
@@ -0,0 +1,70 @@
package data
import (
"log/slog"
"strings"
"time"
"gorm.io/driver/mysql"
"gorm.io/driver/postgres"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
"gorm.io/gorm/logger"
"gorm.io/plugin/prometheus"
)
func Open(dsn, logLevel string) (*gorm.DB, error) {
var db *gorm.DB
var err error
var gormLogLevel logger.LogLevel
switch strings.ToUpper(logLevel) {
case "DEBUG":
gormLogLevel = logger.Info // GORM Info includes SQL queries
case "INFO":
gormLogLevel = logger.Warn
case "WARN", "WARNING":
gormLogLevel = logger.Error
case "ERROR":
gormLogLevel = logger.Error
default:
gormLogLevel = logger.Warn
}
gormConfig := &gorm.Config{
Logger: NewGORMSlogLogger(gormLogLevel, 200*time.Millisecond, true),
PrepareStmt: false, // Disable prepared statement caching to avoid SQLite locking issues
}
if strings.HasPrefix(dsn, "postgres") || strings.Contains(dsn, "host=") {
slog.Info("Using Postgres DB")
db, err = gorm.Open(postgres.Open(dsn), gormConfig)
} else if strings.Contains(dsn, "@tcp(") || strings.Contains(dsn, "@unix(") {
slog.Info("Using MySQL DB")
db, err = gorm.Open(mysql.Open(dsn), gormConfig)
} else {
slog.Info("Using SQLite DB", "path", dsn)
db, err = gorm.Open(sqlite.Open(dsn), gormConfig)
if err == nil {
if err := db.Exec("PRAGMA journal_mode=WAL;").Error; err != nil {
slog.Warn("Failed to set WAL mode for SQLite", "error", err)
}
if err := db.Exec("PRAGMA busy_timeout=5000;").Error; err != nil {
slog.Warn("Failed to set busy timeout for SQLite", "error", err)
}
}
}
if err == nil {
if err := db.Use(prometheus.New(prometheus.Config{
DBName: "tronbyt",
RefreshInterval: 15,
StartServer: false,
HTTPServerPort: 8080,
})); err != nil {
slog.Warn("Failed to register GORM prometheus plugin", "error", err)
}
}
return db, err
}