| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585 |
- // SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
- // SPDX-License-Identifier: Apache-2.0
- package fever // import "miniflux.app/v2/internal/fever"
- import (
- "log/slog"
- "net/http"
- "strconv"
- "strings"
- "time"
- "miniflux.app/v2/internal/http/request"
- "miniflux.app/v2/internal/http/response/json"
- "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")
- }
- type handler struct {
- store *storage.Storage
- router *mux.Router
- }
- func (h *handler) serve(w http.ResponseWriter, r *http.Request) {
- switch {
- case request.HasQueryParam(r, "groups"):
- h.handleGroups(w, r)
- case request.HasQueryParam(r, "feeds"):
- h.handleFeeds(w, r)
- case request.HasQueryParam(r, "favicons"):
- h.handleFavicons(w, r)
- case request.HasQueryParam(r, "unread_item_ids"):
- h.handleUnreadItems(w, r)
- case request.HasQueryParam(r, "saved_item_ids"):
- h.handleSavedItems(w, r)
- case request.HasQueryParam(r, "items"):
- h.handleItems(w, r)
- case r.FormValue("mark") == "item":
- h.handleWriteItems(w, r)
- case r.FormValue("mark") == "feed":
- h.handleWriteFeeds(w, r)
- case r.FormValue("mark") == "group":
- h.handleWriteGroups(w, r)
- default:
- json.OK(w, r, newBaseResponse())
- }
- }
- /*
- A request with the groups argument will return two additional members:
- groups contains an array of group objects
- feeds_groups contains an array of feeds_group objects
- A group object has the following members:
- id (positive integer)
- title (utf-8 string)
- The feeds_group object is documented under “Feeds/Groups Relationships.”
- The “Kindling” super group is not included in this response and is composed of all feeds with
- 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) {
- userID := request.UserID(r)
- slog.Debug("[Fever] Fetching groups",
- slog.Int64("user_id", userID),
- )
- categories, err := h.store.Categories(userID)
- if err != nil {
- json.ServerError(w, r, err)
- return
- }
- feeds, err := h.store.Feeds(userID)
- if err != nil {
- json.ServerError(w, r, err)
- return
- }
- var result groupsResponse
- for _, category := range categories {
- result.Groups = append(result.Groups, group{ID: category.ID, Title: category.Title})
- }
- result.FeedsGroups = h.buildFeedGroups(feeds)
- result.SetCommonValues()
- json.OK(w, r, result)
- }
- /*
- A request with the feeds argument will return two additional members:
- feeds contains an array of group objects
- feeds_groups contains an array of feeds_group objects
- A feed object has the following members:
- id (positive integer)
- favicon_id (positive integer)
- title (utf-8 string)
- url (utf-8 string)
- site_url (utf-8 string)
- is_spark (boolean integer)
- last_updated_on_time (Unix timestamp/integer)
- The feeds_group object is documented under “Feeds/Groups Relationships.”
- The “All Items” super feed is not included in this response and is composed of all items from all feeds
- that belong to a given group. For the “Kindling” super group and all user created groups the items
- 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) {
- userID := request.UserID(r)
- slog.Debug("[Fever] Fetching feeds",
- slog.Int64("user_id", userID),
- )
- feeds, err := h.store.Feeds(userID)
- if err != nil {
- json.ServerError(w, r, err)
- return
- }
- var result feedsResponse
- result.Feeds = make([]feed, 0, len(feeds))
- for _, f := range feeds {
- subscription := feed{
- ID: f.ID,
- Title: f.Title,
- URL: f.FeedURL,
- SiteURL: f.SiteURL,
- IsSpark: 0,
- LastUpdated: f.CheckedAt.Unix(),
- }
- if f.Icon != nil {
- subscription.FaviconID = f.Icon.IconID
- }
- result.Feeds = append(result.Feeds, subscription)
- }
- result.FeedsGroups = h.buildFeedGroups(feeds)
- result.SetCommonValues()
- json.OK(w, r, result)
- }
- /*
- A request with the favicons argument will return one additional member:
- favicons contains an array of favicon objects
- A favicon object has the following members:
- id (positive integer)
- data (base64 encoded image data; prefixed by image type)
- An example data value:
- image/gif;base64,R0lGODlhAQABAIAAAObm5gAAACH5BAEAAAAALAAAAAABAAEAAAICRAEAOw==
- The data member of a favicon object can be used with the data: protocol to embed an image in CSS or HTML.
- A PHP/HTML example:
- echo '<img src="data:'.$favicon['data'].'">';
- */
- func (h *handler) handleFavicons(w http.ResponseWriter, r *http.Request) {
- userID := request.UserID(r)
- slog.Debug("[Fever] Fetching favicons",
- slog.Int64("user_id", userID),
- )
- icons, err := h.store.Icons(userID)
- if err != nil {
- json.ServerError(w, r, err)
- return
- }
- var result faviconsResponse
- for _, i := range icons {
- result.Favicons = append(result.Favicons, favicon{
- ID: i.ID,
- Data: i.DataURL(),
- })
- }
- result.SetCommonValues()
- json.OK(w, r, result)
- }
- /*
- A request with the items argument will return two additional members:
- items contains an array of item objects
- total_items contains the total number of items stored in the database (added in API version 2)
- An item object has the following members:
- id (positive integer)
- feed_id (positive integer)
- title (utf-8 string)
- author (utf-8 string)
- html (utf-8 string)
- url (utf-8 string)
- is_saved (boolean integer)
- is_read (boolean integer)
- created_on_time (Unix timestamp/integer)
- Most servers won’t have enough memory allocated to PHP to dump all items at once.
- Three optional arguments control determine the items included in the response.
- Use the since_id argument with the highest id of locally cached items to request 50 additional items.
- Repeat until the items array in the response is empty.
- Use the max_id argument with the lowest id of locally cached items (or 0 initially) to request 50 previous items.
- Repeat until the items array in the response is empty. (added in API version 2)
- 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) {
- var result itemsResponse
- userID := request.UserID(r)
- builder := h.store.NewEntryQueryBuilder(userID)
- builder.WithoutStatus(model.EntryStatusRemoved)
- builder.WithLimit(50)
- switch {
- case request.HasQueryParam(r, "since_id"):
- sinceID := request.QueryInt64Param(r, "since_id", 0)
- if sinceID > 0 {
- slog.Debug("[Fever] Fetching items since a given date",
- slog.Int64("user_id", userID),
- slog.Int64("since_id", sinceID),
- )
- builder.AfterEntryID(sinceID)
- builder.WithSorting("id", "ASC")
- }
- case request.HasQueryParam(r, "max_id"):
- maxID := request.QueryInt64Param(r, "max_id", 0)
- if maxID == 0 {
- slog.Debug("[Fever] Fetching most recent items",
- slog.Int64("user_id", userID),
- )
- builder.WithSorting("id", "DESC")
- } else if maxID > 0 {
- slog.Debug("[Fever] Fetching items before a given item ID",
- slog.Int64("user_id", userID),
- slog.Int64("max_id", maxID),
- )
- builder.BeforeEntryID(maxID)
- builder.WithSorting("id", "DESC")
- }
- case request.HasQueryParam(r, "with_ids"):
- csvItemIDs := request.QueryStringParam(r, "with_ids", "")
- if csvItemIDs != "" {
- var itemIDs []int64
- for _, strItemID := range strings.Split(csvItemIDs, ",") {
- strItemID = strings.TrimSpace(strItemID)
- itemID, _ := strconv.ParseInt(strItemID, 10, 64)
- itemIDs = append(itemIDs, itemID)
- }
- builder.WithEntryIDs(itemIDs)
- }
- default:
- slog.Debug("[Fever] Fetching oldest items",
- slog.Int64("user_id", userID),
- )
- }
- entries, err := builder.GetEntries()
- if err != nil {
- json.ServerError(w, r, err)
- return
- }
- builder = h.store.NewEntryQueryBuilder(userID)
- builder.WithoutStatus(model.EntryStatusRemoved)
- result.Total, err = builder.CountEntries()
- if err != nil {
- json.ServerError(w, r, err)
- return
- }
- result.Items = make([]item, 0, len(entries))
- for _, entry := range entries {
- isRead := 0
- if entry.Status == model.EntryStatusRead {
- isRead = 1
- }
- isSaved := 0
- if entry.Starred {
- isSaved = 1
- }
- result.Items = append(result.Items, item{
- ID: entry.ID,
- FeedID: entry.FeedID,
- Title: entry.Title,
- Author: entry.Author,
- HTML: mediaproxy.RewriteDocumentWithAbsoluteProxyURL(h.router, entry.Content),
- URL: entry.URL,
- IsSaved: isSaved,
- IsRead: isRead,
- CreatedAt: entry.Date.Unix(),
- })
- }
- result.SetCommonValues()
- json.OK(w, r, result)
- }
- /*
- The unread_item_ids and saved_item_ids arguments can be used to keep your local cache synced
- with the remote Fever installation.
- 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) {
- userID := request.UserID(r)
- slog.Debug("[Fever] Fetching unread items",
- slog.Int64("user_id", userID),
- )
- builder := h.store.NewEntryQueryBuilder(userID)
- builder.WithStatus(model.EntryStatusUnread)
- rawEntryIDs, err := builder.GetEntryIDs()
- if err != nil {
- json.ServerError(w, r, err)
- return
- }
- itemIDs := make([]string, 0, len(rawEntryIDs))
- for _, entryID := range rawEntryIDs {
- itemIDs = append(itemIDs, strconv.FormatInt(entryID, 10))
- }
- var result unreadResponse
- result.ItemIDs = strings.Join(itemIDs, ",")
- result.SetCommonValues()
- json.OK(w, r, result)
- }
- /*
- The unread_item_ids and saved_item_ids arguments can be used to keep your local cache synced
- with the remote Fever installation.
- A request with the saved_item_ids argument will return one additional member:
- saved_item_ids (string/comma-separated list of positive integers)
- */
- func (h *handler) handleSavedItems(w http.ResponseWriter, r *http.Request) {
- userID := request.UserID(r)
- slog.Debug("[Fever] Fetching saved items",
- slog.Int64("user_id", userID),
- )
- builder := h.store.NewEntryQueryBuilder(userID)
- builder.WithStarred(true)
- entryIDs, err := builder.GetEntryIDs()
- if err != nil {
- json.ServerError(w, r, err)
- return
- }
- itemsIDs := make([]string, 0, len(entryIDs))
- for _, entryID := range entryIDs {
- itemsIDs = append(itemsIDs, strconv.FormatInt(entryID, 10))
- }
- result := &savedResponse{ItemIDs: strings.Join(itemsIDs, ",")}
- result.SetCommonValues()
- json.OK(w, r, result)
- }
- /*
- 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) {
- userID := request.UserID(r)
- slog.Debug("[Fever] Receiving mark=item call",
- slog.Int64("user_id", userID),
- )
- entryID := request.FormInt64Value(r, "id")
- if entryID <= 0 {
- return
- }
- builder := h.store.NewEntryQueryBuilder(userID)
- builder.WithEntryID(entryID)
- builder.WithoutStatus(model.EntryStatusRemoved)
- entry, err := builder.GetEntry()
- if err != nil {
- json.ServerError(w, r, err)
- return
- }
- if entry == nil {
- slog.Debug("[Fever] Entry not found",
- slog.Int64("user_id", userID),
- slog.Int64("entry_id", entryID),
- )
- json.OK(w, r, newBaseResponse())
- return
- }
- switch r.FormValue("as") {
- case "read":
- slog.Debug("[Fever] Mark entry as read",
- slog.Int64("user_id", userID),
- slog.Int64("entry_id", entryID),
- )
- h.store.SetEntriesStatus(userID, []int64{entryID}, model.EntryStatusRead)
- case "unread":
- slog.Debug("[Fever] Mark entry as unread",
- slog.Int64("user_id", userID),
- slog.Int64("entry_id", entryID),
- )
- h.store.SetEntriesStatus(userID, []int64{entryID}, model.EntryStatusUnread)
- case "saved":
- slog.Debug("[Fever] Mark entry as saved",
- slog.Int64("user_id", userID),
- slog.Int64("entry_id", entryID),
- )
- if err := h.store.ToggleStarred(userID, entryID); err != nil {
- json.ServerError(w, r, err)
- return
- }
- settings, err := h.store.Integration(userID)
- if err != nil {
- json.ServerError(w, r, err)
- return
- }
- go func() {
- integration.SendEntry(entry, settings)
- }()
- case "unsaved":
- slog.Debug("[Fever] Mark entry as unsaved",
- slog.Int64("user_id", userID),
- slog.Int64("entry_id", entryID),
- )
- if err := h.store.ToggleStarred(userID, entryID); err != nil {
- json.ServerError(w, r, err)
- return
- }
- }
- json.OK(w, r, newBaseResponse())
- }
- /*
- mark=feed
- 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) {
- userID := request.UserID(r)
- feedID := request.FormInt64Value(r, "id")
- before := time.Unix(request.FormInt64Value(r, "before"), 0)
- slog.Debug("[Fever] Mark feed as read before a given date",
- slog.Int64("user_id", userID),
- slog.Int64("feed_id", feedID),
- slog.Time("before_ts", before),
- )
- if feedID <= 0 {
- 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),
- )
- }
- }()
- json.OK(w, r, newBaseResponse())
- }
- /*
- mark=group
- 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) {
- 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
- if groupID == 0 {
- err = h.store.MarkAllAsRead(userID)
- } else {
- err = h.store.MarkCategoryAsRead(userID, groupID, 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),
- )
- }
- }()
- json.OK(w, r, newBaseResponse())
- }
- /*
- 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 {
- 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))
- }
- result := make([]feedsGroups, 0, len(feedsGroupedByCategory))
- for categoryID, feedIDs := range feedsGroupedByCategory {
- result = append(result, feedsGroups{
- GroupID: categoryID,
- FeedIDs: strings.Join(feedIDs, ","),
- })
- }
- return result
- }
|