forked from Ivasoft/opds-proxy
All checks were successful
continuous-integration/drone/push Build is passing
492 lines
12 KiB
Go
492 lines
12 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"
|
|
"unicode"
|
|
|
|
"golang.org/x/text/transform"
|
|
"golang.org/x/text/unicode/norm"
|
|
|
|
"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") || (len(r.UserAgent()) == 0)) && mobiConverter.Available() {
|
|
// Note: Only Kindle browser is that stupid that it forgets to send its User-Agent
|
|
// header after user confirms the file is really to be downloaded.
|
|
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": filenameToAscii7(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
|
|
}
|
|
|
|
func isMn(r rune) bool {
|
|
return unicode.Is(unicode.Mn, r) // Mn: nonspacing marks
|
|
}
|
|
|
|
func filenameToAscii7(s string) string {
|
|
// Remove most diacritics
|
|
t := transform.Chain(norm.NFD, transform.RemoveFunc(isMn), norm.NFC)
|
|
noDiacr, _, _ := transform.String(t, s)
|
|
|
|
// Convert the rest of non-ASCII7 to hex representation
|
|
var sb strings.Builder
|
|
for _, letter := range noDiacr {
|
|
if letter > 0x7F {
|
|
sb.WriteString(fmt.Sprintf("_%X", letter))
|
|
} else {
|
|
sb.WriteRune(letter)
|
|
}
|
|
}
|
|
|
|
return sb.String()
|
|
}
|