refactor: home page / url passing

We now allow multiple OPDS feeds to be pre-defined and displayed
on the homepage. As a result we need to pass the feed navigation
URLs via query parameter rather than a subpath which would only
support proxying to a single OPDS feed.

The feed is passed via the q= query parameter and any relative
links from the OPDS XML are resolved to a complete URL with
domain / scheme.

We also check the "Content-Type" header in the response
received from the OPDS feed to determine whether to parse
an OPDS catalog or just proxy the raw response back (images / files).
This commit is contained in:
Evan Buss
2024-07-06 16:06:08 -04:00
parent 8e42b71dc7
commit c63b20551f
6 changed files with 123 additions and 51 deletions

16
.vscode/launch.json vendored Normal file
View File

@@ -0,0 +1,16 @@
{
// Use IntelliSense to learn about possible attributes.
// Hover to view descriptions of existing attributes.
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
"version": "0.2.0",
"configurations": [
{
"name": "Launch Package",
"type": "go",
"request": "launch",
"mode": "auto",
"program": "${workspaceFolder}"
}
]
}

View File

@@ -25,8 +25,9 @@
{{end}} {{end}}
<div class="nav-controls"> <div class="nav-controls">
<a tabindex="-1" href="/">Home</a>
{{range .Navigation}} {{range .Navigation}}
<a tabindex="-1" href="{{.Href}}">{{.Label}}</a> <a tabindex="-1" href="?q={{.Href}}">{{.Label}}</a>
{{end}} {{end}}
</div> </div>
</nav> </nav>
@@ -35,14 +36,14 @@
{{range .Links}} {{range .Links}}
<li class="link"> <li class="link">
{{if .ImageURL}} {{if .ImageURL}}
<img src="/acquisition{{.ImageURL}}" alt="{{.Title}}" height="40" /> <img src="?q={{.ImageURL}}" alt="{{.Title}}" height="40" />
{{end}} {{end}}
<div class="info"> <div class="info">
{{if .IsDownload}} {{if .IsDownload}}
<a href="/acquisition{{.Href}}" download>{{.Title}}</a> <a href="?q={{.Href}}" download>{{.Title}}</a>
{{else}} {{else}}
<a href="{{.Href}}">{{.Title}}</a> <a href="?q={{.Href}}">{{.Title}}</a>
{{end}} {{end}}
<p>{{.Author}}</p> <p>{{.Author}}</p>
</div> </div>

9
html/home.html Normal file
View File

@@ -0,0 +1,9 @@
{{define "content"}}
<ul>
{{range .}}
<li class="link">
<a href="/feed?q={{.URL}}">{{.Title}}</a>
</li>
{{end}}
</ul>
{{end}}

View File

