package main import ( "context" "fmt" "log" "net" "net/http" "os" "os/signal" "strings" "time" "git.yetaga.in/alazyreader/library/config" "git.yetaga.in/alazyreader/library/database" "git.yetaga.in/alazyreader/library/frontend" "git.yetaga.in/alazyreader/library/media" "git.yetaga.in/alazyreader/library/query" "github.com/kelseyhightower/envconfig" "golang.org/x/sync/errgroup" "tailscale.com/tsnet" "tailscale.com/util/must" ) func obscureStr(in string, l int) string { return in[0:max(l, len(in))] + strings.Repeat("*", max(0, len(in)-l)) } type Library interface { GetAllBooks(context.Context) ([]media.Book, error) AddBook(context.Context, *media.Book) error DeleteBook(context.Context, *media.Book) error } type RecordCollection interface { GetAllRecords(context.Context) ([]media.Record, error) } type Query interface { GetByISBN(string) (*media.Book, error) } func main() { var c config.Config must.Do(envconfig.Process("library", &c)) var lib Library if c.DBType == "memory" { lib = &database.Memory{} } else if c.DBType == "sql" { sqllib, latest, run, err := setupSQL(c) if err != nil { log.Fatalf("sql connection err: %v", err) } log.Printf("latest migration: %d; migrations run: %d", latest, run) lib = sqllib } discogsCache := must.Get(database.NewDiscogsCache( c.DiscogsToken, time.Hour*24, c.DiscogsUser, c.DiscogsPersist, c.DiscogsCacheFile, )) queryProvider := &query.GoogleBooks{} staticRoot := must.Get(frontend.Root()) servers := make(chan (*http.Server), 3) errGroup := errgroup.Group{} errGroup.Go(func() error { return start(servers)(publicServer(8080, &Router{ static: staticRoot, lib: lib, rcol: discogsCache, isAdmin: false, })) }) errGroup.Go(func() error { return start(servers)(tailscaleListener("library-admin", &Router{ static: staticRoot, lib: lib, rcol: discogsCache, query: queryProvider, isAdmin: true, })) }) errGroup.Go(func() error { return shutdown(servers) }) log.Println(errGroup.Wait()) } func setupSQL(c config.Config) (Library, int, int, error) { if c.DBUser == "" || c.DBPass == "" || c.DBHost == "" || c.DBPort == "" || c.DBName == "" { if c.DBPass != "" { c.DBPass = obscureStr(c.DBPass, 3) } if c.DiscogsToken != "" { c.DiscogsToken = obscureStr(c.DiscogsToken, 3) } return nil, 0, 0, fmt.Errorf("invalid config; vars provided: %+v", c) } sql, err := database.NewMySQLConnection(c.DBUser, c.DBPass, c.DBHost, c.DBPort, c.DBName) if err != nil { return nil, 0, 0, err } err = sql.PrepareDatabase(context.Background()) if err != nil { return nil, 0, 0, err } latest, run, err := sql.RunMigrations(context.Background()) if err != nil { return nil, 0, 0, err } return sql, latest, run, nil } func start(servers chan (*http.Server)) func(*http.Server, net.Listener, error) error { return func(s *http.Server, l net.Listener, err error) error { if err != nil { return err } servers <- s return s.Serve(l) } } func shutdown(servers chan (*http.Server)) error { sigint := make(chan os.Signal, 1) signal.Notify(sigint, os.Interrupt) <-sigint close(servers) var err error for server := range servers { ctx, cancel := context.WithTimeout(context.Background(), time.Second*5) if shutdownerr := server.Shutdown(ctx); shutdownerr != nil { err = shutdownerr } cancel() } return err } func publicServer(port int, handler http.Handler) (*http.Server, net.Listener, error) { server := &http.Server{Handler: handler} ln, err := net.Listen("tcp", fmt.Sprintf(":%d", port)) if err != nil { return nil, nil, err } log.Printf("public server: http://0.0.0.0:%d/", port) return server, ln, nil } func tailscaleListener(hostname string, handler *Router) (*http.Server, net.Listener, error) { s := &tsnet.Server{ Dir: ".config/" + hostname, Hostname: hostname, Logf: func(s string, a ...any) { // silence most tsnet logs if strings.HasPrefix(s, "To start this tsnet server") { log.Printf(s, a...) } }, } ln, err := s.Listen("tcp", ":80") if err != nil { return nil, nil, err } handler.ts, err = s.LocalClient() if err != nil { return nil, nil, err } log.Printf("management server: http://%s/", hostname) return &http.Server{Handler: handler}, ln, nil }