Files
opds-proxy/server.go
Evan Buss 0f0540549d feat: request deduplication / debouncing
Kobo eReaders have a buggy browser that makes 2 requests for
the same HTTP resource when you click a link.

This change ensures that requests within a certain time frame
from the same IP, for the same path  / query params will only
be executed a single time.

We record the http request response and replay it for the second
request. If we get 2 simultaneous requests, we use the
sync/singleflight library to ensure only the first request is actually
processed. The second waits for the shared result of the first.

This probably adds latency since some requests are blocked while
we determine if we already have a cache entry, but for a simple
service like this I don't think it matters.
2024-08-18 18:37:42 +00:00

464 lines
11 KiB
Go

package main
import (
"context"
"encoding/hex"
"fmt"
"io"
"log/slog"
"mime"
"net"
"net/http"
"net/url"
"os"
"path/filepath"
"reflect"
"strings"
"sync"
"time"
"github.com/evan-buss/opds-proxy/convert"
"github.com/evan-buss/opds-proxy/html"
"github.com/evan-buss/opds-proxy/internal/debounce"
"github.com/evan-buss/opds-proxy/opds"
"github.com/google/uuid"
"github.com/gorilla/securecookie"
)
const (
MOBI_MIME = "application/x-mobipocket-ebook"
EPUB_MIME = "application/epub+zip"
ATOM_MIME = "application/atom+xml"
)
var (
_ = mime.AddExtensionType(".epub", EPUB_MIME)
_ = mime.AddExtensionType(".kepub.epub", EPUB_MIME)
_ = mime.AddExtensionType(".mobi", MOBI_MIME)
)
type Server struct {
addr string
router *http.ServeMux
s *securecookie.SecureCookie
}
type Credentials struct {
Username string
Password string
}
type contextKey string
const (
requestLogger = contextKey("requestLogger")
isLocalRequest = contextKey("isLocalRequest")
)
const cookieName = "auth-creds"
func NewServer(config *ProxyConfig) (*Server, error) {
hashKey, err := hex.DecodeString(config.Auth.HashKey)
if err != nil {
return nil, err
}
blockKey, err := hex.DecodeString(config.Auth.BlockKey)
if err != nil {
return nil, err
}
if !config.DebugMode {
slog.SetDefault(slog.New(slog.NewJSONHandler(os.Stdout, nil)))
}
s := securecookie.New(hashKey, blockKey)
// Kobo issues 2 requests for each clicked link. This middleware ensures
// we only process the first request and provide the same response for the second.
// This becomes more important when the requests aren't idempotent, such as triggering
// a download.
debounceMiddleware := debounce.NewDebounceMiddleware(time.Millisecond * 100)
router := http.NewServeMux()
router.Handle("GET /{$}", requestMiddleware(handleHome(config.Feeds)))
router.Handle("GET /feed", requestMiddleware(debounceMiddleware(handleFeed("tmp/", config.Feeds, s))))
router.Handle("/auth", requestMiddleware(handleAuth(s)))
router.Handle("GET /static/", http.FileServer(http.FS(html.StaticFiles())))
return &Server{
addr: ":" + config.Port,
router: router,
s: s,
}, nil
}
func requestMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
id := uuid.New()
requestIP := r.Header.Get("X-Forwarded-For")
if requestIP == "" {
requestIP, _, _ = net.SplitHostPort(r.RemoteAddr)
}
isLocal := true
for _, addr := range strings.Split(requestIP, ", ") {
ip := net.ParseIP(addr)
if ip == nil || (!ip.IsPrivate() && !ip.IsLoopback()) {
isLocal = false
break
}
}
ctx := context.WithValue(r.Context(), isLocalRequest, isLocal)
query, _ := url.QueryUnescape(r.URL.RawQuery)
log := slog.With(
slog.Group("request",
slog.String("id", id.String()),
slog.String("ip", requestIP),
slog.String("method", r.Method),
slog.String("path", r.URL.Path),
slog.String("query", query),
slog.String("user-agent", r.UserAgent()),
),
)
ctx = context.WithValue(ctx, requestLogger, log)
r = r.WithContext(ctx)
next.ServeHTTP(w, r)
log.Info("Request Completed",
slog.String("duration", time.Since(start).String()),
slog.Bool("debounce", w.Header().Get("X-Debounce") == "true"),
slog.Bool("shared", w.Header().Get("X-Shared") == "true"),
)
})
}
func (s *Server) Serve() error {
slog.Info("Starting server", slog.String("port", s.addr))
return http.ListenAndServe(s.addr, s.router)
}
func handleHome(feeds []FeedConfig) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
// Don't make user click the only feed
if len(feeds) == 1 {
http.Redirect(w, r, "/feed?q="+feeds[0].Url, http.StatusFound)
return
}
vmFeeds := make([]html.FeedInfo, len(feeds))
for i, feed := range feeds {
vmFeeds[i] = html.FeedInfo{
Title: feed.Name,
URL: feed.Url,
}
}
html.Home(w, vmFeeds)
}
}
func handleFeed(outputDir string, feeds []FeedConfig, s *securecookie.SecureCookie) http.HandlerFunc {
kepubConverter := &convert.KepubConverter{}
mobiConverter := &convert.MobiConverter{}
mutex := sync.Mutex{}
return func(w http.ResponseWriter, r *http.Request) {
queryURL := r.URL.Query().Get("q")
if queryURL == "" {
http.Error(w, "No feed specified", http.StatusBadRequest)
return
}
parsedUrl, err := url.PathUnescape(queryURL)
queryURL = parsedUrl
if err != nil {
handleError(r, w, "Failed to parse URL", err)
return
}
searchTerm := r.URL.Query().Get("search")
if searchTerm != "" {
queryURL = strings.Replace(queryURL, "{searchTerms}", searchTerm, 1)
}
resp, err := fetchFromUrl(queryURL, getCredentials(r, feeds, s))
if err != nil {
handleError(r, w, "Failed to fetch", err)
return
}
defer resp.Body.Close()
if resp.StatusCode == http.StatusUnauthorized {
http.Redirect(w, r, "/auth?return="+r.URL.String(), http.StatusFound)
return
}
mimeType, _, err := mime.ParseMediaType(resp.Header.Get("Content-Type"))
if err != nil {
handleError(r, w, "Failed to parse content type", err)
return
}
if mimeType == ATOM_MIME {
feed, err := opds.ParseFeed(resp.Body)
if err != nil {
handleError(r, w, "Failed to parse feed", err)
return
}
feedParams := html.FeedParams{
URL: queryURL,
Feed: feed,
}
if err = html.Feed(w, feedParams); err != nil {
handleError(r, w, "Failed to render feed", err)
return
}
return
}
mutex.Lock()
defer mutex.Unlock()
var converter convert.Converter
if strings.Contains(r.UserAgent(), "Kobo") && kepubConverter.Available() {
converter = kepubConverter
} else if strings.Contains(r.UserAgent(), "Kindle") && mobiConverter.Available() {
converter = mobiConverter
}
log := r.Context().Value(requestLogger).(*slog.Logger)
filename, err := parseFileName(resp)
if err == nil {
log = log.With(slog.String("file", filename))
}
if mimeType != EPUB_MIME || converter == nil {
forwardResponse(w, resp)
if filename != "" {
log.Info("Sent File")
}
return
}
if err != nil {
handleError(r, w, "Failed to parse file name", err)
return
}
epubFile := filepath.Join(outputDir, filename)
downloadFile(epubFile, resp)
defer os.Remove(epubFile)
outputFile, err := converter.Convert(log, epubFile)
if err != nil {
handleError(r, w, "Failed to convert epub", err)
return
}
if err = sendConvertedFile(w, outputFile); err != nil {
handleError(r, w, "Failed to send converted file", err)
return
}
log.Info("Sent Converted File", slog.String("converter", reflect.TypeOf(converter).String()))
}
}
func handleAuth(s *securecookie.SecureCookie) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
returnUrl := r.URL.Query().Get("return")
if returnUrl == "" {
http.Error(w, "No return URL specified", http.StatusBadRequest)
return
}
if r.Method == "GET" {
html.Login(w, html.LoginParams{ReturnURL: returnUrl})
return
}
if r.Method == "POST" {
username := r.FormValue("username")
password := r.FormValue("password")
rUrl, err := url.Parse(returnUrl)
if err != nil {
http.Error(w, "Invalid return URL", http.StatusBadRequest)
}
domain, err := url.Parse(rUrl.Query().Get("q"))
if err != nil {
http.Error(w, "Invalid site", http.StatusBadRequest)
}
value := map[string]Credentials{
domain.Hostname(): {Username: username, Password: password},
}
encoded, err := s.Encode(cookieName, value)
if err != nil {
handleError(r, w, "Failed to encode credentials", err)
return
}
cookie := &http.Cookie{
Name: cookieName,
Value: encoded,
Path: "/",
// Kobo fails to set cookies with HttpOnly or Secure flags
Secure: false,
HttpOnly: false,
}
http.SetCookie(w, cookie)
http.Redirect(w, r, returnUrl, http.StatusFound)
return
}
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
}
}
func getCredentials(r *http.Request, feeds []FeedConfig, s *securecookie.SecureCookie) *Credentials {
if !r.URL.Query().Has("q") {
return nil
}
requestUrl, err := url.Parse(r.URL.Query().Get("q"))
if err != nil {
return nil
}
// Try to get credentials from the config first
for _, feed := range feeds {
feedUrl, err := url.Parse(feed.Url)
if err != nil {
continue
}
if feedUrl.Hostname() != requestUrl.Hostname() {
continue
}
if feed.Auth == nil || feed.Auth.Username == "" || feed.Auth.Password == "" {
continue
}
// Only set feed credentials for local requests
// when the auth config has local_only flag
isLocal := r.Context().Value(isLocalRequest).(bool)
if !isLocal && feed.Auth.LocalOnly {
continue
}
return &Credentials{Username: feed.Auth.Username, Password: feed.Auth.Password}
}
// Otherwise, try to get credentials from the cookie
cookie, err := r.Cookie(cookieName)
if err != nil {
return nil
}
value := make(map[string]*Credentials)
if err = s.Decode(cookieName, cookie.Value, &value); err != nil {
return nil
}
return value[requestUrl.Hostname()]
}
func fetchFromUrl(url string, credentials *Credentials) (*http.Response, error) {
client := &http.Client{
Timeout: 2 * time.Second,
}
req, err := http.NewRequest("GET", url, nil)
if err != nil {
return nil, err
}
if credentials != nil {
req.SetBasicAuth(credentials.Username, credentials.Password)
}
return client.Do(req)
}
func handleError(r *http.Request, w http.ResponseWriter, message string, err error) {
log := r.Context().Value(requestLogger).(*slog.Logger)
log.Error(message, slog.Any("error", err))
http.Error(w, "An unexpected error occurred", http.StatusInternalServerError)
}
func downloadFile(path string, resp *http.Response) error {
file, err := os.Create(path)
if err != nil {
return err
}
defer file.Close()
_, err = io.Copy(file, resp.Body)
if err != nil {
return err
}
return nil
}
func parseFileName(resp *http.Response) (string, error) {
contentDisposition := resp.Header.Get("Content-Disposition")
_, params, err := mime.ParseMediaType(contentDisposition)
if err != nil {
return "", err
}
return params["filename"], nil
}
func forwardResponse(w http.ResponseWriter, resp *http.Response) {
for k, v := range resp.Header {
w.Header()[k] = v
}
io.Copy(w, resp.Body)
}
func sendConvertedFile(w http.ResponseWriter, filePath string) error {
file, err := os.Open(filePath)
if err != nil {
os.Remove(filePath)
return err
}
defer func() {
file.Close()
os.Remove(filePath)
}()
info, err := file.Stat()
if err != nil {
return err
}
w.Header().Set("Content-Length", fmt.Sprintf("%d", info.Size()))
w.Header().Set("Content-Disposition",
mime.FormatMediaType(
"attachment",
map[string]string{"filename": filepath.Base(filePath)},
),
)
w.Header().Set("Content-Type", mime.TypeByExtension(filepath.Ext(filePath)))
_, err = io.Copy(w, file)
if err != nil {
return err
}
return nil
}