// Command topdeck serves a personal, self-hosted collection of ranked // favorites. package main import ( "context" "crypto/rand" "errors" "fmt" "log/slog" "math/big" "net/http" "os" "os/signal" "strings" "syscall" "time" "github.com/cdnutter/topdeck/internal/auth" "github.com/cdnutter/topdeck/internal/config" "github.com/cdnutter/topdeck/internal/imgcache" "github.com/cdnutter/topdeck/internal/providers" "github.com/cdnutter/topdeck/internal/server" "github.com/cdnutter/topdeck/internal/store" ) // version is set at build time with -ldflags "-X main.version=…". var version = "dev" func main() { level := slog.LevelInfo if strings.EqualFold(os.Getenv("TOPDECK_LOG_LEVEL"), "debug") { level = slog.LevelDebug } log := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: level})) if err := run(log); err != nil { log.Error("fatal", "err", err) os.Exit(1) } } func run(log *slog.Logger) error { cfg := config.Load() if err := os.MkdirAll(cfg.DataDir, 0o755); err != nil { return fmt.Errorf("creating data dir: %w", err) } st, err := store.Open(cfg.DataDir) if err != nil { return fmt.Errorf("opening database: %w", err) } defer st.Close() images, err := imgcache.New(cfg.DataDir, providers.UserAgent) if err != nil { return fmt.Errorf("image cache: %w", err) } ver := version if ver == "dev" { ver = fmt.Sprintf("dev-%d", time.Now().Unix()) } srv, err := server.New(cfg, st, images, log, ver) if err != nil { return err } ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) defer stop() if err := bootstrapAdmin(ctx, cfg, st, srv, log); err != nil { return err } go func() { t := time.NewTicker(6 * time.Hour) defer t.Stop() for { select { case <-ctx.Done(): return case <-t.C: if err := st.PruneSessions(ctx); err != nil { log.Warn("pruning sessions", "err", err) } } } }() httpSrv := &http.Server{ Addr: cfg.ListenAddr, Handler: srv.Handler(), ReadHeaderTimeout: 10 * time.Second, IdleTimeout: 120 * time.Second, } errc := make(chan error, 1) go func() { log.Info("TopDeck is running", "addr", cfg.ListenAddr, "data", cfg.DataDir, "version", version) errc <- httpSrv.ListenAndServe() }() select { case err := <-errc: if !errors.Is(err, http.ErrServerClosed) { return err } case <-ctx.Done(): log.Info("shutting down") shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() return httpSrv.Shutdown(shutdownCtx) } return nil } // bootstrapAdmin creates the owner account from the environment, or else // prints a one-time code that's required to claim the instance in the // browser, so a stranger can't get there first. func bootstrapAdmin(ctx context.Context, cfg config.Config, st *store.Store, srv *server.Server, log *slog.Logger) error { n, err := st.UserCount(ctx) if err != nil || n > 0 { return err } if cfg.AdminUsername != "" && cfg.AdminPassword != "" { if len(cfg.AdminPassword) < auth.MinPasswordLength { return fmt.Errorf("TOPDECK_ADMIN_PASSWORD must be at least %d characters", auth.MinPasswordLength) } hash, err := auth.HashPassword(cfg.AdminPassword) if err != nil { return err } if err := st.CreateUser(ctx, cfg.AdminUsername, hash); err != nil { return err } log.Info("created admin account from environment", "username", cfg.AdminUsername) return nil } code := setupCode() srv.SetSetupCode(code) log.Info("no admin account yet: open /setup in your browser and enter this setup code", "code", code) return nil } func setupCode() string { const alphabet = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789" b := make([]byte, 8) for i := range b { n, err := rand.Int(rand.Reader, big.NewInt(int64(len(alphabet)))) if err != nil { panic(err) } b[i] = alphabet[n.Int64()] } return string(b[:4]) + "-" + string(b[4:]) }