// cryptomon: read-only crypto portfolio dashboard backed by Kraken. // See data/settings.conf (auto-created next to the executable) for config. package main import ( "flag" "fmt" "log/slog" "net/http" "os" "path/filepath" "time" "cryptomon/internal/authsvc" "cryptomon/internal/config" "cryptomon/internal/portfolio" "cryptomon/internal/store" "cryptomon/internal/web" ) func main() { userReset := flag.Bool("userreset", false, "reset login to the default admin/admin and disable MFA, then exit") flag.Parse() exe, err := os.Executable() if err != nil { fatal("locate executable", err) } baseDir := filepath.Dir(exe) dataDir := filepath.Join(baseDir, "data") if err := os.MkdirAll(dataDir, 0700); err != nil { fatal("create data dir", err) } cfg, err := config.Load(filepath.Join(dataDir, "settings.conf")) if err != nil { fatal("load config", err) } dbPath := cfg.DBPath if !filepath.IsAbs(dbPath) { dbPath = filepath.Join(baseDir, dbPath) } st, err := store.Open(dbPath) if err != nil { fatal("open database", err) } defer st.Close() authSvc := authsvc.New(st, cfg) if *userReset { if err := authSvc.ResetToDefault(); err != nil { fatal("reset login", err) } fmt.Printf("Login reset to %s/%s — you'll be asked to set a new username and password on next sign-in.\n", authsvc.DefaultUsername, authsvc.DefaultPassword) return } if err := authSvc.EnsureDefault(); err != nil { fatal("seed default login", err) } svc := portfolio.New(st, cfg) if err := svc.BackfillQuotes(); err != nil { slog.Warn("backfill purchase quote currencies", "err", err) } if err := svc.NormalizeStakedCurrencies(); err != nil { slog.Warn("normalize staked currencies", "err", err) } go runPoller(svc, cfg) srv := web.NewServer(svc, authSvc, cfg.PollIntervalSeconds) addr := fmt.Sprintf(":%d", cfg.Port) slog.Info("cryptomon starting", "addr", addr, "data_dir", dataDir) if err := http.ListenAndServe(addr, srv.Handler()); err != nil { fatal("serve", err) } } func runPoller(svc *portfolio.Service, cfg *config.Config) { syncFast := func() { if err := svc.SyncTrades(); err != nil { slog.Error("sync trades", "err", err) } if err := svc.SyncTransfers(); err != nil { slog.Error("sync transfers", "err", err) } if err := svc.SyncBalance(); err != nil { slog.Error("sync balance", "err", err) } if err := svc.SyncPrices(); err != nil { slog.Error("sync prices", "err", err) } } syncSlow := func() { if err := svc.SyncHistory(); err != nil { slog.Error("sync history", "err", err) } } syncFast() syncSlow() fastTicker := time.NewTicker(time.Duration(cfg.PollIntervalSeconds) * time.Second) defer fastTicker.Stop() slowTicker := time.NewTicker(time.Duration(cfg.HistoryRefreshSeconds) * time.Second) defer slowTicker.Stop() for { select { case <-fastTicker.C: syncFast() case <-slowTicker.C: syncSlow() } } } func fatal(msg string, err error) { slog.Error(msg, "err", err) os.Exit(1) }