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/request"
 	"miniflux.app/v2/internal/http/response"
 	"miniflux.app/v2/internal/http/response"
 	"miniflux.app/v2/internal/integration"
 	"miniflux.app/v2/internal/integration"
-	"miniflux.app/v2/internal/mediaproxy"
 	"miniflux.app/v2/internal/model"
 	"miniflux.app/v2/internal/model"
 	"miniflux.app/v2/internal/storage"
 	"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 {
 	switch {
 	case request.HasQueryParam(r, "groups"):
 	case request.HasQueryParam(r, "groups"):
 		h.handleGroups(w, r)
 		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
 The “Sparks” super group is not included in this response and is composed of all feeds with an
 is_spark equal to 1.
 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)
 	userID := request.UserID(r)
 	slog.Debug("[Fever] Fetching groups",
 	slog.Debug("[Fever] Fetching groups",
 		slog.Int64("user_id", userID),
 		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.Groups = append(result.Groups, group{ID: category.ID, Title: category.Title})
 	}
 	}
 
 
-	result.FeedsGroups = h.buildFeedGroups(feeds)
+	result.FeedsGroups = buildFeedGroups(feeds)
 	result.SetCommonValues()
 	result.SetCommonValues()
 	response.JSON(w, r, result)
 	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.
 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)
 	userID := request.UserID(r)
 	slog.Debug("[Fever] Fetching feeds",
 	slog.Debug("[Fever] Fetching feeds",
 		slog.Int64("user_id", userID),
 		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.Feeds = append(result.Feeds, subscription)
 	}
 	}
 
 
-	result.FeedsGroups = h.buildFeedGroups(feeds)
+	result.FeedsGroups = buildFeedGroups(feeds)
 	result.SetCommonValues()
 	result.SetCommonValues()
 	response.JSON(w, r, result)
 	response.JSON(w, r, result)
 }
 }
@@ -185,7 +179,7 @@ A PHP/HTML example:
 
 
 	echo '<img src="data:'.$favicon['data'].'">';
 	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)
 	userID := request.UserID(r)
 	slog.Debug("[Fever] Fetching favicons",
 	slog.Debug("[Fever] Fetching favicons",
 		slog.Int64("user_id", userID),
 		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.
 	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)
 	(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
 	var result itemsResponse
 
 
 	userID := request.UserID(r)
 	userID := request.UserID(r)
@@ -324,7 +318,7 @@ func (h *handler) handleItems(w http.ResponseWriter, r *http.Request) {
 			FeedID:    entry.FeedID,
 			FeedID:    entry.FeedID,
 			Title:     entry.Title,
 			Title:     entry.Title,
 			Author:    entry.Author,
 			Author:    entry.Author,
-			HTML:      mediaproxy.RewriteDocumentWithAbsoluteProxyURL(h.router, entry.Content),
+			HTML:      h.proxyRewriter(entry.Content),
 			URL:       entry.URL,
 			URL:       entry.URL,
 			IsSaved:   isSaved,
 			IsSaved:   isSaved,
 			IsRead:    isRead,
 			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)
 	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)
 	userID := request.UserID(r)
 	slog.Debug("[Fever] Fetching unread items",
 	slog.Debug("[Fever] Fetching unread items",
 		slog.Int64("user_id", userID),
 		slog.Int64("user_id", userID),
@@ -377,7 +371,7 @@ with the remote Fever installation.
 
 
 	saved_item_ids (string/comma-separated list of positive integers)
 	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)
 	userID := request.UserID(r)
 	slog.Debug("[Fever] Fetching saved items",
 	slog.Debug("[Fever] Fetching saved items",
 		slog.Int64("user_id", userID),
 		slog.Int64("user_id", userID),
@@ -407,7 +401,7 @@ mark=item
 as=? where ? is replaced with read, saved or unsaved
 as=? where ? is replaced with read, saved or unsaved
 id=? where ? is replaced with the id of the item to modify
 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)
 	userID := request.UserID(r)
 	slog.Debug("[Fever] Receiving mark=item call",
 	slog.Debug("[Fever] Receiving mark=item call",
 		slog.Int64("user_id", userID),
 		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
 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
 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)
 	userID := request.UserID(r)
 	feedID := request.FormInt64Value(r, "id")
 	feedID := request.FormInt64Value(r, "id")
 	before := time.Unix(request.FormInt64Value(r, "before"), 0)
 	before := time.Unix(request.FormInt64Value(r, "before"), 0)
@@ -504,16 +498,10 @@ func (h *handler) handleWriteFeeds(w http.ResponseWriter, r *http.Request) {
 		return
 		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())
 	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
 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
 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)
 	userID := request.UserID(r)
 	groupID := request.FormInt64Value(r, "id")
 	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 {
 	if groupID < 0 {
 		return
 		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())
 	response.JSON(w, r, newBaseResponse())
 }
 }
@@ -567,7 +551,7 @@ A feeds_group object has the following members:
 	group_id (positive integer)
 	group_id (positive integer)
 	feed_ids (string/comma-separated list of positive integers)
 	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))
 	feedsGroupedByCategory := make(map[int64][]string, len(feeds))
 	for _, feed := range feeds {
 	for _, feed := range feeds {
 		feedsGroupedByCategory[feed.Category.ID] = append(feedsGroupedByCategory[feed.Category.ID], strconv.FormatInt(feed.ID, 10))
 		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"
 	"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("client_ip", clientIP),
 				slog.String("user_agent", r.UserAgent()),
 				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/fever"
 	"miniflux.app/v2/internal/googlereader"
 	"miniflux.app/v2/internal/googlereader"
 	"miniflux.app/v2/internal/http/request"
 	"miniflux.app/v2/internal/http/request"
+	"miniflux.app/v2/internal/mediaproxy"
 	"miniflux.app/v2/internal/storage"
 	"miniflux.app/v2/internal/storage"
 	"miniflux.app/v2/internal/ui"
 	"miniflux.app/v2/internal/ui"
 	"miniflux.app/v2/internal/worker"
 	"miniflux.app/v2/internal/worker"
@@ -239,7 +240,11 @@ func setupHandler(store *storage.Storage, pool *worker.Pool) *mux.Router {
 
 
 	subrouter.Use(middleware)
 	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)
 	googlereader.Serve(subrouter, store)
 	if config.Opts.HasAPI() {
 	if config.Opts.HasAPI() {
 		api.Serve(subrouter, store, pool)
 		api.Serve(subrouter, store, pool)