Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ services:
# - OPDS__PORT=5228
# - OPDS__FEEDS__0__NAME=Some Feed
# - OPDS__FEEDS__0__URL=http://some-feed.com/opds
# - OPDS__FEEDS__0__USER_AGENT=OPDS-Proxy/1.0
# - OPDS__FEEDS__0__AUTH__USERNAME=user
# - OPDS__FEEDS__0__AUTH__PASSWORD=password
# - OPDS__FEEDS__0__AUTH__LOCAL_ONLY=true
Expand Down Expand Up @@ -84,6 +85,10 @@ auth:
feeds:
- name: Some Feed
url: http://some-feed.com/opds
# (Optional) Feed Custom User Agent
# If present, requests to this feed will use this custom User-Agent.
# This is useful for feeds that require a specific User-Agent to function.
user_agent: OPDS-Proxy/1.0
# (Optional) Feed Authentication Credentials
# If present, users will not be prompted for credentials in the web interface.
# The server will take care of sending these with requests to the feed URL.
Expand Down
1 change: 1 addition & 0 deletions config.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,5 +2,6 @@ debug: true
feeds:
- name: "Project Gutenberg"
url: "https://www.gutenberg.org/ebooks/search.opds/"
user_agent: "OPDS-Proxy/1.0"
- name: "Anarchist Library"
url: "https://theanarchistlibrary.org/opds"
74 changes: 69 additions & 5 deletions handlers/feed.go
Original file line number Diff line number Diff line change
Expand Up @@ -60,11 +60,26 @@ func (h *FeedHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
return
}

var customUserAgent string
if parsedReqURL, err := url.Parse(resolvedURL); err == nil {
for _, feed := range h.feeds {
if parsedFeedURL, err := url.Parse(feed.Url); err == nil {
if parsedFeedURL.Hostname() == parsedReqURL.Hostname() && feed.UserAgent != "" {
customUserAgent = feed.UserAgent
break
}
}
}
}

