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/
334 lines
9.2 KiB
Go
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()
|
|
}
|