Files

184 lines
5.1 KiB
Go

package app
import (
"bytes"
"compress/gzip"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/http/httputil"
"net/url"
"strconv"
"strings"
"time"
)
const (
storeAPIHost = "storeapi.kobo.com"
maxInitializationBody = 8 << 20
)
var koboTransport = func() *http.Transport {
transport := http.DefaultTransport.(*http.Transport).Clone()
transport.ResponseHeaderTimeout = 20 * time.Second
return transport
}()
func (a *App) HandleStoreProxy(w http.ResponseWriter, r *http.Request) {
a.serveKoboProxy(w, r, &url.URL{Scheme: "https", Host: storeAPIHost}, "")
}
// serveKoboProxy expects the public route prefix to have been removed from r.URL.
func (a *App) serveKoboProxy(w http.ResponseWriter, r *http.Request, target *url.URL, deviceToken string) {
path := r.URL.Path
if path == "" {
path = "/"
}
proxy := a.newKoboProxy(target, path)
if deviceToken != "" {
originalDirector := proxy.Director
proxy.Director = func(req *http.Request) {
originalDirector(req)
// Book services can build absolute URLs from forwarded headers.
// Use the upstream origin, not the public ingress host.
req.Header.Del("Forwarded")
req.Header.Del("X-Forwarded-Port")
req.Header.Del("X-Forwarded-Prefix")
req.Header.Set("X-Forwarded-Host", target.Host)
req.Header.Set("X-Forwarded-Proto", target.Scheme)
}
}
if path == "/v1/initialization" {
origin, err := a.publicOrigin(r)
if err != nil {
http.Error(w, "Invalid public origin", http.StatusBadGateway)
return
}
configureInitializationProxy(proxy, origin+"/instapaper-proxy/instapaper")
}
proxy.ServeHTTP(w, r)
}
func (a *App) newKoboProxy(target *url.URL, path string) *httputil.ReverseProxy {
proxy := httputil.NewSingleHostReverseProxy(target)
originalDirector := proxy.Director
proxy.Director = func(req *http.Request) {
req.URL.Path = path
originalDirector(req)
req.Host = target.Host
}
if a.ProxyTransport != nil {
proxy.Transport = a.ProxyTransport
} else {
proxy.Transport = koboTransport
}
proxy.ErrorHandler = func(w http.ResponseWriter, _ *http.Request, err error) {
a.Logger.Errorf("Kobo upstream proxy failed: %T", err)
http.Error(w, "Bad gateway", http.StatusBadGateway)
}
return proxy
}
func configureInitializationProxy(proxy *httputil.ReverseProxy, instapaperURL string) {
originalDirector := proxy.Director
proxy.Director = func(req *http.Request) {
originalDirector(req)
// Request a complete document so its body can be rewritten.
req.Header.Set("Accept-Encoding", "identity")
req.Header.Del("If-None-Match")
req.Header.Del("If-Modified-Since")
req.Header.Del("Range")
req.Header.Del("If-Range")
}
proxy.ModifyResponse = func(resp *http.Response) error {
if resp.StatusCode == http.StatusPartialContent {
return errors.New("partial initialization response")
}
if resp.StatusCode != http.StatusOK {
return nil
}
return rewriteInitialization(resp, instapaperURL)
}
}
func (a *App) publicOrigin(r *http.Request) (string, error) {
if a.Config.PublicURL != "" {
return strings.TrimRight(a.Config.PublicURL, "/"), nil
}
scheme := r.Header.Get("X-Forwarded-Proto")
if scheme == "" {
scheme = "http"
if r.TLS != nil {
scheme = "https"
}
}
host := r.Header.Get("X-Forwarded-Host")
if host == "" {
host = r.Host
}
if scheme != "http" && scheme != "https" || host == "" || strings.ContainsAny(host, "/@?#, \t\r\n\"\\") {
return "", errors.New("invalid forwarded origin")
}
parsed, err := url.Parse("//" + host)
if err != nil || parsed.Hostname() == "" || parsed.Host != host {
return "", errors.New("invalid forwarded host")
}
return scheme + "://" + host, nil
}
func rewriteInitialization(resp *http.Response, instapaperURL string) error {
originalBody := resp.Body
defer func() { _ = originalBody.Close() }()
var reader io.Reader = originalBody
compressed := resp.Header.Get("Content-Encoding") == "gzip"
if compressed {
gz, err := gzip.NewReader(originalBody)
if err != nil {
return err
}
defer func() { _ = gz.Close() }()
reader = gz
} else if encoding := resp.Header.Get("Content-Encoding"); encoding != "" && encoding != "identity" {
return fmt.Errorf("unsupported initialization encoding: %s", encoding)
}
body, err := io.ReadAll(io.LimitReader(reader, maxInitializationBody+1))
if err != nil {
return err
}
if len(body) > maxInitializationBody {
return errors.New("initialization response too large")
}
encodedURL, err := json.Marshal(instapaperURL)
if err != nil {
return err
}
body = bytes.ReplaceAll(body, []byte("https://www.instapaper.com"), encodedURL[1:len(encodedURL)-1])
if compressed {
var buf bytes.Buffer
gw := gzip.NewWriter(&buf)
if _, err := gw.Write(body); err != nil {
return err
}
if err := gw.Close(); err != nil {
return err
}
body = buf.Bytes()
} else {
resp.Header.Del("Content-Encoding")
}
resp.Body = io.NopCloser(bytes.NewReader(body))
resp.ContentLength = int64(len(body))
resp.Header.Set("Content-Length", strconv.Itoa(len(body)))
for _, key := range []string{"ETag", "Last-Modified", "Content-MD5", "Digest", "Content-Range", "Accept-Ranges"} {
resp.Header.Del(key)
}
resp.Header.Set("Cache-Control", "private, no-store")
return nil
}