diff --git a/cmd/serve/api.go b/cmd/serve/api.go new file mode 100644 index 0000000..7560050 --- /dev/null +++ b/cmd/serve/api.go @@ -0,0 +1,120 @@ +package main + +import ( + "encoding/json" + "fmt" + "io/fs" + "net/http" + + "git.yetaga.in/alazyreader/library/media" + "tailscale.com/client/tailscale" + "tailscale.com/util/must" +) + +type Router struct { + static fs.FS + lib Library + rcol RecordCollection +} + +type AdminRouter struct { + static fs.FS + lib Library + ts *tailscale.LocalClient +} + +func writeError[T any](w http.ResponseWriter) func(t T, err error) (T, bool) { + return func(t T, err error) (T, bool) { + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + } + return t, err != nil + } +} + +func writeJSON(w http.ResponseWriter, b []byte, status int) { + w.Header().Set("Content-Type", "application/json; charset=utf-8") + w.WriteHeader(status) + w.Write(b) + w.Write([]byte("\n")) +} + +func (r *Router) ServeHTTP(w http.ResponseWriter, req *http.Request) { + if req.URL.Path == "/api/records" { + RecordsAPIHandler(r.rcol).ServeHTTP(w, req) + return + } + if req.URL.Path == "/api/books" { + BooksAPIHandler(r.lib).ServeHTTP(w, req) + return + } + StaticHandler(r.static).ServeHTTP(w, req) +} + +func (r *AdminRouter) ServeHTTP(w http.ResponseWriter, req *http.Request) { + whois, _ := r.ts.WhoIs(req.Context(), req.RemoteAddr) + switch req.URL.Path { + case "/api/books": + switch req.Method { + case http.MethodGet: + books, ok := writeError[[]media.Book](w)(r.lib.GetAllBooks(req.Context())) + if !ok { + return + } + b, ok := writeError[[]byte](w)(json.Marshal(books)) + if !ok { + return + } + writeJSON(w, b, http.StatusOK) + case http.MethodPost: + default: + badMethod(w) + } + return + } + w.Write([]byte(fmt.Sprintf("%+v", whois.UserProfile.DisplayName))) + // StaticHandler(r.static).ServeHTTP(w, req) +} + +func badMethod(w http.ResponseWriter) { + writeJSON(w, + must.Get(json.Marshal(struct{ Error string }{Error: "method not supported"})), + http.StatusMethodNotAllowed) +} + +func BooksAPIHandler(l Library) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + books, err := l.GetAllBooks(r.Context()) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + b, err := json.Marshal(books) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + + } + writeJSON(w, b, http.StatusOK) + }) +} + +func RecordsAPIHandler(l RecordCollection) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + books, err := l.GetAllRecords(r.Context()) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + b, err := json.Marshal(books) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + writeJSON(w, b, http.StatusOK) + }) +} + +func StaticHandler(f fs.FS) http.Handler { + return http.FileServer(http.FS(f)) +} diff --git a/cmd/serve/main.go b/cmd/serve/main.go index 8868721..970a63b 100644 --- a/cmd/serve/main.go +++ b/cmd/serve/main.go @@ -2,11 +2,12 @@ package main import ( "context" - "encoding/json" "fmt" - "io/fs" "log" + "net" "net/http" + "os" + "os/signal" "strings" "time" @@ -16,8 +17,8 @@ import ( "git.yetaga.in/alazyreader/library/media" "github.com/kelseyhightower/envconfig" "golang.org/x/sync/errgroup" - "tailscale.com/client/tailscale" "tailscale.com/tsnet" + "tailscale.com/util/must" ) func obscureStr(in string, l int) string { @@ -32,160 +33,123 @@ type RecordCollection interface { GetAllRecords(context.Context) ([]media.Record, error) } -type Router struct { - static fs.FS - lib Library - rcol RecordCollection -} - -type AdminRouter struct { - static fs.FS - lib Library - ts *tailscale.LocalClient -} - -func writeJSON(w http.ResponseWriter, b []byte, status int) { - w.Header().Set("Content-Type", "application/json; charset=utf-8") - w.WriteHeader(status) - w.Write(b) - w.Write([]byte("\n")) -} - -func (r *Router) ServeHTTP(w http.ResponseWriter, req *http.Request) { - if req.URL.Path == "/api/records" { - RecordsAPIHandler(r.rcol).ServeHTTP(w, req) - return - } - if req.URL.Path == "/api/books" { - BooksAPIHandler(r.lib).ServeHTTP(w, req) - return - } - StaticHandler(r.static).ServeHTTP(w, req) -} - -func (r *AdminRouter) ServeHTTP(w http.ResponseWriter, req *http.Request) { - whois, _ := r.ts.WhoIs(req.Context(), req.RemoteAddr) - w.Write([]byte(fmt.Sprintf("%+v", whois.UserProfile.DisplayName))) - // StaticHandler(r.static).ServeHTTP(w, req) -} - -func BooksAPIHandler(l Library) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - books, err := l.GetAllBooks(r.Context()) - if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - b, err := json.Marshal(books) - if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - - } - writeJSON(w, b, http.StatusOK) - }) -} - -func RecordsAPIHandler(l RecordCollection) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - books, err := l.GetAllRecords(r.Context()) - if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - b, err := json.Marshal(books) - if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - writeJSON(w, b, http.StatusOK) - }) -} - -func StaticHandler(f fs.FS) http.Handler { - return http.FileServer(http.FS(f)) -} - func main() { var c config.Config - err := envconfig.Process("library", &c) - if err != nil { - log.Fatalln(err) - } - f, err := frontend.Root() - if err != nil { - log.Fatalln(err) - } + must.Do(envconfig.Process("library", &c)) + var lib Library + var err error if c.DBType == "memory" { lib = &database.Memory{} } else if c.DBType == "sql" { - 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) - } - log.Fatalf("vars: %+v", c) - } - sql, err := database.NewMySQLConnection(c.DBUser, c.DBPass, c.DBHost, c.DBPort, c.DBName) + var latest, run int + lib, latest, run, err = setupSQL(c) if err != nil { - log.Fatalln(err) - } - err = sql.PrepareDatabase(context.Background()) - if err != nil { - log.Fatalln(err) - } - latest, run, err := sql.RunMigrations(context.Background()) - if err != nil { - log.Fatalln(err) + log.Fatalf("err starting sql connection: %v", err) } log.Printf("latest migration: %d; migrations run: %d", latest, run) - lib = sql - } - discogsCache, err := database.NewDiscogsCache(c.DiscogsToken, time.Hour*24, c.DiscogsUser, c.DiscogsPersist, c.DiscogsCacheFile) - if err != nil { - log.Fatalln(err) } + discogsCache := must.Get(database.NewDiscogsCache( + c.DiscogsToken, time.Hour*24, c.DiscogsUser, c.DiscogsPersist, c.DiscogsCacheFile, + )) + + frontendRoot := must.Get(frontend.Root()) + adminRoot := must.Get(frontend.AdminRoot()) + + servers := make(chan (*http.Server), 2) errGroup := errgroup.Group{} - errGroup.Go(func() error { - return publicListener(8080, &Router{ - static: f, + errGroup.Go(start(servers)( + publicServer(8080, &Router{ + static: frontendRoot, lib: lib, rcol: discogsCache, - }) - }) - errGroup.Go(func() error { - f, _ := frontend.AdminRoot() - return tailscaleListener("library-admin", &AdminRouter{ - static: f, + }))) + errGroup.Go(start(servers)( + tailscaleListener("library-admin", &AdminRouter{ + static: adminRoot, lib: lib, - }) - }) + }))) + errGroup.Go(shutdown(servers)) + log.Println(errGroup.Wait()) } -func publicListener(port int, handler http.Handler) error { - log.Printf("Listening on http://0.0.0.0:%d/", port) - return http.ListenAndServe(fmt.Sprintf(":%d", port), handler) +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 tailscaleListener(hostname string, handler *AdminRouter) error { +func errFunc(err error) func() error { + return func() error { return err } +} + +func start(servers chan (*http.Server)) func(*http.Server, net.Listener, error) func() error { + return func(s *http.Server, l net.Listener, err error) func() error { + if err != nil { + return errFunc(err) + } + servers <- s + return errFunc(s.Serve(l)) + } +} + +func shutdown(servers chan (*http.Server)) func() error { + sigint := make(chan os.Signal, 1) + signal.Notify(sigint, os.Interrupt) + <-sigint + close(servers) + for server := range servers { + ctx, cancel := context.WithTimeout(context.Background(), time.Second*5) + if err := server.Shutdown(ctx); err != nil { + log.Panicf("error during shutdown: %v", err) + } + cancel() + } + return errFunc(nil) +} + +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", 8080)) + if err != nil { + return nil, nil, err + } + return server, ln, nil +} + +func tailscaleListener(hostname string, handler *AdminRouter) (*http.Server, net.Listener, error) { s := &tsnet.Server{ Dir: ".config/" + hostname, Hostname: hostname, } - defer s.Close() - ln, err := s.Listen("tcp", ":80") if err != nil { - fmt.Printf("%+v\n", err) - return nil + return nil, nil, err } handler.ts, err = s.LocalClient() if err != nil { - return err + return nil, nil, err } - return (&http.Server{Handler: handler}).Serve(ln) + server := &http.Server{Handler: handler} + return server, ln, nil }