package proxy import ( "bytes" "encoding/json" "encoding/xml" "fmt" "io" "net/http" "net/url" "strings" "time" ) // RequestRecord holds the raw request we sent. type RequestRecord struct { Method string `json:"method"` URL string `json:"url"` Headers http.Header `json:"headers"` } // ResponseRecord holds raw response for the details panel. type ResponseRecord struct { Status int `json:"status"` StatusText string `json:"statusText"` Headers http.Header `json:"headers"` Body string `json:"body"` } // FetchResult is returned by Fetch: parsed feed plus raw request/response. type FetchResult struct { Request RequestRecord `json:"request"` Response ResponseRecord `json:"response"` Feed *Feed `json:"feed,omitempty"` IsImage bool `json:"isImage,omitempty"` // true when response is image/* (no feed parse) Error string `json:"error,omitempty"` } // Feed represents a generic OPDS 1.x/2.x feed for navigation. type Feed struct { Kind string `json:"kind"` // "navigation" | "acquisition" | "unknown" Title string `json:"title,omitempty"` ID string `json:"id,omitempty"` Links []Link `json:"links"` Publications []Pub `json:"publications,omitempty"` Navigation []Link `json:"navigation,omitempty"` Entries []Entry `json:"entries,omitempty"` // OPDS 1.x Metadata *Metadata `json:"metadata,omitempty"` } type Metadata struct { Title string `json:"title,omitempty"` ID string `json:"id,omitempty"` Updated string `json:"updated,omitempty"` } type Link struct { Href string `json:"href"` Type string `json:"type,omitempty"` Rel string `json:"rel,omitempty"` Title string `json:"title,omitempty"` Length int64 `json:"length,omitempty"` // OPDS-PS (p5) extension: stream/page info Count string `json:"p5:count,omitempty"` // total pages LastRead string `json:"p5:lastRead,omitempty"` // last read page LastReadDate string `json:"p5:lastReadDate,omitempty"` // when last read } type Pub struct { Metadata *Metadata `json:"metadata,omitempty"` Links []Link `json:"links,omitempty"` Title string `json:"title,omitempty"` } type Entry struct { Title string `json:"title,omitempty"` ID string `json:"id,omitempty"` Links []Link `json:"links,omitempty"` } // Client performs HTTP requests and records request/response. type Client struct { httpClient *http.Client } func NewClient() *Client { return &Client{ httpClient: &http.Client{ Timeout: 30 * time.Second, Transport: &http.Transport{}, }, } } // Fetch fetches targetURL (GET or POST with optional body), records request/response, and parses OPDS feed. func (c *Client) Fetch(method, targetURL string, headers http.Header, body []byte) (*FetchResult, error) { u, err := url.Parse(targetURL) if err != nil { return &FetchResult{Error: "invalid URL: " + err.Error()}, nil } if u.Scheme == "" { u.Scheme = "https" } targetURL = u.String() var bodyReader io.Reader if len(body) > 0 && (method == "POST" || method == "PUT") { bodyReader = bytes.NewReader(body) } req, err := http.NewRequest(method, targetURL, bodyReader) if err != nil { return &FetchResult{Error: err.Error()}, nil } // Default Accept for OPDS req.Header.Set("Accept", "application/opds+json, application/opds+xml, application/json, application/xml, text/xml, */*") req.Header.Set("User-Agent", "OPDS-PS-Explorer/1.0") for k, v := range headers { if strings.EqualFold(k, "Host") { continue } for _, vv := range v { req.Header.Add(k, vv) } } reqRecord := RequestRecord{ Method: req.Method, URL: req.URL.String(), Headers: cloneHeader(req.Header), } resp, err := c.httpClient.Do(req) if err != nil { return &FetchResult{ Request: reqRecord, Error: "request failed: " + err.Error(), }, nil } defer resp.Body.Close() respBody, _ := io.ReadAll(resp.Body) respRecord := ResponseRecord{ Status: resp.StatusCode, StatusText: resp.Status, Headers: cloneHeader(resp.Header), Body: string(respBody), } result := &FetchResult{ Request: reqRecord, Response: respRecord, } ct := resp.Header.Get("Content-Type") if resp.StatusCode >= 200 && resp.StatusCode < 300 { if strings.HasPrefix(ct, "image/") { result.IsImage = true // Do not parse image response as feed; leave body as-is in response for debugging } else { result.Feed = parseFeed(respBody, ct) } } return result, nil } // ProxyStream fetches targetURL and streams the response to w (for images/binary). Caller must not write to w before calling. func (c *Client) ProxyStream(targetURL string, w http.ResponseWriter) (int, error) { u, err := url.Parse(targetURL) if err != nil { return 0, err } if u.Scheme == "" { u.Scheme = "https" } targetURL = u.String() req, err := http.NewRequest(http.MethodGet, targetURL, nil) if err != nil { return 0, err } req.Header.Set("User-Agent", "OPDS-PS-Explorer/1.0") req.Header.Set("Accept", "*/*") resp, err := c.httpClient.Do(req) if err != nil { return 0, err } defer resp.Body.Close() for k, v := range resp.Header { for _, vv := range v { w.Header().Add(k, vv) } } w.WriteHeader(resp.StatusCode) n, err := io.Copy(w, resp.Body) return int(n), err } func linkFromXML(href, typ, rel, title, count, lastRead, lastReadDate string) Link { return Link{ Href: href, Type: typ, Rel: rel, Title: title, Count: count, LastRead: lastRead, LastReadDate: lastReadDate, } } func cloneHeader(h http.Header) http.Header { h2 := make(http.Header) for k, v := range h { h2[k] = append([]string(nil), v...) } return h2 } func parseFeed(body []byte, contentType string) *Feed { feed := &Feed{} switch { case strings.Contains(contentType, "json"): // OPDS 2.0 or JSON feed var opds2 struct { Metadata *Metadata `json:"metadata"` Links []Link `json:"links"` Publications []struct { Metadata *Metadata `json:"metadata"` Links []Link `json:"links"` } `json:"publications"` Navigation []Link `json:"navigation"` } if err := json.Unmarshal(body, &opds2); err != nil { feed.Kind = "unknown" feed.Links = []Link{} return feed } feed.Kind = "acquisition" if len(opds2.Navigation) > 0 { feed.Kind = "navigation" } if opds2.Metadata != nil { feed.Metadata = opds2.Metadata feed.Title = opds2.Metadata.Title feed.ID = opds2.Metadata.ID } feed.Links = opds2.Links feed.Navigation = opds2.Navigation for _, p := range opds2.Publications { title := "" if p.Metadata != nil { title = p.Metadata.Title } feed.Publications = append(feed.Publications, Pub{ Metadata: p.Metadata, Links: p.Links, Title: title, }) } return feed case strings.Contains(contentType, "xml"): // OPDS 1.x var opds1 struct { XMLName xml.Name `xml:"feed"` Title string `xml:"title"` ID string `xml:"id"` Updated string `xml:"updated"` Link []struct { Href string `xml:"href,attr"` Type string `xml:"type,attr"` Rel string `xml:"rel,attr"` Title string `xml:"title,attr"` P5Count string `xml:"http://vaemendis.net/opds-pse/ns count,attr"` P5LastRead string `xml:"http://vaemendis.net/opds-pse/ns lastRead,attr"` P5LastReadDate string `xml:"http://vaemendis.net/opds-pse/ns lastReadDate,attr"` } `xml:"link"` Entry []struct { Title string `xml:"title"` ID string `xml:"id"` Link []struct { Href string `xml:"href,attr"` Type string `xml:"type,attr"` Rel string `xml:"rel,attr"` Title string `xml:"title,attr"` P5Count string `xml:"http://vaemendis.net/opds-pse/ns count,attr"` P5LastRead string `xml:"http://vaemendis.net/opds-pse/ns lastRead,attr"` P5LastReadDate string `xml:"http://vaemendis.net/opds-pse/ns lastReadDate,attr"` } `xml:"link"` } `xml:"entry"` } if err := xml.Unmarshal(body, &opds1); err != nil { feed.Kind = "unknown" return feed } feed.Kind = "acquisition" feed.Title = opds1.Title feed.ID = opds1.ID feed.Metadata = &Metadata{Title: opds1.Title, ID: opds1.ID, Updated: opds1.Updated} for _, l := range opds1.Link { feed.Links = append(feed.Links, linkFromXML(l.Href, l.Type, l.Rel, l.Title, l.P5Count, l.P5LastRead, l.P5LastReadDate)) if l.Rel == "subsection" || l.Rel == "start" || l.Rel == "self" { feed.Navigation = append(feed.Navigation, linkFromXML(l.Href, l.Type, l.Rel, l.Title, l.P5Count, l.P5LastRead, l.P5LastReadDate)) } } for _, e := range opds1.Entry { entry := Entry{Title: e.Title, ID: e.ID} for _, l := range e.Link { entry.Links = append(entry.Links, linkFromXML(l.Href, l.Type, l.Rel, l.Title, l.P5Count, l.P5LastRead, l.P5LastReadDate)) } feed.Entries = append(feed.Entries, entry) } return feed } feed.Kind = "unknown" return feed } // FormatHeaders returns headers as a string for display. func FormatHeaders(h http.Header) string { var b strings.Builder for k, v := range h { for _, vv := range v { fmt.Fprintf(&b, "%s: %s\n", k, vv) } } return b.String() }