forked from Ivasoft/opds-proxy
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:
16
.vscode/launch.json
vendored
Normal file
16
.vscode/launch.json
vendored
Normal 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}"
|
||||||
|
}
|
||||||
|
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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
9
html/home.html
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
{{define "content"}}
|
||||||
|
<ul>
|
||||||
|
{{range .}}
|
||||||
|
<li class="link">
|
||||||
|
<a href="/feed?q={{.URL}}">{{.Title}}</a>
|
||||||
|
</li>
|
||||||
|
{{end}}
|
||||||
|
</ul>
|
||||||
|
{{end}}
|
||||||
47
html/html.go
47
html/html.go
@@ -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
89
main.go
@@ -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)
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user