From 41e2f5e064122b02380d1b6e213fab431d4c2b7a Mon Sep 17 00:00:00 2001 From: Russell Troxel Date: Fri, 19 Jan 2024 19:42:08 -0800 Subject: [PATCH] Add Download Handler (#72) Signed-off-by: Russell Troxel --- internal/calibre/tasks/import.go | 8 +- internal/ent/schema/filetype/filetype.go | 9 ++ internal/handler/download.go | 78 ++++++++++ internal/handler/download_test.go | 138 ++++++++++++++++++ ...housand Leagues under t - Jules Verne.epub | 1 + 5 files changed, 230 insertions(+), 4 deletions(-) create mode 100644 internal/handler/download.go create mode 100644 internal/handler/download_test.go create mode 120000 internal/handler/testdata/Twenty Thousand Leagues under t - Jules Verne.epub diff --git a/internal/calibre/tasks/import.go b/internal/calibre/tasks/import.go index 751fb957..7290baff 100644 --- a/internal/calibre/tasks/import.go +++ b/internal/calibre/tasks/import.go @@ -392,8 +392,8 @@ func registerBookFile(ctx context.Context, client *ent.Client, bookID ksuid.ID, return nil } - filetype := filetype.FromExtension(ext) - if filetype == 0 { + ft := filetype.FromExtension(ext) + if ft == filetype.Unknown { return errors.New("unknown file type") } info, err := file.Info() @@ -402,10 +402,10 @@ func registerBookFile(ctx context.Context, client *ent.Client, bookID ksuid.ID, } size := info.Size() _, err = client.BookFile.Create(). - SetName(file.Name()). + SetName(nameWithoutExt). SetPath(path). SetSize(size). - SetFormat(bookfile.Format(filetype.String())). + SetFormat(bookfile.Format(ft.String())). SetBookID(bookID). Save(ctx) if err != nil { diff --git a/internal/ent/schema/filetype/filetype.go b/internal/ent/schema/filetype/filetype.go index 5b14168f..7fafb75b 100644 --- a/internal/ent/schema/filetype/filetype.go +++ b/internal/ent/schema/filetype/filetype.go @@ -29,6 +29,15 @@ func (f FileType) String() string { return "" } +func (f FileType) Extension() string { + switch f { + case KEPUB: + return ".kepub.epub" + default: + return "." + strings.ToLower(f.String()) + } +} + func FromString(s string) FileType { switch s { case EPUB.String(): diff --git a/internal/handler/download.go b/internal/handler/download.go new file mode 100644 index 00000000..d515836c --- /dev/null +++ b/internal/handler/download.go @@ -0,0 +1,78 @@ +package handler + +import ( + "fmt" + "lybbrio/internal/ent" + "lybbrio/internal/ent/book" + "lybbrio/internal/ent/bookfile" + "lybbrio/internal/ent/schema/ksuid" + "mime" + "net/http" + "net/url" + "strings" + + "github.com/go-chi/chi/v5" + "github.com/go-chi/render" + "github.com/rs/zerolog/log" +) + +func DownloadRoutes(client *ent.Client) http.Handler { + r := chi.NewRouter() + r.Get("/{bookID}/{book_format}", Download(client)) + return r +} + +func Download(client *ent.Client) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + log.Info().Str("path", r.URL.Path).Msg("Download") + ctx := r.Context() + bookID := chi.URLParam(r, "bookID") + bookFormat := chi.URLParam(r, "book_format") + log.Info(). + Str("bookID", bookID). + Str("book_format", bookFormat). + Msg("Params") + if bookID == "" || bookFormat == "" { + + status := http.StatusBadRequest + w.WriteHeader(status) + render.JSON(w, r, map[string]string{ + "error": http.StatusText(status), + }) + return + } + + bookFile, err := client.BookFile.Query(). + Where( + bookfile.And( + bookfile.HasBookWith(book.ID(ksuid.ID(bookID))), + bookfile.FormatEQ(bookfile.Format(strings.ToUpper(bookFormat))), + ), + ). + First(ctx) + if err != nil { + // TODO: Convert if Format isn't available? + status := http.StatusNotFound + if !ent.IsNotFound(err) { + log.Error().Err(err).Msg("Failed to query book file") + status = http.StatusInternalServerError + } + w.WriteHeader(status) + render.JSON(w, r, map[string]string{ + "error": http.StatusText(status), + }) + return + } + mtype := mime.TypeByExtension("." + strings.ToLower(bookFile.Format.String())) + if mtype == "" { + mtype = "application/octet-stream" + } + w.Header().Set("Content-Type", mtype) + dispo := fmt.Sprintf("attachment; filename=%s; filename*=UTF-8''%s", + url.QueryEscape(bookFile.Name), + url.QueryEscape(bookFile.Name), + ) + w.Header().Set("Content-Disposition", dispo) + http.ServeFile(w, r, bookFile.Path) + } +} diff --git a/internal/handler/download_test.go b/internal/handler/download_test.go new file mode 100644 index 00000000..a1c70971 --- /dev/null +++ b/internal/handler/download_test.go @@ -0,0 +1,138 @@ +package handler + +import ( + "context" + "lybbrio/internal/db" + "lybbrio/internal/ent" + "net/http" + "net/http/httptest" + "testing" + + "github.com/go-chi/chi/v5" + "github.com/stretchr/testify/require" + + "lybbrio/internal/ent/schema/permissions" + "lybbrio/internal/viewer" +) + +type testContext struct { + client *ent.Client + teardown func() + adminCtx context.Context +} + +func (t testContext) Teardown() { + t.client.Close() + t.teardown() +} + +func setupHandlerTest(t *testing.T, testName string, teardown ...func()) testContext { + var ret testContext + + ret.client = db.OpenTest(t, testName) + + ret.teardown = func() { + ret.client.Close() + } + + ret.adminCtx = viewer.NewSystemAdminContext(context.Background()) + + return ret +} + +func addContextMiddleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + ctx := viewer.NewContext(r.Context(), "usr_asdf", permissions.NewPermissions()) + next.ServeHTTP(w, r.WithContext(ctx)) + }) +} + +func TestDownload(t *testing.T) { + t.Parallel() + require := require.New(t) + tc := setupHandlerTest(t, "TestDownload") + defer tc.Teardown() + + r := chi.NewRouter() + r.Use(addContextMiddleware) + r.Mount("/", DownloadRoutes(tc.client)) + server := httptest.NewServer(r) + defer server.Close() + + book, err := tc.client.Book.Create(). + SetTitle("Test Book"). + SetSort("Test Book"). + SetPath("/testdata/").Save(tc.adminCtx) + require.NoError(err) + + bookFile, err := tc.client.BookFile.Create(). + SetBook(book). + SetName("Twenty Thousand Leagues under t - Jules Verne"). + SetPath("testdata/Twenty Thousand Leagues under t - Jules Verne.epub"). + SetSize(369814). + SetFormat("EPUB"). + Save(tc.adminCtx) + require.NoError(err) + + url := server.URL + "/" + book.ID.String() + "/" + bookFile.Format.String() + resp, err := server.Client().Get(url) + require.NoError(err) + require.Equal(200, resp.StatusCode) + require.Equal("application/epub+zip", resp.Header.Get("Content-Type")) + require.Equal( + "attachment; filename=Twenty+Thousand+Leagues+under+t+-+Jules+Verne; "+ + "filename*=UTF-8''Twenty+Thousand+Leagues+under+t+-+Jules+Verne", + resp.Header.Get("Content-Disposition"), + ) + require.Equal("369814", resp.Header.Get("Content-Length")) +} + +func TestDownloadErrors(t *testing.T) { + tests := []struct { + name string + bookID string + bookFormat string + wantCode int + }{ + { + name: "Missing bookID", + bookFormat: "EPUB", + wantCode: http.StatusBadRequest, + }, + { + name: "Missing book_format", + bookID: "asdf", + wantCode: http.StatusNotFound, + }, + { + name: "Nonexistent bookID", + bookID: "asdf", + bookFormat: "EPUB", + wantCode: http.StatusNotFound, + }, + { + name: "Invalid book_format", + bookID: "asdf", + bookFormat: "asdf", + wantCode: http.StatusNotFound, + }, + } + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + tc := setupHandlerTest(t, tt.name) + defer tc.Teardown() + + r := chi.NewRouter() + r.Use(addContextMiddleware) + r.Mount("/", DownloadRoutes(tc.client)) + server := httptest.NewServer(r) + defer server.Close() + + url := server.URL + "/" + tt.bookID + "/" + tt.bookFormat + resp, err := server.Client().Get(url) + require.NoError(t, err) + require.Equal(t, tt.wantCode, resp.StatusCode) + }) + } +} diff --git a/internal/handler/testdata/Twenty Thousand Leagues under t - Jules Verne.epub b/internal/handler/testdata/Twenty Thousand Leagues under t - Jules Verne.epub new file mode 120000 index 00000000..7c968040 --- /dev/null +++ b/internal/handler/testdata/Twenty Thousand Leagues under t - Jules Verne.epub @@ -0,0 +1 @@ +../../calibre/test_fixtures/importable/Jules Verne/Twenty Thousand Leagues under the Se (2)/Twenty Thousand Leagues under t - Jules Verne.epub \ No newline at end of file