diff --git a/.github/workflows/go.yml b/.github/workflows/go.yml index 1bd0a1f..a4021c0 100644 --- a/.github/workflows/go.yml +++ b/.github/workflows/go.yml @@ -16,7 +16,7 @@ jobs: - name: Set up Go uses: actions/setup-go@v2 with: - go-version: 1.15 + go-version: 1.16 - name: Build run: go build -v ./... diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 7add829..1dce362 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -22,6 +22,9 @@ jobs: uses: actions/setup-go@v2 with: go-version: 1.16 + - + name: Test + run: go test -v ./... - name: Run GoReleaser uses: goreleaser/goreleaser-action@v2 diff --git a/go.mod b/go.mod index eba05d3..9b3ea43 100644 --- a/go.mod +++ b/go.mod @@ -5,5 +5,6 @@ go 1.12 require ( github.com/lann/builder v0.0.0-20150808151131-f22ce00fd939 github.com/lann/ps v0.0.0-20150810152359-62de8c46ede0 // indirect + github.com/stretchr/testify v1.7.0 golang.org/x/tools v0.0.0-20170217234718-8e779ee0a450 ) diff --git a/go.sum b/go.sum index 1a9fbcb..1ed6fec 100644 --- a/go.sum +++ b/go.sum @@ -1,6 +1,17 @@ +github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/lann/builder v0.0.0-20150808151131-f22ce00fd939 h1:yZJImkCmVI6d1uJ9KRRf/96YbFLDQ/hhs6Xt9Z3OBXI= github.com/lann/builder v0.0.0-20150808151131-f22ce00fd939/go.mod h1:dXGbAdH5GtBTC4WfIxhKZfyBF/HBFgRZSWwZ9g/He9o= github.com/lann/ps v0.0.0-20150810152359-62de8c46ede0 h1:P6pPBnrTSX3DEVR4fDembhRWSsG5rVo6hYhAB/ADZrk= github.com/lann/ps v0.0.0-20150810152359-62de8c46ede0/go.mod h1:vmVJ0l/dxyfGW6FmdpVm2joNMFikkuWg0EoCKLGUMNw= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= golang.org/x/tools v0.0.0-20170217234718-8e779ee0a450 h1:qbbvkCEu5ZgZKpHV38z/uXcloRX6fn/EgiGMW/9eluc= golang.org/x/tools v0.0.0-20170217234718-8e779ee0a450/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c h1:dUUwHk2QECo/6vqA44rthZ8ie2QXMNeKRTHCNY2nXvo= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/service/service.go b/internal/service/service.go new file mode 100644 index 0000000..4139960 --- /dev/null +++ b/internal/service/service.go @@ -0,0 +1,158 @@ +//package service provides a http handler that reads the path in the request.url and returns +// an xml document that follows the OPDS 1.1 standard +// https://specs.opds.io/opds-1.1.html +package service + +import ( + "bytes" + "encoding/xml" + "io/ioutil" + "log" + "mime" + "net/http" + "os" + "path" + "path/filepath" + "time" + + "github.com/dubyte/dir2opds/opds" + "golang.org/x/tools/blog/atom" +) + +func init() { + _ = mime.AddExtensionType(".mobi", "application/x-mobipocket-ebook") + _ = mime.AddExtensionType(".epub", "application/epub+zip") + _ = mime.AddExtensionType(".cbz", "application/x-cbz") + _ = mime.AddExtensionType(".cbr", "application/x-cbr") + _ = mime.AddExtensionType(".fb2", "text/fb2+xml") +} + +type OPDS struct { + DirRoot string + Author string + AuthorEmail string + AuthorURI string +} + +var TimeNowFunc = timeNow + +func (s OPDS) Handler(w http.ResponseWriter, req *http.Request) error { + fPath := path.Join(s.DirRoot, req.URL.Path) + + log.Printf("fPath:'%s'", fPath) + + fi, err := os.Stat(fPath) + if err != nil { + return err + } + + if isFile(fi) { + http.ServeFile(w, req, fPath) + return nil + } + + content, err := s.getContent(req, fPath) + if err != nil { + return err + } + + content = append([]byte(xml.Header), content...) + http.ServeContent(w, req, "feed.xml", TimeNowFunc(), bytes.NewReader(content)) + return nil +} + +func (s OPDS) getContent(req *http.Request, dirpath string) (result []byte, err error) { + feed := s.makeFeed(dirpath, req) + if getPathType(dirpath) == pathTypeDirOfFiles { + acFeed := &opds.AcquisitionFeed{&feed, "http://purl.org/dc/terms/", "http://opds-spec.org/2010/catalog"} + result, err = xml.MarshalIndent(acFeed, " ", " ") + } else { + result, err = xml.MarshalIndent(feed, " ", " ") + } + return +} + +const navigationType = "application/atom+xml;profile=opds-catalog;kind=navigation" + +func (s OPDS) makeFeed(dirpath string, req *http.Request) atom.Feed { + feedBuilder := opds.FeedBuilder. + ID(req.URL.Path). + Title("Catalog in " + req.URL.Path). + Author(opds.AuthorBuilder.Name(s.Author).Email(s.AuthorEmail).URI(s.AuthorURI).Build()). + Updated(TimeNowFunc()). + AddLink(opds.LinkBuilder.Rel("start").Href("/").Type(navigationType).Build()) + + fis, _ := ioutil.ReadDir(dirpath) + for _, fi := range fis { + pathType := getPathType(path.Join(dirpath, fi.Name())) + feedBuilder = feedBuilder. + AddEntry(opds.EntryBuilder. + ID(req.URL.Path + fi.Name()). + Title(fi.Name()). + Updated(TimeNowFunc()). + Published(TimeNowFunc()). + AddLink(opds.LinkBuilder. + Rel(getRel(fi.Name(), pathType)). + Title(fi.Name()). + Href(getHref(req, fi.Name())). + Type(getType(fi.Name(), pathType)). + Build()). + Build()) + } + return feedBuilder.Build() +} + +func getRel(name string, pathType int) string { + if pathType == pathTypeDirOfFiles || pathType == pathTypeDirOfDirs { + return "subsection" + } + + ext := filepath.Ext(name) + if ext == ".png" || ext == ".jpg" || ext == ".jpeg" || ext == ".gif" { + return "http://opds-spec.org/image/thumbnail" + } + + // mobi, epub, etc + return "http://opds-spec.org/acquisition" +} + +func getType(name string, pathType int) string { + if pathType == pathTypeFile { + return mime.TypeByExtension(filepath.Ext(name)) + } + return "application/atom+xml;profile=opds-catalog;kind=acquisition" +} + +func getHref(req *http.Request, name string) string { + return path.Join(req.URL.RequestURI(), name) +} + +const ( + pathTypeFile = iota + pathTypeDirOfDirs + pathTypeDirOfFiles +) + +func getPathType(dirpath string) int { + fi, _ := os.Stat(dirpath) + if isFile(fi) { + return pathTypeFile + } + + fis, _ := ioutil.ReadDir(dirpath) + for _, fi := range fis { + if isFile(fi) { + return pathTypeDirOfFiles + } + } + // Directory of directories + return pathTypeDirOfDirs +} + +func isFile(fi os.FileInfo) bool { + return !fi.IsDir() +} + +func timeNow() time.Time { + return time.Now() +} diff --git a/internal/service/service_test.go b/internal/service/service_test.go new file mode 100644 index 0000000..e070cad --- /dev/null +++ b/internal/service/service_test.go @@ -0,0 +1,115 @@ +package service_test + +import ( + "io" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/dubyte/dir2opds/internal/service" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestHandler(t *testing.T) { + // pre-setup + nowFn := service.TimeNowFunc + defer func() { + service.TimeNowFunc = nowFn + }() + + tests := map[string]struct { + input string + want string + WantedContentType string + }{ + "feed (dir of folders )": {input: "/", want: feed, WantedContentType: "application/xml"}, + "acquisitionFeed(dir of files)": {input: "/mybook", want: acquisitionFeed, WantedContentType: "application/xml"}, + "servingAFile": {input: "/mybook/mybook.txt", want: "Fixture", WantedContentType: "text/plain; charset=utf-8"}, + } + + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + // setup + s := service.OPDS{"testdata", "", "", ""} + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, tc.input, nil) + service.TimeNowFunc = func() time.Time { + return time.Date(2020, 05, 25, 00, 00, 00, 0, time.UTC) + } + + // act + err := s.Handler(w, req) + require.NoError(t, err) + + // post act + resp := w.Result() + body, err := io.ReadAll(resp.Body) + require.NoError(t, err) + + // verify + assert.Equal(t, 200, resp.StatusCode) + assert.Equal(t, tc.WantedContentType, resp.Header.Get("Content-Type")) + assert.Equal(t, tc.want, string(body)) + }) + } + +} + +var feed = ` + + Catalog in / + / + + 2020-05-25T00:00:00+00:00 + + + + + emptyFolder + /emptyFolder + + 2020-05-25T00:00:00+00:00 + 2020-05-25T00:00:00+00:00 + + + mybook + /mybook + + 2020-05-25T00:00:00+00:00 + 2020-05-25T00:00:00+00:00 + + ` + +var acquisitionFeed = ` + + Catalog in /mybook + /mybook + + 2020-05-25T00:00:00+00:00 + + + + + mybook.epub + /mybookmybook.epub + + 2020-05-25T00:00:00+00:00 + 2020-05-25T00:00:00+00:00 + + + mybook.pdf + /mybookmybook.pdf + + 2020-05-25T00:00:00+00:00 + 2020-05-25T00:00:00+00:00 + + + mybook.txt + /mybookmybook.txt + + 2020-05-25T00:00:00+00:00 + 2020-05-25T00:00:00+00:00 + + ` diff --git a/internal/service/testdata/mybook/mybook.epub b/internal/service/testdata/mybook/mybook.epub new file mode 100644 index 0000000..b675c76 Binary files /dev/null and b/internal/service/testdata/mybook/mybook.epub differ diff --git a/internal/service/testdata/mybook/mybook.pdf b/internal/service/testdata/mybook/mybook.pdf new file mode 100644 index 0000000..8476a28 Binary files /dev/null and b/internal/service/testdata/mybook/mybook.pdf differ diff --git a/internal/service/testdata/mybook/mybook.txt b/internal/service/testdata/mybook/mybook.txt new file mode 100644 index 0000000..fdfaaa1 --- /dev/null +++ b/internal/service/testdata/mybook/mybook.txt @@ -0,0 +1 @@ +Fixture \ No newline at end of file diff --git a/main.go b/main.go index a9a2477..f6e2f07 100644 --- a/main.go +++ b/main.go @@ -18,21 +18,13 @@ package main import ( - "bytes" - "encoding/xml" "flag" "fmt" "io/ioutil" "log" - "mime" "net/http" - "os" - "path" - "path/filepath" - "time" - "github.com/dubyte/dir2opds/opds" - "golang.org/x/tools/blog/atom" + "github.com/dubyte/dir2opds/internal/service" ) var ( @@ -45,20 +37,6 @@ var ( debug = flag.Bool("debug", false, "If it is set it will log the requests") ) -type acquisitionFeed struct { - *atom.Feed - Dc string `xml:"xmlns:dc,attr"` - Opds string `xml:"xmlns:opds,attr"` -} - -func init() { - _ = mime.AddExtensionType(".mobi", "application/x-mobipocket-ebook") - _ = mime.AddExtensionType(".epub", "application/epub+zip") - _ = mime.AddExtensionType(".cbz", "application/x-cbz") - _ = mime.AddExtensionType(".cbr", "application/x-cbr") - _ = mime.AddExtensionType(".fb2", "text/fb2+xml") -} - func main() { flag.Parse() @@ -69,7 +47,9 @@ func main() { fmt.Println(startValues()) - http.HandleFunc("/", errorHandler(handler)) + s := service.OPDS{DirRoot: *dirRoot, Author: *author, AuthorEmail: *authorEmail, AuthorURI: *authorURI} + + http.HandleFunc("/", errorHandler(s.Handler)) log.Fatal(http.ListenAndServe(*host+":"+*port, nil)) } @@ -79,123 +59,6 @@ func startValues() string { return result } -func handler(w http.ResponseWriter, req *http.Request) error { - fPath := path.Join(*dirRoot, req.URL.Path) - - log.Printf("fPath:'%s'", fPath) - - fi, err := os.Stat(fPath) - if err != nil { - return err - } - - if isFile(fi) { - http.ServeFile(w, req, fPath) - return nil - } - - content, err := getContent(req, fPath) - if err != nil { - return err - } - - content = append([]byte(xml.Header), content...) - http.ServeContent(w, req, "feed.xml", time.Now(), bytes.NewReader(content)) - return nil -} - -func getContent(req *http.Request, dirpath string) (result []byte, err error) { - feed := makeFeed(dirpath, req) - if getPathType(dirpath) == pathTypeDirOfFiles { - acFeed := &acquisitionFeed{&feed, "http://purl.org/dc/terms/", "http://opds-spec.org/2010/catalog"} - result, err = xml.MarshalIndent(acFeed, " ", " ") - } else { - result, err = xml.MarshalIndent(feed, " ", " ") - } - return -} - -const navigationType = "application/atom+xml;profile=opds-catalog;kind=navigation" - -func makeFeed(dirpath string, req *http.Request) atom.Feed { - feedBuilder := opds.FeedBuilder. - ID(req.URL.Path). - Title("Catalog in " + req.URL.Path). - Author(opds.AuthorBuilder.Name(*author).Email(*authorEmail).URI(*authorURI).Build()). - Updated(time.Now()). - AddLink(opds.LinkBuilder.Rel("start").Href("/").Type(navigationType).Build()) - - fis, _ := ioutil.ReadDir(dirpath) - for _, fi := range fis { - pathType := getPathType(path.Join(dirpath, fi.Name())) - feedBuilder = feedBuilder. - AddEntry(opds.EntryBuilder. - ID(req.URL.Path + fi.Name()). - Title(fi.Name()). - Updated(time.Now()). - Published(time.Now()). - AddLink(opds.LinkBuilder. - Rel(getRel(fi.Name(), pathType)). - Title(fi.Name()). - Href(getHref(req, fi.Name())). - Type(getType(fi.Name(), pathType)). - Build()). - Build()) - } - return feedBuilder.Build() -} - -func getRel(name string, pathType int) string { - if pathType == pathTypeDirOfFiles || pathType == pathTypeDirOfDirs { - return "subsection" - } - - ext := filepath.Ext(name) - if ext == ".png" || ext == ".jpg" || ext == ".jpeg" || ext == ".gif" { - return "http://opds-spec.org/image/thumbnail" - } - - // mobi, epub, etc - return "http://opds-spec.org/acquisition" -} - -func getType(name string, pathType int) string { - if pathType == pathTypeFile { - return mime.TypeByExtension(filepath.Ext(name)) - } - return "application/atom+xml;profile=opds-catalog;kind=acquisition" -} - -func getHref(req *http.Request, name string) string { - return path.Join(req.URL.RequestURI(), name) -} - -const ( - pathTypeFile = iota - pathTypeDirOfDirs - pathTypeDirOfFiles -) - -func getPathType(dirpath string) int { - fi, _ := os.Stat(dirpath) - if isFile(fi) { - return pathTypeFile - } - - fis, _ := ioutil.ReadDir(dirpath) - for _, fi := range fis { - if isFile(fi) { - return pathTypeDirOfFiles - } - } - // Directory of directories - return pathTypeDirOfDirs -} - -func isFile(fi os.FileInfo) bool { - return !fi.IsDir() -} - func errorHandler(f func(http.ResponseWriter, *http.Request) error) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { err := f(w, r) diff --git a/main_test.go b/main_test.go new file mode 100644 index 0000000..0b552e5 --- /dev/null +++ b/main_test.go @@ -0,0 +1,57 @@ +package main + +import ( + "bytes" + "errors" + "log" + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestStartValues(t *testing.T) { + // pre-setup + oldHost, oldPort := *host, *port + defer func() { + *host = oldHost + *port = oldPort + }() + + // setup + *host = "wow.com" + *port = "42" + + // act + res := startValues() + + // assert + assert.Equal(t, "listening in: wow.com:42", res) + +} + +func TestErrorHandler(t *testing.T) { + // pre-setup + stdOutput := log.Writer() + defer func() { + log.SetOutput(stdOutput) + }() + + // setup + var buf bytes.Buffer + log.SetOutput(&buf) // to record what is logged + + f := func(http.ResponseWriter, *http.Request) error { + return errors.New("scary error") + } + h := errorHandler(f) + res := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/", nil) + + // act + h(res, req) + + // assert + assert.Contains(t, buf.String(), `handling "/": scary error`) +} diff --git a/opds/feed_builder.go b/opds/feed_builder.go index 5cda5a4..e74fcc1 100644 --- a/opds/feed_builder.go +++ b/opds/feed_builder.go @@ -7,6 +7,12 @@ import ( "golang.org/x/tools/blog/atom" ) +type AcquisitionFeed struct { + *atom.Feed + Dc string `xml:"xmlns:dc,attr"` + Opds string `xml:"xmlns:opds,attr"` +} + type feedBuilder builder.Builder func (f feedBuilder) Title(title string) feedBuilder {