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 {