diff --git a/cmd/serve/api.go b/cmd/serve/api.go index 7560050..d79cca3 100644 --- a/cmd/serve/api.go +++ b/cmd/serve/api.go @@ -2,13 +2,12 @@ package main import ( "encoding/json" - "fmt" + "io" "io/fs" "net/http" "git.yetaga.in/alazyreader/library/media" "tailscale.com/client/tailscale" - "tailscale.com/util/must" ) type Router struct { @@ -23,98 +22,175 @@ type AdminRouter struct { 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) { +type handler struct { + get func() + post func() + put func() + delete func() +} + +func (h handler) Handle(w http.ResponseWriter, req *http.Request) { + if req.Method == http.MethodHead && h.get != nil { + h.get() + } else if req.Method == http.MethodGet && h.get != nil { + h.get() + } else if req.Method == http.MethodPost && h.post != nil { + h.post() + } else if req.Method == http.MethodPut && h.put != nil { + h.put() + } else if req.Method == http.MethodDelete && h.delete != nil { + h.delete() + } else { + badMethod(w) + } +} + +func writeError[T any](t T, err error) func(w http.ResponseWriter) (T, bool) { + return func(w http.ResponseWriter) (T, bool) { if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) } - return t, err != nil + return t, err == nil } } -func writeJSON(w http.ResponseWriter, b []byte, status int) { +func writeNoBody(w http.ResponseWriter, status int) { + w.WriteHeader(status) +} +func writeJSON(w http.ResponseWriter, b any, status int) { + bytes, err := json.Marshal(b) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } w.Header().Set("Content-Type", "application/json; charset=utf-8") w.WriteHeader(status) - w.Write(b) + w.Write(bytes) 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 +func (router *Router) ServeHTTP(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/api/records": + handler{ + get: func() { getRecords(router.rcol, w, r) }, + }.Handle(w, r) + case "/api/books": + handler{ + get: func() { getBooks(router.lib, w, r) }, + }.Handle(w, r) + default: + static(router.static).ServeHTTP(w, r) } - 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 { +func (router *AdminRouter) ServeHTTP(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/api/whoami": + handler{ + get: func() { getWhoAmI(router.ts, w, r) }, + }.Handle(w, r) 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 + handler{ + get: func() { getBooks(router.lib, w, r) }, + post: func() { addBook(router.lib, w, r) }, + delete: func() { deleteBook(router.lib, w, r) }, + }.Handle(w, r) + default: + static(router.static).ServeHTTP(w, r) } - 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"})), + 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 getBooks(l Library, w http.ResponseWriter, r *http.Request) { + books, err := l.GetAllBooks(r.Context()) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + writeJSON(w, books, 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 addBook(l Library, w http.ResponseWriter, r *http.Request) { + if r.Body == nil { + http.Error(w, "no body provided", http.StatusBadRequest) + return + } + defer r.Body.Close() + b, err := io.ReadAll(r.Body) + if err != nil { + http.Error(w, "error reading body", http.StatusBadRequest) + return + } + book := &media.Book{} + err = json.Unmarshal(b, book) + if err != nil { + http.Error(w, "error parsing body", http.StatusBadRequest) + return + } + err = l.AddBook(r.Context(), book) + if err != nil { + http.Error(w, "error parsing body", http.StatusBadRequest) + return + } + writeNoBody(w, http.StatusAccepted) } -func StaticHandler(f fs.FS) http.Handler { +func deleteBook(l Library, w http.ResponseWriter, r *http.Request) { + if r.Body == nil { + http.Error(w, "no body provided", http.StatusBadRequest) + return + } + defer r.Body.Close() + b, err := io.ReadAll(r.Body) + if err != nil { + http.Error(w, "error reading body", http.StatusBadRequest) + return + } + book := &media.Book{} + err = json.Unmarshal(b, book) + if err != nil { + http.Error(w, "error parsing body", http.StatusBadRequest) + return + } + err = l.DeleteBook(r.Context(), book) + if err != nil { + http.Error(w, "error parsing body", http.StatusBadRequest) + return + } + writeNoBody(w, http.StatusAccepted) +} + +func getRecords(l RecordCollection, w http.ResponseWriter, r *http.Request) { + records, err := l.GetAllRecords(r.Context()) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + writeJSON(w, records, http.StatusOK) +} + +func getWhoAmI(ts *tailscale.LocalClient, w http.ResponseWriter, r *http.Request) { + whois, ok := writeError(ts.WhoIs(r.Context(), r.RemoteAddr))(w) + if !ok { + return + } + writeJSON(w, struct { + Username string `json:"Username"` + DisplayName string `json:"DisplayName"` + ProfilePicURL string `json:"ProfilePicURL"` + }{ + Username: whois.UserProfile.LoginName, + DisplayName: whois.UserProfile.DisplayName, + ProfilePicURL: whois.UserProfile.ProfilePicURL, + }, http.StatusOK) +} + +func static(f fs.FS) http.Handler { return http.FileServer(http.FS(f)) } diff --git a/cmd/serve/main.go b/cmd/serve/main.go index 970a63b..267a23d 100644 --- a/cmd/serve/main.go +++ b/cmd/serve/main.go @@ -27,6 +27,8 @@ func obscureStr(in string, l int) string { type Library interface { GetAllBooks(context.Context) ([]media.Book, error) + AddBook(context.Context, *media.Book) error + DeleteBook(context.Context, *media.Book) error } type RecordCollection interface { @@ -56,20 +58,26 @@ func main() { frontendRoot := must.Get(frontend.Root()) adminRoot := must.Get(frontend.AdminRoot()) - servers := make(chan (*http.Server), 2) + servers := make(chan (*http.Server), 3) errGroup := errgroup.Group{} - errGroup.Go(start(servers)( - publicServer(8080, &Router{ - static: frontendRoot, - lib: lib, - rcol: discogsCache, - }))) - errGroup.Go(start(servers)( - tailscaleListener("library-admin", &AdminRouter{ - static: adminRoot, - lib: lib, - }))) - errGroup.Go(shutdown(servers)) + errGroup.Go(func() error { + return start(servers)( + publicServer(8080, &Router{ + static: frontendRoot, + lib: lib, + rcol: discogsCache, + })) + }) + errGroup.Go(func() error { + return start(servers)( + tailscaleListener("library-admin", &AdminRouter{ + static: adminRoot, + lib: lib, + })) + }) + errGroup.Go(func() error { + return shutdown(servers) + }) log.Println(errGroup.Wait()) } @@ -99,33 +107,30 @@ func setupSQL(c config.Config) (Library, int, int, error) { return sql, latest, run, nil } -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 { +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 errFunc(err) + return err } servers <- s - return errFunc(s.Serve(l)) + return s.Serve(l) } } -func shutdown(servers chan (*http.Server)) func() error { +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 err := server.Shutdown(ctx); err != nil { - log.Panicf("error during shutdown: %v", err) + if shutdownerr := server.Shutdown(ctx); shutdownerr != nil { + err = shutdownerr } cancel() } - return errFunc(nil) + return err } func publicServer(port int, handler http.Handler) (*http.Server, net.Listener, error) { @@ -134,6 +139,7 @@ func publicServer(port int, handler http.Handler) (*http.Server, net.Listener, e if err != nil { return nil, nil, err } + log.Println("starting public server") return server, ln, nil } diff --git a/database/mysql.go b/database/mysql.go index 93df8fd..cc30ee9 100644 --- a/database/mysql.go +++ b/database/mysql.go @@ -153,9 +153,8 @@ func (m *MySQL) GetAllBooks(ctx context.Context) ([]media.Book, error) { } allBooksQuery := fmt.Sprintf(`SELECT - id, title, authors, sortauthor, isbn10, isbn13, format, - genre, publisher, series, volume, year, signed, - description, notes, coverurl, childrens + id, title, authors, sortauthor, isbn10, isbn13, format, genre, publisher, + series, volume, year, signed, description, notes, coverurl, childrens FROM %s`, m.tableName) books := []media.Book{} @@ -169,12 +168,8 @@ func (m *MySQL) GetAllBooks(ctx context.Context) ([]media.Book, error) { b := media.Book{} var authors string err := rows.Scan( - &b.ID, &b.Title, &authors, - &b.SortAuthor, &b.ISBN10, &b.ISBN13, - &b.Format, &b.Genre, &b.Publisher, - &b.Series, &b.Volume, &b.Year, - &b.Signed, &b.Description, &b.Notes, - &b.CoverURL, &b.Childrens) + &b.ID, &b.Title, &authors, &b.SortAuthor, &b.ISBN10, &b.ISBN13, &b.Format, &b.Genre, &b.Publisher, + &b.Series, &b.Volume, &b.Year, &b.Signed, &b.Description, &b.Notes, &b.CoverURL, &b.Childrens) if err != nil { return nil, err } @@ -193,25 +188,14 @@ func (m *MySQL) AddBook(ctx context.Context, b *media.Book) error { res, err := m.connection.ExecContext(ctx, ` INSERT INTO `+m.tableName+` - (title, authors, sortauthor, isbn10, isbn13, format, genre, publisher, series, volume, year, signed, description, notes, coverurl, childrens) + ( + title, authors, sortauthor, isbn10, isbn13, format, genre, publisher, series, + volume, year, signed, description, notes, coverurl, childrens + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, - b.Title, - strings.Join(b.Authors, ";"), - b.SortAuthor, - b.ISBN10, - b.ISBN13, - b.Format, - b.Genre, - b.Publisher, - b.Series, - b.Volume, - b.Year, - b.Signed, - b.Description, - b.Notes, - b.CoverURL, - b.Childrens, + b.Title, strings.Join(b.Authors, ";"), b.SortAuthor, b.ISBN10, b.ISBN13, b.Format, b.Genre, b.Publisher, b.Series, + b.Volume, b.Year, b.Signed, b.Description, b.Notes, b.CoverURL, b.Childrens, ) if err != nil { return err @@ -236,41 +220,13 @@ func (m *MySQL) UpdateBook(ctx context.Context, old, new *media.Book) error { res, err := m.connection.ExecContext(ctx, ` UPDATE `+m.tableName+` - SET id=? - title=? - authors=? - sortauthor=? - isbn10=? - isbn13=? - format=? - genre=? - publisher=? - series=? - volume=? - year=? - signed=? - description=? - notes=? - coverurl=? - childrens=? + SET + id=? title=? authors=? sortauthor=? isbn10=? isbn13=? format=? genre=? publisher=? + series=? volume=? year=? signed=? description=? notes=? coverurl=? childrens=? WHERE id=?`, - new.Title, - strings.Join(new.Authors, ";"), - new.SortAuthor, - new.ISBN10, - new.ISBN13, - new.Format, - new.Genre, - new.Publisher, - new.Series, - new.Volume, - new.Year, - new.Signed, - new.Description, - new.Notes, - new.CoverURL, - new.Childrens, - old.ID) + new.Title, strings.Join(new.Authors, ";"), new.SortAuthor, new.ISBN10, new.ISBN13, new.Format, new.Genre, new.Publisher, + new.Series, new.Volume, new.Year, new.Signed, new.Description, new.Notes, new.CoverURL, new.Childrens, old.ID, + ) if err != nil { return err } @@ -284,6 +240,10 @@ func (m *MySQL) UpdateBook(ctx context.Context, old, new *media.Book) error { return nil } +func (m *MySQL) DeleteBook(_ context.Context, b *media.Book) error { + return nil +} + func parseMigrationFileName(filename string) (int, string, error) { sp := strings.SplitN(filename, "-", 2) i, err := strconv.Atoi(sp[0])