handler.go 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585
  1. // SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
  2. // SPDX-License-Identifier: Apache-2.0
  3. package fever // import "miniflux.app/v2/internal/fever"
  4. import (
  5. "log/slog"
  6. "net/http"
  7. "strconv"
  8. "strings"
  9. "time"
  10. "miniflux.app/v2/internal/http/request"
  11. "miniflux.app/v2/internal/http/response/json"
  12. "miniflux.app/v2/internal/integration"
  13. "miniflux.app/v2/internal/mediaproxy"
  14. "miniflux.app/v2/internal/model"
  15. "miniflux.app/v2/internal/storage"
  16. "github.com/gorilla/mux"
  17. )
  18. // Serve handles Fever API calls.
  19. func Serve(router *mux.Router, store *storage.Storage) {
  20. handler := &handler{store, router}
  21. sr := router.PathPrefix("/fever").Subrouter()
  22. sr.Use(newMiddleware(store).serve)
  23. sr.HandleFunc("/", handler.serve).Name("feverEndpoint")
  24. }
  25. type handler struct {
  26. store *storage.Storage
  27. router *mux.Router
  28. }
  29. func (h *handler) serve(w http.ResponseWriter, r *http.Request) {
  30. switch {
  31. case request.HasQueryParam(r, "groups"):
  32. h.handleGroups(w, r)
  33. case request.HasQueryParam(r, "feeds"):
  34. h.handleFeeds(w, r)
  35. case request.HasQueryParam(r, "favicons"):
  36. h.handleFavicons(w, r)
  37. case request.HasQueryParam(r, "unread_item_ids"):
  38. h.handleUnreadItems(w, r)
  39. case request.HasQueryParam(r, "saved_item_ids"):
  40. h.handleSavedItems(w, r)
  41. case request.HasQueryParam(r, "items"):
  42. h.handleItems(w, r)
  43. case r.FormValue("mark") == "item":
  44. h.handleWriteItems(w, r)
  45. case r.FormValue("mark") == "feed":
  46. h.handleWriteFeeds(w, r)
  47. case r.FormValue("mark") == "group":
  48. h.handleWriteGroups(w, r)
  49. default:
  50. json.OK(w, r, newBaseResponse())
  51. }
  52. }
  53. /*
  54. A request with the groups argument will return two additional members:
  55. groups contains an array of group objects
  56. feeds_groups contains an array of feeds_group objects
  57. A group object has the following members:
  58. id (positive integer)
  59. title (utf-8 string)
  60. The feeds_group object is documented under “Feeds/Groups Relationships.”
  61. The “Kindling” super group is not included in this response and is composed of all feeds with
  62. an is_spark equal to 0.
  63. The “Sparks” super group is not included in this response and is composed of all feeds with an
  64. is_spark equal to 1.
  65. */
  66. func (h *handler) handleGroups(w http.ResponseWriter, r *http.Request) {
  67. userID := request.UserID(r)
  68. slog.Debug("[Fever] Fetching groups",
  69. slog.Int64("user_id", userID),
  70. )
  71. categories, err := h.store.Categories(userID)
  72. if err != nil {
  73. json.ServerError(w, r, err)
  74. return
  75. }
  76. feeds, err := h.store.Feeds(userID)
  77. if err != nil {
  78. json.ServerError(w, r, err)
  79. return
  80. }
  81. var result groupsResponse
  82. for _, category := range categories {
  83. result.Groups = append(result.Groups, group{ID: category.ID, Title: category.Title})
  84. }
  85. result.FeedsGroups = h.buildFeedGroups(feeds)
  86. result.SetCommonValues()
  87. json.OK(w, r, result)
  88. }
  89. /*
  90. A request with the feeds argument will return two additional members:
  91. feeds contains an array of group objects
  92. feeds_groups contains an array of feeds_group objects
  93. A feed object has the following members:
  94. id (positive integer)
  95. favicon_id (positive integer)
  96. title (utf-8 string)
  97. url (utf-8 string)
  98. site_url (utf-8 string)
  99. is_spark (boolean integer)
  100. last_updated_on_time (Unix timestamp/integer)
  101. The feeds_group object is documented under “Feeds/Groups Relationships.”
  102. The “All Items” super feed is not included in this response and is composed of all items from all feeds
  103. that belong to a given group. For the “Kindling” super group and all user created groups the items
  104. should be limited to feeds with an is_spark equal to 0.
  105. For the “Sparks” super group the items should be limited to feeds with an is_spark equal to 1.
  106. */
  107. func (h *handler) handleFeeds(w http.ResponseWriter, r *http.Request) {
  108. userID := request.UserID(r)
  109. slog.Debug("[Fever] Fetching feeds",
  110. slog.Int64("user_id", userID),
  111. )
  112. feeds, err := h.store.Feeds(userID)
  113. if err != nil {
  114. json.ServerError(w, r, err)
  115. return
  116. }
  117. var result feedsResponse
  118. result.Feeds = make([]feed, 0, len(feeds))
  119. for _, f := range feeds {
  120. subscription := feed{
  121. ID: f.ID,
  122. Title: f.Title,
  123. URL: f.FeedURL,
  124. SiteURL: f.SiteURL,
  125. IsSpark: 0,
  126. LastUpdated: f.CheckedAt.Unix(),
  127. }
  128. if f.Icon != nil {
  129. subscription.FaviconID = f.Icon.IconID
  130. }
  131. result.Feeds = append(result.Feeds, subscription)
  132. }
  133. result.FeedsGroups = h.buildFeedGroups(feeds)
  134. result.SetCommonValues()
  135. json.OK(w, r, result)
  136. }
  137. /*
  138. A request with the favicons argument will return one additional member:
  139. favicons contains an array of favicon objects
  140. A favicon object has the following members:
  141. id (positive integer)
  142. data (base64 encoded image data; prefixed by image type)
  143. An example data value:
  144. image/gif;base64,R0lGODlhAQABAIAAAObm5gAAACH5BAEAAAAALAAAAAABAAEAAAICRAEAOw==
  145. The data member of a favicon object can be used with the data: protocol to embed an image in CSS or HTML.
  146. A PHP/HTML example:
  147. echo '<img src="data:'.$favicon['data'].'">';
  148. */
  149. func (h *handler) handleFavicons(w http.ResponseWriter, r *http.Request) {
  150. userID := request.UserID(r)
  151. slog.Debug("[Fever] Fetching favicons",
  152. slog.Int64("user_id", userID),
  153. )
  154. icons, err := h.store.Icons(userID)
  155. if err != nil {
  156. json.ServerError(w, r, err)
  157. return
  158. }
  159. var result faviconsResponse
  160. for _, i := range icons {
  161. result.Favicons = append(result.Favicons, favicon{
  162. ID: i.ID,
  163. Data: i.DataURL(),
  164. })
  165. }
  166. result.SetCommonValues()
  167. json.OK(w, r, result)
  168. }
  169. /*
  170. A request with the items argument will return two additional members:
  171. items contains an array of item objects
  172. total_items contains the total number of items stored in the database (added in API version 2)
  173. An item object has the following members:
  174. id (positive integer)
  175. feed_id (positive integer)
  176. title (utf-8 string)
  177. author (utf-8 string)
  178. html (utf-8 string)
  179. url (utf-8 string)
  180. is_saved (boolean integer)
  181. is_read (boolean integer)
  182. created_on_time (Unix timestamp/integer)
  183. Most servers won’t have enough memory allocated to PHP to dump all items at once.
  184. Three optional arguments control determine the items included in the response.
  185. Use the since_id argument with the highest id of locally cached items to request 50 additional items.
  186. Repeat until the items array in the response is empty.
  187. Use the max_id argument with the lowest id of locally cached items (or 0 initially) to request 50 previous items.
  188. Repeat until the items array in the response is empty. (added in API version 2)
  189. Use the with_ids argument with a comma-separated list of item ids to request (a maximum of 50) specific items.
  190. (added in API version 2)
  191. */
  192. func (h *handler) handleItems(w http.ResponseWriter, r *http.Request) {
  193. var result itemsResponse
  194. userID := request.UserID(r)
  195. builder := h.store.NewEntryQueryBuilder(userID)
  196. builder.WithoutStatus(model.EntryStatusRemoved)
  197. builder.WithLimit(50)
  198. switch {
  199. case request.HasQueryParam(r, "since_id"):
  200. sinceID := request.QueryInt64Param(r, "since_id", 0)
  201. if sinceID > 0 {
  202. slog.Debug("[Fever] Fetching items since a given date",
  203. slog.Int64("user_id", userID),
  204. slog.Int64("since_id", sinceID),
  205. )
  206. builder.AfterEntryID(sinceID)
  207. builder.WithSorting("id", "ASC")
  208. }
  209. case request.HasQueryParam(r, "max_id"):
  210. maxID := request.QueryInt64Param(r, "max_id", 0)
  211. if maxID == 0 {
  212. slog.Debug("[Fever] Fetching most recent items",
  213. slog.Int64("user_id", userID),
  214. )
  215. builder.WithSorting("id", "DESC")
  216. } else if maxID > 0 {
  217. slog.Debug("[Fever] Fetching items before a given item ID",
  218. slog.Int64("user_id", userID),
  219. slog.Int64("max_id", maxID),
  220. )
  221. builder.BeforeEntryID(maxID)
  222. builder.WithSorting("id", "DESC")
  223. }
  224. case request.HasQueryParam(r, "with_ids"):
  225. csvItemIDs := request.QueryStringParam(r, "with_ids", "")
  226. if csvItemIDs != "" {
  227. var itemIDs []int64
  228. for _, strItemID := range strings.Split(csvItemIDs, ",") {
  229. strItemID = strings.TrimSpace(strItemID)
  230. itemID, _ := strconv.ParseInt(strItemID, 10, 64)
  231. itemIDs = append(itemIDs, itemID)
  232. }
  233. builder.WithEntryIDs(itemIDs)
  234. }
  235. default:
  236. slog.Debug("[Fever] Fetching oldest items",
  237. slog.Int64("user_id", userID),
  238. )
  239. }
  240. entries, err := builder.GetEntries()
  241. if err != nil {
  242. json.ServerError(w, r, err)
  243. return
  244. }
  245. builder = h.store.NewEntryQueryBuilder(userID)
  246. builder.WithoutStatus(model.EntryStatusRemoved)
  247. result.Total, err = builder.CountEntries()
  248. if err != nil {
  249. json.ServerError(w, r, err)
  250. return
  251. }
  252. result.Items = make([]item, 0, len(entries))
  253. for _, entry := range entries {
  254. isRead := 0
  255. if entry.Status == model.EntryStatusRead {
  256. isRead = 1
  257. }
  258. isSaved := 0
  259. if entry.Starred {
  260. isSaved = 1
  261. }
  262. result.Items = append(result.Items, item{
  263. ID: entry.ID,
  264. FeedID: entry.FeedID,
  265. Title: entry.Title,
  266. Author: entry.Author,
  267. HTML: mediaproxy.RewriteDocumentWithAbsoluteProxyURL(h.router, entry.Content),
  268. URL: entry.URL,
  269. IsSaved: isSaved,
  270. IsRead: isRead,
  271. CreatedAt: entry.Date.Unix(),
  272. })
  273. }
  274. result.SetCommonValues()
  275. json.OK(w, r, result)
  276. }
  277. /*
  278. The unread_item_ids and saved_item_ids arguments can be used to keep your local cache synced
  279. with the remote Fever installation.
  280. A request with the unread_item_ids argument will return one additional member:
  281. unread_item_ids (string/comma-separated list of positive integers)
  282. */
  283. func (h *handler) handleUnreadItems(w http.ResponseWriter, r *http.Request) {
  284. userID := request.UserID(r)
  285. slog.Debug("[Fever] Fetching unread items",
  286. slog.Int64("user_id", userID),
  287. )
  288. builder := h.store.NewEntryQueryBuilder(userID)
  289. builder.WithStatus(model.EntryStatusUnread)
  290. rawEntryIDs, err := builder.GetEntryIDs()
  291. if err != nil {
  292. json.ServerError(w, r, err)
  293. return
  294. }
  295. itemIDs := make([]string, 0, len(rawEntryIDs))
  296. for _, entryID := range rawEntryIDs {
  297. itemIDs = append(itemIDs, strconv.FormatInt(entryID, 10))
  298. }
  299. var result unreadResponse
  300. result.ItemIDs = strings.Join(itemIDs, ",")
  301. result.SetCommonValues()
  302. json.OK(w, r, result)
  303. }
  304. /*
  305. The unread_item_ids and saved_item_ids arguments can be used to keep your local cache synced
  306. with the remote Fever installation.
  307. A request with the saved_item_ids argument will return one additional member:
  308. saved_item_ids (string/comma-separated list of positive integers)
  309. */
  310. func (h *handler) handleSavedItems(w http.ResponseWriter, r *http.Request) {
  311. userID := request.UserID(r)
  312. slog.Debug("[Fever] Fetching saved items",
  313. slog.Int64("user_id", userID),
  314. )
  315. builder := h.store.NewEntryQueryBuilder(userID)
  316. builder.WithStarred(true)
  317. entryIDs, err := builder.GetEntryIDs()
  318. if err != nil {
  319. json.ServerError(w, r, err)
  320. return
  321. }
  322. itemsIDs := make([]string, 0, len(entryIDs))
  323. for _, entryID := range entryIDs {
  324. itemsIDs = append(itemsIDs, strconv.FormatInt(entryID, 10))
  325. }
  326. result := &savedResponse{ItemIDs: strings.Join(itemsIDs, ",")}
  327. result.SetCommonValues()
  328. json.OK(w, r, result)
  329. }
  330. /*
  331. mark=item
  332. as=? where ? is replaced with read, saved or unsaved
  333. id=? where ? is replaced with the id of the item to modify
  334. */
  335. func (h *handler) handleWriteItems(w http.ResponseWriter, r *http.Request) {
  336. userID := request.UserID(r)
  337. slog.Debug("[Fever] Receiving mark=item call",
  338. slog.Int64("user_id", userID),
  339. )
  340. entryID := request.FormInt64Value(r, "id")
  341. if entryID <= 0 {
  342. return
  343. }
  344. builder := h.store.NewEntryQueryBuilder(userID)
  345. builder.WithEntryID(entryID)
  346. builder.WithoutStatus(model.EntryStatusRemoved)
  347. entry, err := builder.GetEntry()
  348. if err != nil {
  349. json.ServerError(w, r, err)
  350. return
  351. }
  352. if entry == nil {
  353. slog.Debug("[Fever] Entry not found",
  354. slog.Int64("user_id", userID),
  355. slog.Int64("entry_id", entryID),
  356. )
  357. json.OK(w, r, newBaseResponse())
  358. return
  359. }
  360. switch r.FormValue("as") {
  361. case "read":
  362. slog.Debug("[Fever] Mark entry as read",
  363. slog.Int64("user_id", userID),
  364. slog.Int64("entry_id", entryID),
  365. )
  366. h.store.SetEntriesStatus(userID, []int64{entryID}, model.EntryStatusRead)
  367. case "unread":
  368. slog.Debug("[Fever] Mark entry as unread",
  369. slog.Int64("user_id", userID),
  370. slog.Int64("entry_id", entryID),
  371. )
  372. h.store.SetEntriesStatus(userID, []int64{entryID}, model.EntryStatusUnread)
  373. case "saved":
  374. slog.Debug("[Fever] Mark entry as saved",
  375. slog.Int64("user_id", userID),
  376. slog.Int64("entry_id", entryID),
  377. )
  378. if err := h.store.ToggleStarred(userID, entryID); err != nil {
  379. json.ServerError(w, r, err)
  380. return
  381. }
  382. settings, err := h.store.Integration(userID)
  383. if err != nil {
  384. json.ServerError(w, r, err)
  385. return
  386. }
  387. go func() {
  388. integration.SendEntry(entry, settings)
  389. }()
  390. case "unsaved":
  391. slog.Debug("[Fever] Mark entry as unsaved",
  392. slog.Int64("user_id", userID),
  393. slog.Int64("entry_id", entryID),
  394. )
  395. if err := h.store.ToggleStarred(userID, entryID); err != nil {
  396. json.ServerError(w, r, err)
  397. return
  398. }
  399. }
  400. json.OK(w, r, newBaseResponse())
  401. }
  402. /*
  403. mark=feed
  404. as=read
  405. id=? where ? is replaced with the id of the feed or group to modify
  406. before=? where ? is replaced with the Unix timestamp of the the local client’s most recent items API request
  407. */
  408. func (h *handler) handleWriteFeeds(w http.ResponseWriter, r *http.Request) {
  409. userID := request.UserID(r)
  410. feedID := request.FormInt64Value(r, "id")
  411. before := time.Unix(request.FormInt64Value(r, "before"), 0)
  412. slog.Debug("[Fever] Mark feed as read before a given date",
  413. slog.Int64("user_id", userID),
  414. slog.Int64("feed_id", feedID),
  415. slog.Time("before_ts", before),
  416. )
  417. if feedID <= 0 {
  418. return
  419. }
  420. go func() {
  421. if err := h.store.MarkFeedAsRead(userID, feedID, before); err != nil {
  422. slog.Error("[Fever] Unable to mark feed as read",
  423. slog.Int64("user_id", userID),
  424. slog.Int64("feed_id", feedID),
  425. slog.Time("before_ts", before),
  426. slog.Any("error", err),
  427. )
  428. }
  429. }()
  430. json.OK(w, r, newBaseResponse())
  431. }
  432. /*
  433. mark=group
  434. as=read
  435. id=? where ? is replaced with the id of the feed or group to modify
  436. before=? where ? is replaced with the Unix timestamp of the the local client’s most recent items API request
  437. */
  438. func (h *handler) handleWriteGroups(w http.ResponseWriter, r *http.Request) {
  439. userID := request.UserID(r)
  440. groupID := request.FormInt64Value(r, "id")
  441. before := time.Unix(request.FormInt64Value(r, "before"), 0)
  442. slog.Debug("[Fever] Mark group as read before a given date",
  443. slog.Int64("user_id", userID),
  444. slog.Int64("group_id", groupID),
  445. slog.Time("before_ts", before),
  446. )
  447. if groupID < 0 {
  448. return
  449. }
  450. go func() {
  451. var err error
  452. if groupID == 0 {
  453. err = h.store.MarkAllAsRead(userID)
  454. } else {
  455. err = h.store.MarkCategoryAsRead(userID, groupID, before)
  456. }
  457. if err != nil {
  458. slog.Error("[Fever] Unable to mark group as read",
  459. slog.Int64("user_id", userID),
  460. slog.Int64("group_id", groupID),
  461. slog.Time("before_ts", before),
  462. slog.Any("error", err),
  463. )
  464. }
  465. }()
  466. json.OK(w, r, newBaseResponse())
  467. }
  468. /*
  469. A feeds_group object has the following members:
  470. group_id (positive integer)
  471. feed_ids (string/comma-separated list of positive integers)
  472. */
  473. func (h *handler) buildFeedGroups(feeds model.Feeds) []feedsGroups {
  474. feedsGroupedByCategory := make(map[int64][]string, len(feeds))
  475. for _, feed := range feeds {
  476. feedsGroupedByCategory[feed.Category.ID] = append(feedsGroupedByCategory[feed.Category.ID], strconv.FormatInt(feed.ID, 10))
  477. }
  478. result := make([]feedsGroups, 0, len(feedsGroupedByCategory))
  479. for categoryID, feedIDs := range feedsGroupedByCategory {
  480. result = append(result, feedsGroups{
  481. GroupID: categoryID,
  482. FeedIDs: strings.Join(feedIDs, ","),
  483. })
  484. }
  485. return result
  486. }