opds-explorer/internal/proxy/client.go
Vibe Kanban d41f6859f4 OPDS-PS Explorer (vibe-kanban 1dc76aea)
Create a server (go) and webapp (vue) development tool to explore OPDS-PS capable servers. This tool is intended for development purposes only, it should allow to navigate the server, view raw requests and responses and interact with the API in a general way. I think it's best if we provide a columns layout to navigate the OPDS-PS server much like the macos finder does, with a panel with details to the far right for technical information.

Here's the SPEC: https://specs.opds.io/
2026-02-11 09:36:23 +01:00

334 lines
9.2 KiB
Go

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()
}