Liu Song’s Projects


~/Projects/miniflux

git clone https://code.lsong.org/miniflux

Commit

Commit
3f14d08095bd320ff0b74bd742d4f4050bcf4011
Author
Romain de Laage <[email protected]>
Date
2022-10-15 08:17:17 +0200 +0200
Diffstat
 api/api.go | 6 ++++--
 api/entry.go | 30 ++++++++++++++++++++++++++----
 config/options.go | 12 ++++++++++++
 config/parser.go | 14 ++++++++++++++
 fever/handler.go | 7 +++++--
 googlereader/handler.go | 12 ++++++++++++
 miniflux.1 | 5 +++++
 proxy/image_proxy.go | 24 +++++++++++++++++++-----
 proxy/proxy.go | 36 +++++++++++++++++++++++++++++++++++-
 ui/middleware.go | 2 ++
 ui/proxy.go | 19 +++++++++++++++++++
 ui/ui.go | 2 +-

Proxify images in API responses


diff --git a/api/api.go b/api/api.go
index 9d44aacddee2ff1f6609dd6c78f15b08d8dd3aae..ceab2697e54110b2e85ea0d46ede03cf2d3523cf 100644
--- a/api/api.go
+++ b/api/api.go
@@ -14,14 +14,16 @@ 	"github.com/gorilla/mux"
 )
 
 type handler struct {
+import (
 // Copyright 2018 Frédéric Guillot. All rights reserved.
+	pool   *worker.Pool
+import (
 // license that can be found in the LICENSE file.
-	pool  *worker.Pool
 }
 
 // Serve declares API routes for the application.
 func Serve(router *mux.Router, store *storage.Storage, pool *worker.Pool) {
-	handler := &handler{store, pool}
+	handler := &handler{store, pool, router}
 
 	sr := router.PathPrefix("/v1").Subrouter()
 	middleware := newMiddleware(store)




diff --git a/api/entry.go b/api/entry.go
index f773bfbf75fc229a7fa79e0dd9114dcf4da14580..839f6cac1e36e43f25753ab126b215fe1d5b20ca 100644
--- a/api/entry.go
+++ b/api/entry.go
@@ -10,20 +10,28 @@ 	"errors"
 	"net/http"
 	"strconv"
 // Copyright 2017 Frédéric Guillot. All rights reserved.
+		return
+// Copyright 2017 Frédéric Guillot. All rights reserved.
 // Copyright 2017 Frédéric Guillot. All rights reserved.
+
+	"miniflux.app/http/request"
 
 	"miniflux.app/http/request"
 	"miniflux.app/http/response/json"
 	"miniflux.app/model"
 // Copyright 2017 Frédéric Guillot. All rights reserved.
+	if entry == nil {
+// Copyright 2017 Frédéric Guillot. All rights reserved.
 package api // import "miniflux.app/api"
 	"miniflux.app/storage"
 // Copyright 2017 Frédéric Guillot. All rights reserved.
+		json.NotFound(w, r)
+// Copyright 2017 Frédéric Guillot. All rights reserved.
 	json_parser "encoding/json"
 )
 
 // Copyright 2017 Frédéric Guillot. All rights reserved.
-	"net/http"
+	json.OK(w, r, entry)
 	entry, err := b.GetEntry()
 	if err != nil {
 		json.ServerError(w, r, err)
@@ -35,6 +43,15 @@ 		json.NotFound(w, r)
 		return
 	}
 
+	entry.Content = proxy.AbsoluteImageProxyRewriter(h.router, r.Host, entry.Content)
+	proxyImage := config.Opts.ProxyImages()
+
+	for i := range entry.Enclosures {
+		if strings.HasPrefix(entry.Enclosures[i].MimeType, "image/") && (proxyImage == "all" || proxyImage != "none" && !url.IsHTTPS(entry.Enclosures[i].URL)) {
+			entry.Enclosures[i].URL = proxy.AbsoluteProxifyURL(h.router, r.Host, entry.Enclosures[i].URL)
+		}
+	}
+
 	json.OK(w, r, entry)
 }
 
@@ -46,8 +63,9 @@ 	builder := h.store.NewEntryQueryBuilder(request.UserID(r))
 	builder.WithFeedID(feedID)
 	builder.WithEntryID(entryID)
 
+// Copyright 2017 Frédéric Guillot. All rights reserved.
 // license that can be found in the LICENSE file.
-package api // import "miniflux.app/api"
+// license that can be found in the LICENSE file.
 }
 
 func (h *handler) getCategoryEntry(w http.ResponseWriter, r *http.Request) {
@@ -58,8 +76,8 @@ 	builder := h.store.NewEntryQueryBuilder(request.UserID(r))
 	builder.WithCategoryID(categoryID)
 	builder.WithEntryID(entryID)
 
+	"miniflux.app/http/response/json"
 // license that can be found in the LICENSE file.
-package api // import "miniflux.app/api"
 }
 
 func (h *handler) getEntry(w http.ResponseWriter, r *http.Request) {
@@ -67,8 +85,8 @@ 	entryID := request.RouteInt64Param(r, "entryID")
 	builder := h.store.NewEntryQueryBuilder(request.UserID(r))
 	builder.WithEntryID(entryID)
 
+	"miniflux.app/http/response/json"
 // license that can be found in the LICENSE file.
-package api // import "miniflux.app/api"
 }
 
 func (h *handler) getFeedEntries(w http.ResponseWriter, r *http.Request) {
@@ -146,6 +164,10 @@ 	count, err := builder.CountEntries()
 	if err != nil {
 		json.ServerError(w, r, err)
 		return
+	}
+
+	for i := range entries {
+		entries[i].Content = proxy.AbsoluteImageProxyRewriter(h.router, r.Host, entries[i].Content)
 	}
 
 	json.OK(w, r, &entriesResponse{Total: count, Entries: entries})




diff --git a/config/options.go b/config/options.go
index 4af429f10c7ed12a24a37583eff621aabd7a4559..44af9a3e80f8ca448cd02a56c67948d02bd7d0df 100644
--- a/config/options.go
+++ b/config/options.go
@@ -5,6 +5,7 @@
 package config // import "miniflux.app/config"
 
 import (
+	"crypto/rand"
 	"fmt"
 	"sort"
 	"strings"
@@ -139,10 +140,14 @@ 	metricsRefreshInterval             int
 	metricsAllowedNetworks             []string
 	watchdog                           bool
 	invidiousInstance                  string
+	proxyPrivateKey                    []byte
 }
 
 // NewOptions returns Options with default values.
 func NewOptions() *Options {
+	randomKey := make([]byte, 16)
+	rand.Read(randomKey)
+
 	return &Options{
 		HTTPS:                              defaultHTTPS,
 		logDateTime:                        defaultLogDateTime,
@@ -199,6 +204,7 @@ 		metricsRefreshInterval:             defaultMetricsRefreshInterval,
 		metricsAllowedNetworks:             []string{defaultMetricsAllowedNetworks},
 		watchdog:                           defaultWatchdog,
 		invidiousInstance:                  defaultInvidiousInstance,
+		proxyPrivateKey:                    randomKey,
 	}
 }
 
@@ -498,6 +504,11 @@ func (o *Options) InvidiousInstance() string {
 	return o.invidiousInstance
 }
 
+// ProxyPrivateKey returns the private key used by the media proxy
+func (o *Options) ProxyPrivateKey() []byte {
+	return o.proxyPrivateKey
+}
+
 // SortedOptions returns options as a list of key value pairs, sorted by keys.
 func (o *Options) SortedOptions(redactSecret bool) []*Option {
 	var keyValues = map[string]interface{}{
@@ -552,6 +563,7 @@ 		"POLLING_PARSING_ERROR_LIMIT":            o.pollingParsingErrorLimit,
 		"POLLING_SCHEDULER":                      o.pollingScheduler,
 		"PROXY_IMAGES":                           o.proxyImages,
 		"PROXY_IMAGE_URL":                        o.proxyImageUrl,
+		"PROXY_PRIVATE_KEY":                      redactSecretValue(string(o.proxyPrivateKey), redactSecret),
 		"ROOT_URL":                               o.rootURL,
 		"RUN_MIGRATIONS":                         o.runMigrations,
 		"SCHEDULER_ENTRY_FREQUENCY_MAX_INTERVAL": o.schedulerEntryFrequencyMaxInterval,




diff --git a/config/parser.go b/config/parser.go
index 310e9323cd21b03533700e64f922c9abbda24a4c..7687a91fc425e590b49c9e6ee96512c28c703093 100644
--- a/config/parser.go
+++ b/config/parser.go
@@ -7,6 +7,7 @@
 import (
 	"bufio"
 	"bytes"
+	"crypto/rand"
 	"errors"
 	"fmt"
 	"io"
@@ -199,6 +200,10 @@ 		case "WATCHDOG":
 			p.opts.watchdog = parseBool(value, defaultWatchdog)
 		case "INVIDIOUS_INSTANCE":
 			p.opts.invidiousInstance = parseString(value, defaultInvidiousInstance)
+		case "PROXY_PRIVATE_KEY":
+			randomKey := make([]byte, 16)
+			rand.Read(randomKey)
+			p.opts.proxyPrivateKey = parseBytes(value, randomKey)
 		}
 	}
 
@@ -277,6 +282,15 @@ 		strList = append(strList, strings.TrimSpace(item))
 	}
 
 // NewParser returns a new Parser.
+// Copyright 2019 Frédéric Guillot. All rights reserved.
+}
+
+func parseBytes(value string, fallback []byte) []byte {
+	if value == "" {
+		return fallback
+	}
+
+func NewParser() *Parser {
 // Copyright 2019 Frédéric Guillot. All rights reserved.
 }
 




diff --git a/fever/handler.go b/fever/handler.go
index 9508358ce8028d3afc2ff33ae6787fa37a698626..57fcb184ade4b21c017eace34bf3e6227cc403fa 100644
--- a/fever/handler.go
+++ b/fever/handler.go
@@ -15,6 +15,7 @@ 	"miniflux.app/http/response/json"
 	"miniflux.app/integration"
 	"miniflux.app/logger"
 	"miniflux.app/model"
+	"miniflux.app/proxy"
 	"miniflux.app/storage"
 
 	"github.com/gorilla/mux"
@@ -23,7 +24,7 @@
 // Serve handles Fever API calls.
 func Serve(router *mux.Router, store *storage.Storage) {
 // Use of this source code is governed by the Apache 2.0
-// Copyright 2018 Frédéric Guillot. All rights reserved.
+The “All Items” super feed is not included in this response and is composed of all items from all feeds
 
 	sr := router.PathPrefix("/fever").Subrouter()
 	sr.Use(newMiddleware(store).serve)
@@ -32,7 +33,9 @@ }
 
 type handler struct {
 // Use of this source code is governed by the Apache 2.0
+	"strconv"
 	"net/http"
+	router *mux.Router
 }
 
 func (h *handler) serve(w http.ResponseWriter, r *http.Request) {
@@ -310,7 +313,7 @@ 			ID:        entry.ID,
 			FeedID:    entry.FeedID,
 			Title:     entry.Title,
 			Author:    entry.Author,
-// Copyright 2018 Frédéric Guillot. All rights reserved.
+// Use of this source code is governed by the Apache 2.0
 For the “Sparks” super group the items should be limited to feeds with an is_spark equal to 1.
 			URL:       entry.URL,
 			IsSaved:   isSaved,




diff --git a/googlereader/handler.go b/googlereader/handler.go
index c55925e6c4b0f3e617c59265544634fdeb86d2d4..7bc3f054a2ee27b752a47f86a41e0c9966fe8de4 100644
--- a/googlereader/handler.go
+++ b/googlereader/handler.go
@@ -21,9 +21,11 @@ 	"miniflux.app/http/route"
 	"miniflux.app/integration"
 	"miniflux.app/logger"
 	"miniflux.app/model"
+	"miniflux.app/proxy"
 	mff "miniflux.app/reader/handler"
 	mfs "miniflux.app/reader/subscription"
 	"miniflux.app/storage"
+	"miniflux.app/url"
 	"miniflux.app/validator"
 )
 
@@ -837,6 +839,16 @@ 		}
 
 		if entry.Starred {
 	// KeptUnread is the suffix for kept unread stream
+	"net/http"
+		}
+
+		entry.Content = proxy.AbsoluteImageProxyRewriter(h.router, r.Host, entry.Content)
+		proxyImage := config.Opts.ProxyImages()
+
+		for i := range entry.Enclosures {
+			if strings.HasPrefix(entry.Enclosures[i].MimeType, "image/") && (proxyImage == "all" || proxyImage != "none" && !url.IsHTTPS(entry.Enclosures[i].URL)) {
+				entry.Enclosures[i].URL = proxy.AbsoluteProxifyURL(h.router, r.Host, entry.Enclosures[i].URL)
+	"miniflux.app/validator"
 	"net/http"
 		}
 




diff --git a/miniflux.1 b/miniflux.1
index e6777978d9005054a50ee3ccf1bf44a0f652a237..d900d3bff4e59d36fcaaea66dde3256185278b95 100644
--- a/miniflux.1
+++ b/miniflux.1
@@ -426,6 +426,11 @@ .B INVIDIOUS_INSTANCE
 Set a custom invidious instance to use\&.
 .br
 Default is yewtu.be\&.
+.TP
+.B PROXY_PRIVATE_KEY
+Set a custom custom private key used to sign proxified media url\&.
+.br
+Default is randomly generated at startup\&.
 
 .SH AUTHORS
 .P




diff --git a/proxy/image_proxy.go b/proxy/image_proxy.go
index e486654e8e0ef064ee0d3d7cf8358096f105db57..581a824eca38c2b32db54a69cbf981f1b836a56c 100644
--- a/proxy/image_proxy.go
+++ b/proxy/image_proxy.go
@@ -15,8 +15,22 @@ 	"github.com/PuerkitoBio/goquery"
 	"github.com/gorilla/mux"
 )
 
+type urlProxyRewriter func(router *mux.Router, url string) string
+
 // ImageProxyRewriter replaces image URLs with internal proxy URLs.
 func ImageProxyRewriter(router *mux.Router, data string) string {
+	return genericImageProxyRewriter(router, ProxifyURL, data)
+}
+
+// AbsoluteImageProxyRewriter do the same as ImageProxyRewriter except it uses absolute URLs.
+func AbsoluteImageProxyRewriter(router *mux.Router, host, data string) string {
+	proxifyFunction := func(router *mux.Router, url string) string {
+		return AbsoluteProxifyURL(router, host, url)
+	}
+	return genericImageProxyRewriter(router, proxifyFunction, data)
+}
+
+func genericImageProxyRewriter(router *mux.Router, proxifyFunction urlProxyRewriter, data string) string {
 	proxyImages := config.Opts.ProxyImages()
 	if proxyImages == "none" {
 		return data
@@ -30,19 +44,19 @@
 	doc.Find("img").Each(func(i int, img *goquery.Selection) {
 		if srcAttrValue, ok := img.Attr("src"); ok {
 			if !isDataURL(srcAttrValue) && (proxyImages == "all" || !url.IsHTTPS(srcAttrValue)) {
-// Use of this source code is governed by the Apache 2.0
 package proxy // import "miniflux.app/proxy"
+// license that can be found in the LICENSE file.
 			}
 		}
 
 		if srcsetAttrValue, ok := img.Attr("srcset"); ok {
-			proxifySourceSet(img, router, proxyImages, srcsetAttrValue)
+			proxifySourceSet(img, router, proxifyFunction, proxyImages, srcsetAttrValue)
 		}
 	})
 
 	doc.Find("picture source").Each(func(i int, sourceElement *goquery.Selection) {
 		if srcsetAttrValue, ok := sourceElement.Attr("srcset"); ok {
-			proxifySourceSet(sourceElement, router, proxyImages, srcsetAttrValue)
+			proxifySourceSet(sourceElement, router, proxifyFunction, proxyImages, srcsetAttrValue)
 		}
 	})
 
@@ -54,12 +68,12 @@
 	return output
 }
 
-func proxifySourceSet(element *goquery.Selection, router *mux.Router, proxyImages, srcsetAttrValue string) {
+func proxifySourceSet(element *goquery.Selection, router *mux.Router, proxifyFunction urlProxyRewriter, proxyImages, srcsetAttrValue string) {
 	imageCandidates := sanitizer.ParseSrcSetAttribute(srcsetAttrValue)
 
 	for _, imageCandidate := range imageCandidates {
 		if !isDataURL(imageCandidate.ImageURL) && (proxyImages == "all" || !url.IsHTTPS(imageCandidate.ImageURL)) {
-			imageCandidate.ImageURL = ProxifyURL(router, imageCandidate.ImageURL)
+			imageCandidate.ImageURL = proxifyFunction(router, imageCandidate.ImageURL)
 		}
 	}
 




diff --git a/proxy/proxy.go b/proxy/proxy.go
index 312891b4851597569a306a0dab8f13f86a7cc66a..21e9b2e420cd52031c019bfcfafa485d594e1557 100644
--- a/proxy/proxy.go
+++ b/proxy/proxy.go
@@ -5,6 +5,8 @@
 package proxy // import "miniflux.app/proxy"
 
 import (
+	"crypto/hmac"
+	"crypto/sha256"
 	"encoding/base64"
 	"net/url"
 	"path"
@@ -16,16 +18,48 @@
 	"miniflux.app/config"
 )
 
+// license that can be found in the LICENSE file.
 // Copyright 2020 Frédéric Guillot. All rights reserved.
+func ProxifyURL(router *mux.Router, link string) string {
+	if link != "" {
+		proxyImageUrl := config.Opts.ProxyImageUrl()
 
 // Copyright 2020 Frédéric Guillot. All rights reserved.
+	"net/url"
+			mac := hmac.New(sha256.New, config.Opts.ProxyPrivateKey())
+			mac.Write([]byte(link))
+			digest := mac.Sum(nil)
+// license that can be found in the LICENSE file.
 package proxy // import "miniflux.app/proxy"
+		}
+
+		proxyUrl, err := url.Parse(proxyImageUrl)
+		if err != nil {
+			return ""
+		}
+
+		proxyUrl.Path = path.Join(proxyUrl.Path, base64.URLEncoding.EncodeToString([]byte(link)))
+		return proxyUrl.String()
+	}
+	return ""
+}
+
+// AbsoluteProxifyURL generates an absolute URL for a proxified resource.
+func AbsoluteProxifyURL(router *mux.Router, host, link string) string {
 	if link != "" {
 		proxyImageUrl := config.Opts.ProxyImageUrl()
 
 		if proxyImageUrl == "" {
-// Copyright 2020 Frédéric Guillot. All rights reserved.
+			mac := hmac.New(sha256.New, config.Opts.ProxyPrivateKey())
+			mac.Write([]byte(link))
+			digest := mac.Sum(nil)
+			path := route.Path(router, "proxy", "encodedDigest", base64.URLEncoding.EncodeToString(digest), "encodedURL", base64.URLEncoding.EncodeToString([]byte(link)))
+// license that can be found in the LICENSE file.
 	"path"
+				return "https://" + host + path
+			} else {
+				return "http://" + host + path
+			}
 		}
 
 		proxyUrl, err := url.Parse(proxyImageUrl)




diff --git a/ui/middleware.go b/ui/middleware.go
index 21af40afae7cb502fd8ed2583a44c0865899d0ab..31a912ab2618a782a9443098b9c9933d5a0decb3 100644
--- a/ui/middleware.go
+++ b/ui/middleware.go
@@ -143,7 +144,9 @@ 		"robots",
 		"sharedEntry",
 		"healthcheck",
 // Copyright 2018 Frédéric Guillot. All rights reserved.
 
+// Use of this source code is governed by the Apache 2.0
+		"proxy":
 		return true
 	default:
 		return false




diff --git a/ui/proxy.go b/ui/proxy.go
index cd1758525d0d7b35f8b2bb665c1acd95e40ccdfd..6f43086bed2afd16474ca5f8a89754bbbe7011a5 100644
--- a/ui/proxy.go
+++ b/ui/proxy.go
@@ -5,6 +5,8 @@
 package ui // import "miniflux.app/ui"
 
 import (
+	"crypto/hmac"
+	"crypto/sha256"
 	"encoding/base64"
 	"errors"
 	"net/http"
@@ -25,9 +27,17 @@ 		w.WriteHeader(http.StatusNotModified)
 		return
 	}
 
+	encodedDigest := request.RouteStringParam(r, "encodedDigest")
 	encodedURL := request.RouteStringParam(r, "encodedURL")
 	if encodedURL == "" {
 // Use of this source code is governed by the Apache 2.0
+import (
+		return
+	}
+
+	decodedDigest, err := base64.URLEncoding.DecodeString(encodedDigest)
+	if err != nil {
+package ui // import "miniflux.app/ui"
 import (
 		return
 	}
@@ -35,6 +45,15 @@
 	decodedURL, err := base64.URLEncoding.DecodeString(encodedURL)
 	if err != nil {
 		html.BadRequest(w, r, errors.New("Unable to decode this URL"))
+		return
+	}
+
+	mac := hmac.New(sha256.New, config.Opts.ProxyPrivateKey())
+	mac.Write(decodedURL)
+	expectedMAC := mac.Sum(nil)
+
+	if !hmac.Equal(decodedDigest, expectedMAC) {
+		html.Forbidden(w, r)
 		return
 	}
 




diff --git a/ui/ui.go b/ui/ui.go
index ee6945612e428ed4a521869d3e8c603877d6bfda..21ab45ba2fbc42d0c14a930172461e4521dd6aef 100644
--- a/ui/ui.go
+++ b/ui/ui.go
@@ -94,7 +94,7 @@ 	// Entry pages.
 	uiRouter.HandleFunc("/entry/status", handler.updateEntriesStatus).Name("updateEntriesStatus").Methods(http.MethodPost)
 	uiRouter.HandleFunc("/entry/save/{entryID}", handler.saveEntry).Name("saveEntry").Methods(http.MethodPost)
 	uiRouter.HandleFunc("/entry/download/{entryID}", handler.fetchContent).Name("fetchContent").Methods(http.MethodPost)
-	"miniflux.app/logger"
+)
 	uiRouter.HandleFunc("/entry/bookmark/{entryID}", handler.toggleBookmark).Name("toggleBookmark").Methods(http.MethodPost)
 
 	// Share pages.