Browse Source

refactor(fever): remove dependency on gorilla/mux

Frédéric Guillot 3 weeks ago
parent
commit
f50c5dda7b
4 changed files with 485 additions and 114 deletions
  1. 387 0
      internal/fever/README.md
  2. 44 60
      internal/fever/handler.go
  3. 48 53
      internal/fever/middleware.go
  4. 6 1
      internal/http/server/httpd.go

+ 387 - 0
internal/fever/README.md

@@ -0,0 +1,387 @@
+# Miniflux Fever API
+
+This document describes the Fever-compatible API implemented by the `internal/fever` package in this repository.
+
+## Endpoint
+
+- Path: `BASE_URL/fever/`
+- Methods: not restricted by the router; read requests are typically sent as `GET`, write requests should be sent as `POST`
+- Response format: JSON only
+- Reported API version: `3`
+
+## Authentication
+
+Fever authentication is enabled per user from the Miniflux integrations page.
+
+- `Fever Username` and `Fever Password` are configured in Miniflux
+- Miniflux stores the Fever token as the MD5 hash of `username:password`
+- Clients authenticate by sending that token as the `api_key` parameter
+- Token lookup is case-insensitive
+
+Example:
+
+```text
+api_key = md5("fever_username:fever_password")
+```
+
+Example shell command:
+
+```bash
+printf '%s' 'fever_username:fever_password' | md5sum
+```
+
+Authentication failure does not return HTTP 401. The middleware returns HTTP 200 with:
+
+```json
+{
+  "api_version": 3,
+  "auth": 0
+}
+```
+
+On successful authentication, every response includes:
+
+- `api_version`: always `3`
+- `auth`: always `1`
+- `last_refreshed_on_time`: current server Unix timestamp at response time
+
+## Dispatch Rules
+
+The handler selects the first matching operation in this order:
+
+1. `groups`
+2. `feeds`
+3. `favicons`
+4. `unread_item_ids`
+5. `saved_item_ids`
+6. `items`
+7. `mark=item`
+8. `mark=feed`
+9. `mark=group`
+
+If no selector is provided, the server returns the base authenticated response only.
+
+For read operations, the selector must be present in the query string. For write operations, `mark`, `as`, `id`, and `before` are read from request form values, so they may come from the query string or a form body.
+
+## Read Operations
+
+### `?groups`
+
+Returns:
+
+- `groups`: list of categories
+- `feeds_groups`: mapping of category IDs to feed IDs
+
+Response shape:
+
+```json
+{
+  "api_version": 3,
+  "auth": 1,
+  "last_refreshed_on_time": 1710000000,
+  "groups": [
+    {
+      "id": 1,
+      "title": "All"
+    }
+  ],
+  "feeds_groups": [
+    {
+      "group_id": 1,
+      "feed_ids": "10,11"
+    }
+  ]
+}
+```
+
+Notes:
+
+- `groups` are Miniflux categories
+- `feeds_groups.feed_ids` is a comma-separated string
+- categories with no feeds are returned in `groups` but have no `feeds_groups` entry
+
+### `?feeds`
+
+Returns:
+
+- `feeds`: list of feeds
+- `feeds_groups`: mapping of category IDs to feed IDs
+
+Feed fields:
+
+- `id`
+- `favicon_id`
+- `title`
+- `url`
+- `site_url`
+- `is_spark`
+- `last_updated_on_time`
+
+Notes:
+
+- `favicon_id` is `0` when the feed has no icon
+- `is_spark` is always `0` in this implementation
+- `last_updated_on_time` is the feed check time as a Unix timestamp
+
+### `?favicons`
+
+Returns:
+
+- `favicons`: list of favicon objects
+
+Favicon fields:
+
+- `id`
+- `data`
+
+Notes:
+
+- `data` is a data URL such as `image/png;base64,...`
+
+### `?unread_item_ids`
+
+Returns:
+
+- `unread_item_ids`: comma-separated list of unread entry IDs
+
+Response shape:
+
+```json
+{
+  "api_version": 3,
+  "auth": 1,
+  "last_refreshed_on_time": 1710000000,
+  "unread_item_ids": "100,101,102"
+}
+```
+
+### `?saved_item_ids`
+
+Returns:
+
+- `saved_item_ids`: comma-separated list of starred entry IDs
+
+### `?items`
+
+Returns:
+
+- `items`: list of entries
+- `total_items`: total number of non-removed entries for the user
+
+Item fields:
+
+- `id`
+- `feed_id`
+- `title`
+- `author`
+- `html`
+- `url`
+- `is_saved`
+- `is_read`
+- `created_on_time`
+
+The implementation always excludes entries whose status is `removed`.
+
+#### Pagination and filtering
+
+The handler applies a fixed limit of 50 items.
+
+Supported parameters:
+
+- `since_id`: when greater than `0`, returns entries with `id > since_id`, ordered by `id ASC`
+- `max_id`: when equal to `0`, returns the most recent entries ordered by `id DESC`; when greater than `0`, returns entries with `id < max_id`, ordered by `id DESC`
+- `with_ids`: comma-separated list of entry IDs to fetch
+
+Selector precedence inside `?items` is:
+
+1. `since_id`
+2. `max_id`
+3. `with_ids`
+4. no item filter
+
+Notes:
+
+- `with_ids` does not enforce the 50-ID maximum mentioned in older Fever documentation
+- invalid `with_ids` members are parsed as `0` and do not match normal entries
+- when `items` is requested without `since_id`, `max_id`, or `with_ids`, the code applies no explicit `ORDER BY`, so result ordering is not guaranteed by SQL
+- `html` is returned after Miniflux content rewriting and may include media-proxy-rewritten URLs
+
+Example:
+
+```json
+{
+  "api_version": 3,
+  "auth": 1,
+  "last_refreshed_on_time": 1710000000,
+  "total_items": 245,
+  "items": [
+    {
+      "id": 100,
+      "feed_id": 10,
+      "title": "Example entry",
+      "author": "Author",
+      "html": "<p>Content</p>",
+      "url": "https://example.org/post",
+      "is_saved": 0,
+      "is_read": 1,
+      "created_on_time": 1709990000
+    }
+  ]
+}
+```
+
+## Write Operations
+
+Normal successful write operations return the base authenticated response:
+
+```json
+{
+  "api_version": 3,
+  "auth": 1,
+  "last_refreshed_on_time": 1710000000
+}
+```
+
+### `mark=item`
+
+Parameters:
+
+- `mark=item`
+- `id=<entry_id>`
+- `as=read|unread|saved|unsaved`
+
+Behavior:
+
+- `as=read`: marks the entry as read
+- `as=unread`: marks the entry as unread
+- `as=saved`: toggles the starred flag
+- `as=unsaved`: toggles the starred flag
+
+Important:
+
+- `saved` and `unsaved` both call the same toggle operation
+- sending `as=saved` twice will save, then unsave
+- sending `as=unsaved` twice will unsave, then save
+- if `id <= 0`, the handler returns without writing a response body
+- if the entry does not exist or is already removed, the server returns the base response without an error
+
+### `mark=feed`
+
+Parameters:
+
+- `mark=feed`
+- `as=read`
+- `id=<feed_id>`
+- `before=<unix_timestamp>`
+
+Behavior:
+
+- marks unread entries in the feed as read when `published_at < before`
+- the update runs asynchronously in a goroutine after the response is returned
+
+Notes:
+
+- if `id <= 0`, the handler returns without writing a response body
+- if `before` is missing or invalid, it is treated as Unix time `0`, which usually means nothing is marked as read
+
+### `mark=group`
+
+Parameters:
+
+- `mark=group`
+- `as=read`
+- `id=<group_id>`
+- `before=<unix_timestamp>`
+
+Behavior:
+
+- `id=0`: marks all unread entries as read, ignoring `before`
+- `id>0`: marks unread entries in the matching category as read when `published_at < before`
+- the update runs asynchronously in a goroutine after the response is returned
+
+Notes:
+
+- group IDs map to Miniflux category IDs
+- if `id < 0`, the handler returns without writing a response body
+- if `before` is missing or invalid for `id>0`, it is treated as Unix time `0`, which usually means nothing is marked as read
+
+## Error Handling
+
+Authentication failures:
+
+- HTTP status: `200`
+- body: `{"api_version":3,"auth":0}`
+
+Internal errors:
+
+- HTTP status: `500`
+- body:
+
+```json
+{
+  "error_message": "..."
+}
+```
+
+## Differences From Generic Fever Documentation
+
+This implementation is Fever-compatible, but it does not match every detail of historical Fever API docs.
+
+- Responses are always JSON; `api=xml` is mentioned in code comments but is not implemented
+- `api_version` is `3`
+- `last_refreshed_on_time` is set to the current response time, not the timestamp of the most recently refreshed feed
+- the `Kindling` and `Sparks` super groups are not returned
+- `feeds[].is_spark` is always `0`
+- item ordering without explicit pagination parameters is unspecified
+- `as=saved` and `as=unsaved` toggle the saved flag instead of setting it absolutely
+
+## Examples
+
+Fetch groups:
+
+```bash
+curl -s 'https://miniflux.example.com/fever/?api_key=TOKEN&groups'
+```
+
+Fetch most recent items:
+
+```bash
+curl -s 'https://miniflux.example.com/fever/?api_key=TOKEN&items&max_id=0'
+```
+
+Fetch items after a known ID:
+
+```bash
+curl -s 'https://miniflux.example.com/fever/?api_key=TOKEN&items&since_id=123'
+```
+
+Mark an item as read:
+
+```bash
+curl -s -X POST 'https://miniflux.example.com/fever/' \
+  -d 'api_key=TOKEN' \
+  -d 'mark=item' \
+  -d 'as=read' \
+  -d 'id=123'
+```
+
+Mark a feed as read before a timestamp:
+
+```bash
+curl -s -X POST 'https://miniflux.example.com/fever/' \
+  -d 'api_key=TOKEN' \
+  -d 'mark=feed' \
+  -d 'as=read' \
+  -d 'id=10' \
+  -d 'before=1710000000'
+```
+
+Mark all items as read through the group endpoint:
+
+```bash
+curl -s -X POST 'https://miniflux.example.com/fever/' \
+  -d 'api_key=TOKEN' \
+  -d 'mark=group' \
+  -d 'as=read' \
+  -d 'id=0'
+```