creds := auth.GetCredentials(resolvedURL, r, h.feeds, h.s)
resp, err := httpx.Fetch(resolvedURL, 10, func(req *http.Request) {
if creds != nil {
req.SetBasicAuth(creds.Username, creds.Password)
}
if customUserAgent != "" {
req.Header.Set("User-Agent", customUserAgent)
}
})
if err != nil {
http.Error(w, fmt.Sprintf("Failed to fetch %q: %v", resolvedURL, err), http.StatusBadGateway)
Expand Down Expand Up @@ -126,14 +141,19 @@ func (h *FeedHandler) resolveQueryURL(queryURL, searchTerm string) (string, erro
return repl.Replace(queryURL), nil
}

if tmpl, err := opds.ResolveOpenSearchTemplate(queryURL); err == nil && tmpl != "" {
return repl.Replace(tmpl), nil
// Fall back to appending the search parameter for non-OpenSearch servers
u, err := url.Parse(queryURL)
if err == nil {
q := u.Query()
q.Set("query", searchTerm)
u.RawQuery = q.Encode()
return u.String(), nil
}

return queryURL, nil
}

func (h *FeedHandler) serveAtom(w http.ResponseWriter, r *http.Request, resp *http.Response, url string, deviceType device.DeviceType) error {
func (h *FeedHandler) serveAtom(w http.ResponseWriter, r *http.Request, resp *http.Response, feedUrl string, deviceType device.DeviceType) error {
// Read the body so we can fall back to forwarding it on parse/render errors
body, err := io.ReadAll(resp.Body)
if err != nil {
Expand All @@ -150,6 +170,50 @@ func (h *FeedHandler) serveAtom(w http.ResponseWriter, r *http.Request, resp *ht
return nil
}

// Intercept OpenSearch descriptor links and resolve them to URL templates
for i, link := range feed.Links {
if link.Rel == "search" && link.TypeLink == "application/opensearchdescription+xml" {
base, err := url.Parse(feedUrl)
if err == nil {
if rel, err := url.Parse(link.Href); err == nil {
osdURL := base.ResolveReference(rel).String()

var customUserAgent string
if parsedReqURL, err := url.Parse(osdURL); err == nil {
for _, feedCfg := range h.feeds {
if parsedFeedURL, err := url.Parse(feedCfg.Url); err == nil {
if parsedFeedURL.Hostname() == parsedReqURL.Hostname() && feedCfg.UserAgent != "" {
customUserAgent = feedCfg.UserAgent
break
}
}
}
}

creds := auth.GetCredentials(osdURL, r, h.feeds, h.s)
osdResp, err := httpx.Fetch(osdURL, 10, func(req *http.Request) {
if creds != nil {
req.SetBasicAuth(creds.Username, creds.Password)
}
if customUserAgent != "" {
req.Header.Set("User-Agent", customUserAgent)
}
})

if err == nil && osdResp.StatusCode >= 200 && osdResp.StatusCode < 300 {
if tmpl, err := opds.ParseOpenSearchTemplate(osdResp.Body); err == nil && tmpl != "" {
feed.Links[i].Href = tmpl
feed.Links[i].TypeLink = "application/atom+xml"
}
osdResp.Body.Close()
} else if err == nil {
osdResp.Body.Close()
}
}
}
}
}

entryID := r.URL.Query().Get("id")
if entryID != "" {
var entry opds.Entry
Expand All @@ -165,7 +229,7 @@ func (h *FeedHandler) serveAtom(w http.ResponseWriter, r *http.Request, resp *ht
}

params := view.EntryParams{
URL: url,
URL: feedUrl,
Feed: feed,
Entry: entry,
DeviceType: deviceType,
Expand All @@ -176,7 +240,7 @@ func (h *FeedHandler) serveAtom(w http.ResponseWriter, r *http.Request, resp *ht
return nil
}

params := view.FeedParams{URL: url, Feed: feed}
params := view.FeedParams{URL: feedUrl, Feed: feed}
view.Render(w, func(buf io.Writer) error { return view.Feed(buf, params) })
return nil
}
Expand Down
7 changes: 4 additions & 3 deletions internal/auth/util.go
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,8 @@ type FeedAuth struct {
}

type FeedConfig struct {
Name string
Url string
Auth *FeedAuth
Name string
Url string
UserAgent string
Auth *FeedAuth
}
2 changes: 2 additions & 0 deletions internal/formats/formats.go
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,8 @@ func FormatByMimeType(mimeType string) (Format, bool) {
// Legacy/alternative MIME types
"application/mobi": MOBI,
"application/x-epub+zip": EPUB,
"application/xml": ATOM,
"text/xml": ATOM,
}

format, exists := formats[mimeType]
Expand Down
58 changes: 58 additions & 0 deletions internal/formats/formats_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
package formats

import (
"testing"
)

func TestFormatByMimeType(t *testing.T) {
tests := []struct {
name string
mimeType string
want Format
wantOk bool
}{
{
name: "ATOM from standard application/atom+xml",
mimeType: "application/atom+xml",
want: ATOM,
wantOk: true,
},
{
name: "ATOM from generic application/xml",
mimeType: "application/xml",
want: ATOM,
wantOk: true,
},
{
name: "ATOM from generic text/xml",
mimeType: "text/xml",
want: ATOM,
wantOk: true,
},
{
name: "EPUB from standard application/epub+zip",
mimeType: "application/epub+zip",
want: EPUB,
wantOk: true,
},
{
name: "Unknown format",
mimeType: "application/unknown",
want: Format{},
wantOk: false,
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, ok := FormatByMimeType(tt.mimeType)
if ok != tt.wantOk {
t.Errorf("FormatByMimeType() ok = %v, wantOk %v", ok, tt.wantOk)
return
}
if ok && got != tt.want {
t.Errorf("FormatByMimeType() got = %v, want %v", got, tt.want)
}
})
}
}
7 changes: 4 additions & 3 deletions main.go
Original file line number Diff line number Diff line change
Expand Up @@ -37,9 +37,10 @@ type AuthConfig struct {
}

type FeedConfig struct {
Name string `koanf:"name"`
Url string `koanf:"url"`
Auth *FeedConfigAuth `koanf:"auth"`
Name string `koanf:"name"`
Url string `koanf:"url"`
UserAgent string `koanf:"user_agent"`
Auth *FeedConfigAuth `koanf:"auth"`
}

type FeedConfigAuth struct {
Expand Down
2 changes: 1 addition & 1 deletion opds/entry_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ func TestEntryUnmarshal(t *testing.T) {
Formats: AZW3,EPUB<br/>
<div><p>A timeless romance following Elizabeth Bennet, a strong-willed young woman, and Mr. Darcy, a proud and wealthy gentleman. Set in Georgian England, the novel explores themes of love, marriage, social class, and personal growth through wit and humor.</p><p>When Elizabeth first meets Mr. Darcy at a ball, she finds him arrogant and disagreeable. Meanwhile, she is charmed by the dashing Mr. Wickham, who tells her tales of Darcy's alleged misconduct. As the story unfolds, Elizabeth discovers that first impressions can be deceiving, and that pride and prejudice can blind us to true character.</p><p>Through a series of misunderstandings, revelations, and personal growth, both Elizabeth and Darcy must overcome their initial judgments to find true love. This beloved classic remains one of the most popular novels in English literature.</p></div></div>
</content>
<link type="application/x-mobi8-ebook" href="/get/azw3/313/books" rel="http://opds-spec.org/acquisition" length="825549" mtime="2021-04-08T00:38:35+00:00"/>
<link type="application/x-mobi8-ebook" href="/get/azw3/313/books" rel="http://opds-spec.org/acquisition/open-access" length="825549" mtime="2021-04-08T00:38:35+00:00"/>
<link type="application/epub+zip" href="/get/epub/313/books" rel="http://opds-spec.org/acquisition" length="642930" mtime="2021-04-08T00:38:23+00:00"/>
<link type="image/jpeg" href="/get/cover/313/books" rel="http://opds-spec.org/cover"/>
<link type="image/jpeg" href="/get/thumb/313/books" rel="http://opds-spec.org/thumbnail"/>
Expand Down
2 changes: 1 addition & 1 deletion opds/link.go
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ type Links []Link

// IsDownload checks if the link is an acquisition/download link
func (l Link) IsDownload() bool {
return l.Rel == AcquisitionFeedRel
return strings.HasPrefix(l.Rel, AcquisitionFeedRel)
}

// IsImage checks if the link is an image with optional category filtering
Expand Down
45 changes: 17 additions & 28 deletions opds/opensearch.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,6 @@ import (
"encoding/xml"
"fmt"
"io"
"net/http"
"time"
)

// OpenSearchDescription represents an OpenSearch Description Document (OSDD)
Expand All @@ -25,49 +23,40 @@ type OSDUrl struct {
Template string `xml:"template,attr"`
}

// ResolveOpenSearchTemplate fetches an OSDD from the given URL and returns the
// Atom/OPDS template URL to use for search requests. It prefers
// "application/atom+xml;profile=opds-catalog" then falls back to
// "application/atom+xml" if needed.
func ResolveOpenSearchTemplate(osdURL string) (string, error) {
client := &http.Client{Timeout: 10 * time.Second}

// simplified request: use client.Get since no headers are needed
resp, err := client.Get(osdURL)
if err != nil {
return "", fmt.Errorf("failed to fetch OpenSearch description from %q: %w", osdURL, err)
}
defer resp.Body.Close()

if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return "", fmt.Errorf("unexpected status fetching OSDD: %s", resp.Status)
}

return parseOpenSearchTemplate(resp.Body)
}

// parseOpenSearchTemplate parses an OpenSearch Description XML from r and
// ParseOpenSearchTemplate parses an OpenSearch Description XML from r and
// returns the preferred Atom/OPDS search template URL.
func parseOpenSearchTemplate(r io.Reader) (string, error) {
func ParseOpenSearchTemplate(r io.Reader) (string, error) {
// Stream decode to avoid buffering entire body in memory
var d OpenSearchDescription
decoder := xml.NewDecoder(r)
if err := decoder.Decode(&d); err != nil && err != io.EOF {
return "", fmt.Errorf("failed to parse OpenSearch description: %w", err)
}

// First pass: look for the OPDS profile type
// First pass: look for the OPDS profile type with kind=acquisition
for _, u := range d.Urls {
if u.Type == "application/atom+xml;profile=opds-catalog;kind=acquisition" && u.Template != "" {
return u.Template, nil
}
}
// Second pass: look for the OPDS profile type
for _, u := range d.Urls {
if u.Type == "application/atom+xml;profile=opds-catalog" && u.Template != "" {
return u.Template, nil
}
}
// Second pass: any atom+xml template
// Third pass: any atom+xml template
for _, u := range d.Urls {
if u.Type == "application/atom+xml" && u.Template != "" {
return u.Template, nil
}
}
// Fourth pass: any template at all
for _, u := range d.Urls {
if u.Template != "" {
return u.Template, nil
}
}

return "", fmt.Errorf("no suitable Atom template found in OSDD")
return "", fmt.Errorf("no suitable template found in OSDD")
}
Loading