@@ -2,8 +2,11 @@ package html
import ( import (
"embed" "embed"
"fmt"
"html/template" "html/template"
"io" "io"
"log"
"net/url"
"strings" "strings"
"github.com/evan-buss/kobo-opds-proxy/opds" "github.com/evan-buss/kobo-opds-proxy/opds"
@@ -13,6 +16,7 @@ import (
var files embed.FS var files embed.FS
var ( var (
home = parse("home.html")
feed = parse("feed.html") feed = parse("feed.html")
) )
@@ -21,6 +25,7 @@ func parse(file string) *template.Template {
} }
type FeedParams struct { type FeedParams struct {
URL string
Feed *opds.Feed Feed *opds.Feed
} }
@@ -44,7 +49,7 @@ type LinkViewModel struct {
IsDownload bool IsDownload bool
} }
func convertFeed(feed *opds.Feed) FeedViewModel { func convertFeed(url string, feed *opds.Feed) FeedViewModel {
vm := FeedViewModel{ vm := FeedViewModel{
Title: feed.Title, Title: feed.Title,
Search: "", Search: "",
@@ -54,25 +59,25 @@ func convertFeed(feed *opds.Feed) FeedViewModel {
for _, link := range feed.Links { for _, link := range feed.Links {
if link.Rel == "search" { if link.Rel == "search" {
vm.Search = link.Href vm.Search = resolveHref(url, link.Href)
} }
if link.TypeLink == "application/atom+xml;type=feed;profile=opds-catalog" { if link.TypeLink == "application/atom+xml;type=feed;profile=opds-catalog" {
vm.Navigation = append(vm.Navigation, NavigationViewModel{ vm.Navigation = append(vm.Navigation, NavigationViewModel{
Href: link.Href, Href: resolveHref(url, link.Href),
Label: strings.ToUpper(link.Rel[:1]) + link.Rel[1:], Label: strings.ToUpper(link.Rel[:1]) + link.Rel[1:],
}) })
} }
} }
for _, entry := range feed.Entries { for _, entry := range feed.Entries {
vm.Links = append(vm.Links, constructLink(entry)) vm.Links = append(vm.Links, constructLink(url, entry))
} }
return vm return vm
} }
func constructLink(entry opds.Entry) LinkViewModel { func constructLink(url string, entry opds.Entry) LinkViewModel {
vm := LinkViewModel{ vm := LinkViewModel{
Title: entry.Title, Title: entry.Title,
Content: entry.Content.Content, Content: entry.Content.Content,
@@ -87,13 +92,12 @@ func constructLink(entry opds.Entry) LinkViewModel {
for _, link := range entry.Links { for _, link := range entry.Links {
vm.IsDownload = link.IsDownload() vm.IsDownload = link.IsDownload()
if link.IsNavigation() || link.IsDownload() { if link.IsNavigation() || link.IsDownload() {
vm.Href = link.Href vm.Href = resolveHref(url, link.Href)
} }
// Prefer the first "thumbnail" image we find // Prefer the first "thumbnail" image we find
if vm.ImageURL == "" && link.IsImage("thumbnail") { if vm.ImageURL == "" && link.IsImage("thumbnail") {
vm.ImageURL = link.Href vm.ImageURL = resolveHref(url, link.Href)
} }
} }
@@ -101,7 +105,7 @@ func constructLink(entry opds.Entry) LinkViewModel {
if vm.ImageURL == "" { if vm.ImageURL == "" {
for _, link := range entry.Links { for _, link := range entry.Links {
if link.IsImage("") { if link.IsImage("") {
vm.ImageURL = link.Href vm.ImageURL = resolveHref(url, link.Href)
break break
} }
} }
@@ -109,17 +113,40 @@ func constructLink(entry opds.Entry) LinkViewModel {
return vm return vm
} }
func resolveHref(feedUrl string, relativePath string) string {
baseUrl, err := url.Parse(feedUrl)
if err != nil {
log.Fatal(err)
}
relativeUrl, err := url.Parse(relativePath)
if err != nil {
log.Fatal(err)
}
resolved := baseUrl.ResolveReference(relativeUrl).String()
fmt.Println("Resolved URL: ", resolved)
return resolved
}
func Feed(w io.Writer, p FeedParams, partial string) error { func Feed(w io.Writer, p FeedParams, partial string) error {
if partial == "" { if partial == "" {
partial = "layout.html" partial = "layout.html"
} }
vm := convertFeed(p.Feed) vm := convertFeed(p.URL, p.Feed)
return feed.ExecuteTemplate(w, partial, vm) return feed.ExecuteTemplate(w, partial, vm)
} }
type FeedInfo struct {
Title string
URL string
}
func Home(w io.Writer, p []FeedInfo) error {
return home.ExecuteTemplate(w, "layout.html", p)
}
func StaticFiles() embed.FS { func StaticFiles() embed.FS {
return files return files
} }

89
main.go
View File

@@ -4,6 +4,7 @@ import (
"io" "io"
"log" "log"
"log/slog" "log/slog"
"mime"
"net/http" "net/http"
"time" "time"
@@ -11,55 +12,78 @@ import (
"github.com/evan-buss/kobo-opds-proxy/opds" "github.com/evan-buss/kobo-opds-proxy/opds"
) )
const baseUrl = "http://calibre.terminus"
func main() { func main() {
router := http.NewServeMux() router := http.NewServeMux()
router.HandleFunc("GET /", handleFeed()) router.HandleFunc("GET /{$}", handleHome())
router.HandleFunc("GET /acquisition/{path...}", handleAcquisition()) router.HandleFunc("GET /feed", handleFeed())
router.Handle("GET /static/", http.FileServer(http.FS(html.StaticFiles()))) router.Handle("GET /static/", http.FileServer(http.FS(html.StaticFiles())))
slog.Info("Starting server", slog.String("port", "8080")) slog.Info("Starting server", slog.String("port", "8080"))
log.Fatal(http.ListenAndServe(":8080", router)) log.Fatal(http.ListenAndServe(":8080", router))
} }
func handleFeed() http.HandlerFunc { func handleHome() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
opdsUrl := baseUrl + r.URL.RequestURI() slog.Info("Rendering home page")
feeds := []html.FeedInfo{
slog.Info("Fetching feed", slog.String("path", opdsUrl)) {
Title: "Evan's Library",
feed, err := fetchFeed(opdsUrl) URL: "http://calibre.terminus/opds",
if err != nil { },
slog.Error("Failed to parse feed", slog.String("path", r.URL.RawPath), slog.Any("error", err)) {
http.Error(w, "An unexpected error occurred", http.StatusInternalServerError) Title: "Project Gutenberg",
return URL: "https://m.gutenberg.org/ebooks.opds/",
} },
err = html.Feed(w, html.FeedParams{Feed: feed}, "")
if err != nil {
slog.Error("Failed to render feed", slog.String("path", r.URL.RawPath), slog.Any("error", err))
http.Error(w, "An unexpected error occurred", http.StatusInternalServerError)
return
} }
html.Home(w, feeds)
} }
} }
func handleAcquisition() http.HandlerFunc { func handleFeed() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
opdsUrl := baseUrl + "/" + r.PathValue("path") queryURL := r.URL.Query().Get("q")
resp, err := fetchFromUrl(opdsUrl) if queryURL == "" {
if err != nil { http.Error(w, "No feed specified", http.StatusBadRequest)
slog.Error("Failed to fetch acquisition", slog.String("path", opdsUrl), slog.Any("error", err))
http.Error(w, "An unexpected error occurred", http.StatusInternalServerError)
return return
} }
slog.Info("Fetching feed", slog.String("path", queryURL))
resp, err := fetchFromUrl(queryURL)
if err != nil {
handleError(r, w, "Failed to fetch feed", err)
return
}
defer resp.Body.Close()
contentType := resp.Header.Get("Content-Type")
mimeType, _, err := mime.ParseMediaType(contentType)
if err != nil {
handleError(r, w, "Failed to parse content type", err)
}
if mimeType == "application/atom+xml" {
feed, err := opds.ParseFeed(resp.Body)
if err != nil {
handleError(r, w, "Failed to parse feed", err)
return
}
err = html.Feed(w, html.FeedParams{URL: queryURL, Feed: feed}, "")
if err != nil {
handleError(r, w, "Failed to render feed", err)
return
}
}
for k, v := range resp.Header { for k, v := range resp.Header {
w.Header()[k] = v w.Header()[k] = v
} }
io.Copy(w, resp.Body) io.Copy(w, resp.Body)
} }
} }
@@ -77,12 +101,7 @@ func fetchFromUrl(url string) (*http.Response, error) {
return client.Do(req) return client.Do(req)
} }
func fetchFeed(url string) (*opds.Feed, error) { func handleError(r *http.Request, w http.ResponseWriter, message string, err error) {
r, err := fetchFromUrl(url) slog.Error(message, slog.String("path", r.URL.RawPath), slog.Any("error", err))
if err != nil { http.Error(w, "An unexpected error occurred", http.StatusInternalServerError)
return nil, err
}
defer r.Body.Close()
return opds.ParseFeed(r.Body)
} }

View File

@@ -22,7 +22,7 @@ func (link Link) IsDownload() bool {
} }
func (link Link) IsImage(category string) bool { func (link Link) IsImage(category string) bool {
if strings.HasPrefix(link.TypeLink, "image") { if strings.HasPrefix(link.TypeLink, "image") && !strings.HasPrefix(link.Href, "data") {
return strings.Contains(link.Rel, category) return strings.Contains(link.Rel, category)
} }
@@ -30,5 +30,5 @@ func (link Link) IsImage(category string) bool {
} }
func (link Link) IsNavigation() bool { func (link Link) IsNavigation() bool {
return link.TypeLink == "application/atom+xml;type=feed;profile=opds-catalog" return link.TypeLink == "application/atom+xml;type=feed;profile=opds-catalog" || link.Rel == "subsection"
} }