+ 44 - 60
internal/fever/handler.go

@@ -13,28 +13,22 @@ import (
 	"miniflux.app/v2/internal/http/request"
 	"miniflux.app/v2/internal/http/response"
 	"miniflux.app/v2/internal/integration"
-	"miniflux.app/v2/internal/mediaproxy"
 	"miniflux.app/v2/internal/model"
 	"miniflux.app/v2/internal/storage"
-
-	"github.com/gorilla/mux"
 )
 
-// Serve handles Fever API calls.
-func Serve(router *mux.Router, store *storage.Storage) {
-	handler := &handler{store, router}
-
-	sr := router.PathPrefix("/fever").Subrouter()
-	sr.Use(newMiddleware(store).serve)
-	sr.HandleFunc("/", handler.serve).Name("feverEndpoint")
+// NewHandler returns an http.Handler for Fever API calls.
+func NewHandler(store *storage.Storage, proxyRewriter func(string) string) http.Handler {
+	h := &feverHandler{store: store, proxyRewriter: proxyRewriter}
+	return http.HandlerFunc(h.serve)
 }
 
-type handler struct {
-	store  *storage.Storage
-	router *mux.Router
+type feverHandler struct {
+	store         *storage.Storage
+	proxyRewriter func(string) string
 }
 
-func (h *handler) serve(w http.ResponseWriter, r *http.Request) {
+func (h *feverHandler) serve(w http.ResponseWriter, r *http.Request) {
 	switch {
 	case request.HasQueryParam(r, "groups"):
 		h.handleGroups(w, r)
@@ -78,7 +72,7 @@ an is_spark equal to 0.
 The “Sparks” super group is not included in this response and is composed of all feeds with an
 is_spark equal to 1.
 */
-func (h *handler) handleGroups(w http.ResponseWriter, r *http.Request) {
+func (h *feverHandler) handleGroups(w http.ResponseWriter, r *http.Request) {
 	userID := request.UserID(r)
 	slog.Debug("[Fever] Fetching groups",
 		slog.Int64("user_id", userID),
@@ -101,7 +95,7 @@ func (h *handler) handleGroups(w http.ResponseWriter, r *http.Request) {
 		result.Groups = append(result.Groups, group{ID: category.ID, Title: category.Title})
 	}
 
-	result.FeedsGroups = h.buildFeedGroups(feeds)
+	result.FeedsGroups = buildFeedGroups(feeds)
 	result.SetCommonValues()
 	response.JSON(w, r, result)
 }
@@ -130,7 +124,7 @@ should be limited to feeds with an is_spark equal to 0.
 
 For the “Sparks” super group the items should be limited to feeds with an is_spark equal to 1.
 */
-func (h *handler) handleFeeds(w http.ResponseWriter, r *http.Request) {
+func (h *feverHandler) handleFeeds(w http.ResponseWriter, r *http.Request) {
 	userID := request.UserID(r)
 	slog.Debug("[Fever] Fetching feeds",
 		slog.Int64("user_id", userID),
@@ -161,7 +155,7 @@ func (h *handler) handleFeeds(w http.ResponseWriter, r *http.Request) {
 		result.Feeds = append(result.Feeds, subscription)
 	}
 
-	result.FeedsGroups = h.buildFeedGroups(feeds)
+	result.FeedsGroups = buildFeedGroups(feeds)
 	result.SetCommonValues()
 	response.JSON(w, r, result)
 }
@@ -185,7 +179,7 @@ A PHP/HTML example:
 
 	echo '<img src="data:'.$favicon['data'].'">';
 */
-func (h *handler) handleFavicons(w http.ResponseWriter, r *http.Request) {
+func (h *feverHandler) handleFavicons(w http.ResponseWriter, r *http.Request) {
 	userID := request.UserID(r)
 	slog.Debug("[Fever] Fetching favicons",
 		slog.Int64("user_id", userID),
@@ -239,7 +233,7 @@ Three optional arguments control determine the items included in the response.
 	Use the with_ids argument with a comma-separated list of item ids to request (a maximum of 50) specific items.
 	(added in API version 2)
 */
-func (h *handler) handleItems(w http.ResponseWriter, r *http.Request) {
+func (h *feverHandler) handleItems(w http.ResponseWriter, r *http.Request) {
 	var result itemsResponse
 
 	userID := request.UserID(r)
@@ -324,7 +318,7 @@ func (h *handler) handleItems(w http.ResponseWriter, r *http.Request) {
 			FeedID:    entry.FeedID,
 			Title:     entry.Title,
 			Author:    entry.Author,
-			HTML:      mediaproxy.RewriteDocumentWithAbsoluteProxyURL(h.router, entry.Content),
+			HTML:      h.proxyRewriter(entry.Content),
 			URL:       entry.URL,
 			IsSaved:   isSaved,
 			IsRead:    isRead,
@@ -344,7 +338,7 @@ A request with the unread_item_ids argument will return one additional member:
 
 	unread_item_ids (string/comma-separated list of positive integers)
 */
-func (h *handler) handleUnreadItems(w http.ResponseWriter, r *http.Request) {
+func (h *feverHandler) handleUnreadItems(w http.ResponseWriter, r *http.Request) {
 	userID := request.UserID(r)
 	slog.Debug("[Fever] Fetching unread items",
 		slog.Int64("user_id", userID),
@@ -377,7 +371,7 @@ with the remote Fever installation.
 
 	saved_item_ids (string/comma-separated list of positive integers)
 */
-func (h *handler) handleSavedItems(w http.ResponseWriter, r *http.Request) {
+func (h *feverHandler) handleSavedItems(w http.ResponseWriter, r *http.Request) {
 	userID := request.UserID(r)
 	slog.Debug("[Fever] Fetching saved items",
 		slog.Int64("user_id", userID),
@@ -407,7 +401,7 @@ mark=item
 as=? where ? is replaced with read, saved or unsaved
 id=? where ? is replaced with the id of the item to modify
 */
-func (h *handler) handleWriteItems(w http.ResponseWriter, r *http.Request) {
+func (h *feverHandler) handleWriteItems(w http.ResponseWriter, r *http.Request) {
 	userID := request.UserID(r)
 	slog.Debug("[Fever] Receiving mark=item call",
 		slog.Int64("user_id", userID),
@@ -489,7 +483,7 @@ as=read
 id=? where ? is replaced with the id of the feed or group to modify
 before=? where ? is replaced with the Unix timestamp of the the local client’s most recent items API request
 */
-func (h *handler) handleWriteFeeds(w http.ResponseWriter, r *http.Request) {
+func (h *feverHandler) handleWriteFeeds(w http.ResponseWriter, r *http.Request) {
 	userID := request.UserID(r)
 	feedID := request.FormInt64Value(r, "id")
 	before := time.Unix(request.FormInt64Value(r, "before"), 0)
@@ -504,16 +498,10 @@ func (h *handler) handleWriteFeeds(w http.ResponseWriter, r *http.Request) {
 		return
 	}
 
-	go func() {
-		if err := h.store.MarkFeedAsRead(userID, feedID, before); err != nil {
-			slog.Error("[Fever] Unable to mark feed as read",
-				slog.Int64("user_id", userID),
-				slog.Int64("feed_id", feedID),
-				slog.Time("before_ts", before),
-				slog.Any("error", err),
-			)
-		}
-	}()
+	if err := h.store.MarkFeedAsRead(userID, feedID, before); err != nil {
+		response.JSONServerError(w, r, err)
+		return
+	}
 
 	response.JSON(w, r, newBaseResponse())
 }
@@ -524,39 +512,35 @@ as=read
 id=? where ? is replaced with the id of the feed or group to modify
 before=? where ? is replaced with the Unix timestamp of the the local client’s most recent items API request
 */
-func (h *handler) handleWriteGroups(w http.ResponseWriter, r *http.Request) {
+func (h *feverHandler) handleWriteGroups(w http.ResponseWriter, r *http.Request) {
 	userID := request.UserID(r)
 	groupID := request.FormInt64Value(r, "id")
-	before := time.Unix(request.FormInt64Value(r, "before"), 0)
-
-	slog.Debug("[Fever] Mark group as read before a given date",
-		slog.Int64("user_id", userID),
-		slog.Int64("group_id", groupID),
-		slog.Time("before_ts", before),
-	)
 
 	if groupID < 0 {
 		return
 	}
 
-	go func() {
-		var err error
+	var err error
 
-		if groupID == 0 {
-			err = h.store.MarkAllAsRead(userID)
-		} else {
-			err = h.store.MarkCategoryAsRead(userID, groupID, before)
-		}
+	if groupID == 0 {
+		err = h.store.MarkAllAsRead(userID)
+		slog.Debug("[Fever] Mark all items as read",
+			slog.Int64("user_id", userID),
+		)
+	} else {
+		before := time.Unix(request.FormInt64Value(r, "before"), 0)
+		err = h.store.MarkCategoryAsRead(userID, groupID, before)
+		slog.Debug("[Fever] Mark group as read before a given date",
+			slog.Int64("user_id", userID),
+			slog.Int64("group_id", groupID),
+			slog.Time("before_ts", before),
+		)
+	}
 
-		if err != nil {
-			slog.Error("[Fever] Unable to mark group as read",
-				slog.Int64("user_id", userID),
-				slog.Int64("group_id", groupID),
-				slog.Time("before_ts", before),
-				slog.Any("error", err),
-			)
-		}
-	}()
+	if err != nil {
+		response.JSONServerError(w, r, err)
+		return
+	}
 
 	response.JSON(w, r, newBaseResponse())
 }
@@ -567,7 +551,7 @@ A feeds_group object has the following members:
 	group_id (positive integer)
 	feed_ids (string/comma-separated list of positive integers)
 */
-func (h *handler) buildFeedGroups(feeds model.Feeds) []feedsGroups {
+func buildFeedGroups(feeds model.Feeds) []feedsGroups {
 	feedsGroupedByCategory := make(map[int64][]string, len(feeds))
 	for _, feed := range feeds {
 		feedsGroupedByCategory[feed.Category.ID] = append(feedsGroupedByCategory[feed.Category.ID], strconv.FormatInt(feed.ID, 10))

+ 48 - 53
internal/fever/middleware.go

@@ -13,66 +13,61 @@ import (
 	"miniflux.app/v2/internal/storage"
 )
 
-type middleware struct {
-	store *storage.Storage
-}
-
-func newMiddleware(s *storage.Storage) *middleware {
-	return &middleware{s}
-}
+// Middleware returns the Fever authentication middleware.
+func Middleware(store *storage.Storage) func(http.Handler) http.Handler {
+	return func(next http.Handler) http.Handler {
+		return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+			clientIP := request.ClientIP(r)
+			apiKey := r.FormValue("api_key")
+			if apiKey == "" {
+				slog.Warn("[Fever] No API key provided",
+					slog.Bool("authentication_failed", true),
+					slog.String("client_ip", clientIP),
+					slog.String("user_agent", r.UserAgent()),
+				)
+				response.JSON(w, r, newAuthFailureResponse())
+				return
+			}
 
-func (m *middleware) serve(next http.Handler) http.Handler {
-	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
-		clientIP := request.ClientIP(r)
-		apiKey := r.FormValue("api_key")
-		if apiKey == "" {
-			slog.Warn("[Fever] No API key provided",
-				slog.Bool("authentication_failed", true),
-				slog.String("client_ip", clientIP),
-				slog.String("user_agent", r.UserAgent()),
-			)
-			response.JSON(w, r, newAuthFailureResponse())
-			return
-		}
+			user, err := store.UserByFeverToken(apiKey)
+			if err != nil {
+				slog.Error("[Fever] Unable to fetch user by API key",
+					slog.Bool("authentication_failed", true),
+					slog.String("client_ip", clientIP),
+					slog.String("user_agent", r.UserAgent()),
+					slog.Any("error", err),
+				)
+				response.JSON(w, r, newAuthFailureResponse())
+				return
+			}
 
-		user, err := m.store.UserByFeverToken(apiKey)
-		if err != nil {
-			slog.Error("[Fever] Unable to fetch user by API key",
-				slog.Bool("authentication_failed", true),
-				slog.String("client_ip", clientIP),
-				slog.String("user_agent", r.UserAgent()),
-				slog.Any("error", err),
-			)
-			response.JSON(w, r, newAuthFailureResponse())
-			return
-		}
+			if user == nil {
+				slog.Warn("[Fever] No user found with the API key provided",
+					slog.Bool("authentication_failed", true),
+					slog.String("client_ip", clientIP),
+					slog.String("user_agent", r.UserAgent()),
+				)
+				response.JSON(w, r, newAuthFailureResponse())
+				return
+			}
 
-		if user == nil {
-			slog.Warn("[Fever] No user found with the API key provided",
-				slog.Bool("authentication_failed", true),
+			slog.Info("[Fever] User authenticated successfully",
+				slog.Bool("authentication_successful", true),
 				slog.String("client_ip", clientIP),
 				slog.String("user_agent", r.UserAgent()),
+				slog.Int64("user_id", user.ID),
+				slog.String("username", user.Username),
 			)
-			response.JSON(w, r, newAuthFailureResponse())
-			return
-		}
-
-		slog.Info("[Fever] User authenticated successfully",
-			slog.Bool("authentication_successful", true),
-			slog.String("client_ip", clientIP),
-			slog.String("user_agent", r.UserAgent()),
-			slog.Int64("user_id", user.ID),
-			slog.String("username", user.Username),
-		)
 
-		m.store.SetLastLogin(user.ID)
+			store.SetLastLogin(user.ID)
 
-		ctx := r.Context()
-		ctx = context.WithValue(ctx, request.UserIDContextKey, user.ID)
-		ctx = context.WithValue(ctx, request.UserTimezoneContextKey, user.Timezone)
-		ctx = context.WithValue(ctx, request.IsAdminUserContextKey, user.IsAdmin)
-		ctx = context.WithValue(ctx, request.IsAuthenticatedContextKey, true)
+			ctx := r.Context()
+			ctx = context.WithValue(ctx, request.UserIDContextKey, user.ID)
+			ctx = context.WithValue(ctx, request.UserTimezoneContextKey, user.Timezone)
+			ctx = context.WithValue(ctx, request.IsAdminUserContextKey, user.IsAdmin)
+			ctx = context.WithValue(ctx, request.IsAuthenticatedContextKey, true)
 
-		next.ServeHTTP(w, r.WithContext(ctx))
-	})
+			next.ServeHTTP(w, r.WithContext(ctx))
+		})
+	}
 }

+ 6 - 1
internal/http/server/httpd.go

@@ -18,6 +18,7 @@ import (
 	"miniflux.app/v2/internal/fever"
 	"miniflux.app/v2/internal/googlereader"
 	"miniflux.app/v2/internal/http/request"
+	"miniflux.app/v2/internal/mediaproxy"
 	"miniflux.app/v2/internal/storage"
 	"miniflux.app/v2/internal/ui"
 	"miniflux.app/v2/internal/worker"
@@ -239,7 +240,11 @@ func setupHandler(store *storage.Storage, pool *worker.Pool) *mux.Router {
 
 	subrouter.Use(middleware)
 
-	fever.Serve(subrouter, store)
+	feverSubrouter := subrouter.PathPrefix("/fever").Subrouter()
+	feverSubrouter.Use(fever.Middleware(store))
+	feverSubrouter.Handle("/", fever.NewHandler(store, func(content string) string {
+		return mediaproxy.RewriteDocumentWithAbsoluteProxyURL(subrouter, content)
+	})).Name("feverEndpoint")
 	googlereader.Serve(subrouter, store)
 	if config.Opts.HasAPI() {
 		api.Serve(subrouter, store, pool)