mirror of
https://github.com/miniflux/v2.git
synced 2025-08-26 18:21:01 +00:00
Add Fever API
This commit is contained in:
parent
ae62e543d3
commit
bc20e0884b
24 changed files with 984 additions and 37 deletions
|
@ -103,3 +103,8 @@ func (j *JSONResponse) toJSON(v interface{}) []byte {
|
|||
|
||||
return b
|
||||
}
|
||||
|
||||
// NewJSONResponse returns a new JSONResponse.
|
||||
func NewJSONResponse(w http.ResponseWriter, r *http.Request) *JSONResponse {
|
||||
return &JSONResponse{request: r, writer: w}
|
||||
}
|
||||
|
|
|
@ -51,6 +51,18 @@ func (r *Request) Cookie(name string) string {
|
|||
return cookie.Value
|
||||
}
|
||||
|
||||
// FormValue returns a form value as integer.
|
||||
func (r *Request) FormValue(param string) string {
|
||||
return r.request.FormValue(param)
|
||||
}
|
||||
|
||||
// FormIntegerValue returns a form value as integer.
|
||||
func (r *Request) FormIntegerValue(param string) int64 {
|
||||
value := r.request.FormValue(param)
|
||||
integer, _ := strconv.Atoi(value)
|
||||
return int64(integer)
|
||||
}
|
||||
|
||||
// IntegerParam returns an URL parameter as integer.
|
||||
func (r *Request) IntegerParam(param string) (int64, error) {
|
||||
vars := mux.Vars(r.request)
|
||||
|
@ -105,6 +117,13 @@ func (r *Request) QueryIntegerParam(param string, defaultValue int) int {
|
|||
return val
|
||||
}
|
||||
|
||||
// HasQueryParam checks if the query string contains the given parameter.
|
||||
func (r *Request) HasQueryParam(param string) bool {
|
||||
values := r.request.URL.Query()
|
||||
_, ok := values[param]
|
||||
return ok
|
||||
}
|
||||
|
||||
// NewRequest returns a new Request struct.
|
||||
func NewRequest(w http.ResponseWriter, r *http.Request) *Request {
|
||||
return &Request{writer: w, request: r}
|
||||
|
|
|
@ -26,7 +26,7 @@ func (r *Response) SetCookie(cookie *http.Cookie) {
|
|||
// JSON returns a JSONResponse.
|
||||
func (r *Response) JSON() *JSONResponse {
|
||||
r.commonHeaders()
|
||||
return &JSONResponse{writer: r.writer, request: r.request}
|
||||
return NewJSONResponse(r.writer, r.request)
|
||||
}
|
||||
|
||||
// HTML returns a HTMLResponse.
|
||||
|
|
636
server/fever/fever.go
Normal file
636
server/fever/fever.go
Normal file
|
@ -0,0 +1,636 @@
|
|||
// Copyright 2017 Frédéric Guillot. All rights reserved.
|
||||
// Use of this source code is governed by the Apache 2.0
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
package fever
|
||||
|
||||
import (
|
||||
"log"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/miniflux/miniflux2/integration"
|
||||
"github.com/miniflux/miniflux2/model"
|
||||
"github.com/miniflux/miniflux2/server/core"
|
||||
"github.com/miniflux/miniflux2/storage"
|
||||
)
|
||||
|
||||
type baseResponse struct {
|
||||
Version int `json:"api_version"`
|
||||
Authenticated int `json:"auth"`
|
||||
LastRefresh int64 `json:"last_refreshed_on_time"`
|
||||
}
|
||||
|
||||
func (b *baseResponse) SetCommonValues() {
|
||||
b.Version = 3
|
||||
b.Authenticated = 1
|
||||
b.LastRefresh = time.Now().Unix()
|
||||
}
|
||||
|
||||
/*
|
||||
The default response is a JSON object containing two members:
|
||||
|
||||
api_version contains the version of the API responding (positive integer)
|
||||
auth whether the request was successfully authenticated (boolean integer)
|
||||
|
||||
The API can also return XML by passing xml as the optional value of the api argument like so:
|
||||
|
||||
http://yourdomain.com/fever/?api=xml
|
||||
|
||||
The top level XML element is named response.
|
||||
|
||||
The response to each successfully authenticated request will have auth set to 1 and include
|
||||
at least one additional member:
|
||||
|
||||
last_refreshed_on_time contains the time of the most recently refreshed (not updated)
|
||||
feed (Unix timestamp/integer)
|
||||
|
||||
*/
|
||||
func newBaseResponse() baseResponse {
|
||||
r := baseResponse{}
|
||||
r.SetCommonValues()
|
||||
return r
|
||||
}
|
||||
|
||||
type groupsResponse struct {
|
||||
baseResponse
|
||||
Groups []group `json:"groups"`
|
||||
FeedsGroups []feedsGroups `json:"feeds_groups"`
|
||||
}
|
||||
|
||||
type feedsResponse struct {
|
||||
baseResponse
|
||||
Feeds []feed `json:"feeds"`
|
||||
FeedsGroups []feedsGroups `json:"feeds_groups"`
|
||||
}
|
||||
|
||||
type faviconsResponse struct {
|
||||
baseResponse
|
||||
Favicons []favicon `json:"favicons"`
|
||||
}
|
||||
|
||||
type itemsResponse struct {
|
||||
baseResponse
|
||||
Items []item `json:"items"`
|
||||
Total int `json:"total_items"`
|
||||
}
|
||||
|
||||
type unreadResponse struct {
|
||||
baseResponse
|
||||
ItemIDs string `json:"unread_item_ids"`
|
||||
}
|
||||
|
||||
type savedResponse struct {
|
||||
baseResponse
|
||||
ItemIDs string `json:"saved_item_ids"`
|
||||
}
|
||||
|
||||
type linksResponse struct {
|
||||
baseResponse
|
||||
Links []string `json:"links"`
|
||||
}
|
||||
|
||||
type group struct {
|
||||
ID int64 `json:"id"`
|
||||
Title string `json:"title"`
|
||||
}
|
||||
|
||||
type feedsGroups struct {
|
||||
GroupID int64 `json:"group_id"`
|
||||
FeedIDs string `json:"feed_ids"`
|
||||
}
|
||||
|
||||
type feed struct {
|
||||
ID int64 `json:"id"`
|
||||
FaviconID int64 `json:"favicon_id"`
|
||||
Title string `json:"title"`
|
||||
URL string `json:"url"`
|
||||
SiteURL string `json:"site_url"`
|
||||
IsSpark int `json:"is_spark"`
|
||||
LastUpdated int64 `json:"last_updated_on_time"`
|
||||
}
|
||||
|
||||
type item struct {
|
||||
ID int64 `json:"id"`
|
||||
FeedID int64 `json:"feed_id"`
|
||||
Title string `json:"title"`
|
||||
Author string `json:"author"`
|
||||
HTML string `json:"html"`
|
||||
URL string `json:"url"`
|
||||
IsSaved int `json:"is_saved"`
|
||||
IsRead int `json:"is_read"`
|
||||
CreatedAt int64 `json:"created_on_time"`
|
||||
}
|
||||
|
||||
type favicon struct {
|
||||
ID int64 `json:"id"`
|
||||
Data string `json:"data"`
|
||||
}
|
||||
|
||||
// Controller implements the Fever API.
|
||||
type Controller struct {
|
||||
store *storage.Storage
|
||||
}
|
||||
|
||||
// Handler handles Fever API calls
|
||||
func (c *Controller) Handler(ctx *core.Context, request *core.Request, response *core.Response) {
|
||||
switch {
|
||||
case request.HasQueryParam("groups"):
|
||||
c.handleGroups(ctx, request, response)
|
||||
case request.HasQueryParam("feeds"):
|
||||
c.handleFeeds(ctx, request, response)
|
||||
case request.HasQueryParam("favicons"):
|
||||
c.handleFavicons(ctx, request, response)
|
||||
case request.HasQueryParam("unread_item_ids"):
|
||||
c.handleUnreadItems(ctx, request, response)
|
||||
case request.HasQueryParam("saved_item_ids"):
|
||||
c.handleSavedItems(ctx, request, response)
|
||||
case request.HasQueryParam("items"):
|
||||
c.handleItems(ctx, request, response)
|
||||
case request.HasQueryParam("links"):
|
||||
c.handleLinks(ctx, request, response)
|
||||
case request.FormValue("mark") == "item":
|
||||
c.handleWriteItems(ctx, request, response)
|
||||
case request.FormValue("mark") == "feed":
|
||||
c.handleWriteFeeds(ctx, request, response)
|
||||
case request.FormValue("mark") == "group":
|
||||
c.handleWriteGroups(ctx, request, response)
|
||||
default:
|
||||
response.JSON().Standard(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 (c *Controller) handleGroups(ctx *core.Context, request *core.Request, response *core.Response) {
|
||||
userID := ctx.UserID()
|
||||
log.Printf("[Fever] Fetching groups for userID=%d\n", userID)
|
||||
|
||||
categories, err := c.store.Categories(userID)
|
||||
if err != nil {
|
||||
response.JSON().ServerError(err)
|
||||
return
|
||||
}
|
||||
|
||||
feeds, err := c.store.Feeds(userID)
|
||||
if err != nil {
|
||||
response.JSON().ServerError(err)
|
||||
return
|
||||
}
|
||||
|
||||
var result groupsResponse
|
||||
for _, category := range categories {
|
||||
result.Groups = append(result.Groups, group{ID: category.ID, Title: category.Title})
|
||||
}
|
||||
|
||||
result.FeedsGroups = c.buildFeedGroups(feeds)
|
||||
result.SetCommonValues()
|
||||
response.JSON().Standard(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 (c *Controller) handleFeeds(ctx *core.Context, request *core.Request, response *core.Response) {
|
||||
userID := ctx.UserID()
|
||||
log.Printf("[Fever] Fetching feeds for userID=%d\n", userID)
|
||||
|
||||
feeds, err := c.store.Feeds(userID)
|
||||
if err != nil {
|
||||
response.JSON().ServerError(err)
|
||||
return
|
||||
}
|
||||
|
||||
var result feedsResponse
|
||||
for _, f := range feeds {
|
||||
result.Feeds = append(result.Feeds, feed{
|
||||
ID: f.ID,
|
||||
FaviconID: f.Icon.IconID,
|
||||
Title: f.Title,
|
||||
URL: f.FeedURL,
|
||||
SiteURL: f.SiteURL,
|
||||
IsSpark: 0,
|
||||
LastUpdated: f.CheckedAt.Unix(),
|
||||
})
|
||||
}
|
||||
|
||||
result.FeedsGroups = c.buildFeedGroups(feeds)
|
||||
result.SetCommonValues()
|
||||
response.JSON().Standard(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 (c *Controller) handleFavicons(ctx *core.Context, request *core.Request, response *core.Response) {
|
||||
userID := ctx.UserID()
|
||||
log.Printf("[Fever] Fetching favicons for userID=%d\n", userID)
|
||||
|
||||
icons, err := c.store.Icons(userID)
|
||||
if err != nil {
|
||||
response.JSON().ServerError(err)
|
||||
return
|
||||
}
|
||||
|
||||
var result faviconsResponse
|
||||
for _, i := range icons {
|
||||
result.Favicons = append(result.Favicons, favicon{
|
||||
ID: i.ID,
|
||||
Data: i.DataURL(),
|
||||
})
|
||||
}
|
||||
|
||||
result.SetCommonValues()
|
||||
response.JSON().Standard(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 (c *Controller) handleItems(ctx *core.Context, request *core.Request, response *core.Response) {
|
||||
var result itemsResponse
|
||||
|
||||
userID := ctx.UserID()
|
||||
timezone := ctx.UserTimezone()
|
||||
log.Printf("[Fever] Fetching items for userID=%d\n", userID)
|
||||
|
||||
builder := c.store.GetEntryQueryBuilder(userID, timezone)
|
||||
builder.WithoutStatus(model.EntryStatusRemoved)
|
||||
builder.WithLimit(50)
|
||||
builder.WithOrder("id")
|
||||
builder.WithDirection(model.DefaultSortingDirection)
|
||||
|
||||
sinceID := request.QueryIntegerParam("since_id", 0)
|
||||
if sinceID > 0 {
|
||||
builder.WithGreaterThanEntryID(int64(sinceID))
|
||||
}
|
||||
|
||||
maxID := request.QueryIntegerParam("max_id", 0)
|
||||
if maxID > 0 {
|
||||
builder.WithOffset(maxID)
|
||||
}
|
||||
|
||||
csvItemIDs := request.QueryStringParam("with_ids", "")
|
||||
if csvItemIDs != "" {
|
||||
var itemIDs []int64
|
||||
|
||||
for _, strItemID := range strings.Split(csvItemIDs, ",") {
|
||||
strItemID = strings.TrimSpace(strItemID)
|
||||
itemID, _ := strconv.Atoi(strItemID)
|
||||
itemIDs = append(itemIDs, int64(itemID))
|
||||
}
|
||||
|
||||
builder.WithEntryIDs(itemIDs)
|
||||
}
|
||||
|
||||
entries, err := builder.GetEntries()
|
||||
if err != nil {
|
||||
response.JSON().ServerError(err)
|
||||
return
|
||||
}
|
||||
|
||||
builder = c.store.GetEntryQueryBuilder(userID, timezone)
|
||||
builder.WithoutStatus(model.EntryStatusRemoved)
|
||||
result.Total, err = builder.CountEntries()
|
||||
if err != nil {
|
||||
response.JSON().ServerError(err)
|
||||
return
|
||||
}
|
||||
|
||||
for _, entry := range entries {
|
||||
isRead := 0
|
||||
if entry.Status == model.EntryStatusRead {
|
||||
isRead = 1
|
||||
}
|
||||
|
||||
result.Items = append(result.Items, item{
|
||||
ID: entry.ID,
|
||||
FeedID: entry.FeedID,
|
||||
Title: entry.Title,
|
||||
Author: entry.Author,
|
||||
HTML: entry.Content,
|
||||
URL: entry.URL,
|
||||
IsSaved: 0,
|
||||
IsRead: isRead,
|
||||
CreatedAt: entry.Date.Unix(),
|
||||
})
|
||||
}
|
||||
|
||||
result.SetCommonValues()
|
||||
response.JSON().Standard(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 (c *Controller) handleUnreadItems(ctx *core.Context, request *core.Request, response *core.Response) {
|
||||
userID := ctx.UserID()
|
||||
log.Printf("[Fever] Fetching unread items for userID=%d\n", userID)
|
||||
|
||||
builder := c.store.GetEntryQueryBuilder(userID, ctx.UserTimezone())
|
||||
builder.WithStatus(model.EntryStatusUnread)
|
||||
entries, err := builder.GetEntries()
|
||||
if err != nil {
|
||||
response.JSON().ServerError(err)
|
||||
return
|
||||
}
|
||||
|
||||
var itemIDs []string
|
||||
for _, entry := range entries {
|
||||
itemIDs = append(itemIDs, strconv.FormatInt(entry.ID, 10))
|
||||
}
|
||||
|
||||
var result unreadResponse
|
||||
result.ItemIDs = strings.Join(itemIDs, ",")
|
||||
result.SetCommonValues()
|
||||
response.JSON().Standard(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 (c *Controller) handleSavedItems(ctx *core.Context, request *core.Request, response *core.Response) {
|
||||
userID := ctx.UserID()
|
||||
log.Printf("[Fever] Fetching saved items for userID=%d\n", userID)
|
||||
|
||||
var result savedResponse
|
||||
result.SetCommonValues()
|
||||
response.JSON().Standard(result)
|
||||
}
|
||||
|
||||
/*
|
||||
A request with the links argument will return one additional member:
|
||||
|
||||
links contains an array of link objects
|
||||
|
||||
A link object has the following members:
|
||||
|
||||
id (positive integer)
|
||||
feed_id (positive integer) only use when is_item equals 1
|
||||
item_id (positive integer) only use when is_item equals 1
|
||||
temperature (positive float)
|
||||
is_item (boolean integer)
|
||||
is_local (boolean integer) used to determine if the source feed and favicon should be displayed
|
||||
is_saved (boolean integer) only use when is_item equals 1
|
||||
title (utf-8 string)
|
||||
url (utf-8 string)
|
||||
item_ids (string/comma-separated list of positive integers)
|
||||
*/
|
||||
func (c *Controller) handleLinks(ctx *core.Context, request *core.Request, response *core.Response) {
|
||||
userID := ctx.UserID()
|
||||
log.Printf("[Fever] Fetching links for userID=%d\n", userID)
|
||||
|
||||
var result linksResponse
|
||||
result.SetCommonValues()
|
||||
response.JSON().Standard(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 (c *Controller) handleWriteItems(ctx *core.Context, request *core.Request, response *core.Response) {
|
||||
userID := ctx.UserID()
|
||||
log.Printf("[Fever] Receiving mark=item call for userID=%d\n", userID)
|
||||
|
||||
entryID := request.FormIntegerValue("id")
|
||||
if entryID <= 0 {
|
||||
return
|
||||
}
|
||||
|
||||
builder := c.store.GetEntryQueryBuilder(userID, ctx.UserTimezone())
|
||||
builder.WithEntryID(entryID)
|
||||
builder.WithoutStatus(model.EntryStatusRemoved)
|
||||
|
||||
entry, err := builder.GetEntry()
|
||||
if err != nil {
|
||||
response.JSON().ServerError(err)
|
||||
return
|
||||
}
|
||||
|
||||
if entry == nil {
|
||||
return
|
||||
}
|
||||
|
||||
switch request.FormValue("as") {
|
||||
case "read":
|
||||
c.store.SetEntriesStatus(userID, []int64{entryID}, model.EntryStatusRead)
|
||||
case "unread":
|
||||
c.store.SetEntriesStatus(userID, []int64{entryID}, model.EntryStatusUnread)
|
||||
case "saved":
|
||||
settings, err := c.store.Integration(userID)
|
||||
if err != nil {
|
||||
response.JSON().ServerError(err)
|
||||
return
|
||||
}
|
||||
|
||||
go func() {
|
||||
integration.SendEntry(entry, settings)
|
||||
}()
|
||||
}
|
||||
|
||||
response.JSON().Standard(newBaseResponse())
|
||||
}
|
||||
|
||||
/*
|
||||
mark=? where ? is replaced with feed or 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 (c *Controller) handleWriteFeeds(ctx *core.Context, request *core.Request, response *core.Response) {
|
||||
userID := ctx.UserID()
|
||||
log.Printf("[Fever] Receiving mark=feed call for userID=%d\n", userID)
|
||||
|
||||
feedID := request.FormIntegerValue("id")
|
||||
if feedID <= 0 {
|
||||
return
|
||||
}
|
||||
|
||||
builder := c.store.GetEntryQueryBuilder(userID, ctx.UserTimezone())
|
||||
builder.WithStatus(model.EntryStatusUnread)
|
||||
builder.WithFeedID(feedID)
|
||||
|
||||
before := request.FormIntegerValue("before")
|
||||
if before > 0 {
|
||||
t := time.Unix(before, 0)
|
||||
builder.Before(&t)
|
||||
}
|
||||
|
||||
entryIDs, err := builder.GetEntryIDs()
|
||||
if err != nil {
|
||||
response.JSON().ServerError(err)
|
||||
return
|
||||
}
|
||||
|
||||
err = c.store.SetEntriesStatus(userID, entryIDs, model.EntryStatusRead)
|
||||
if err != nil {
|
||||
response.JSON().ServerError(err)
|
||||
return
|
||||
}
|
||||
|
||||
response.JSON().Standard(newBaseResponse())
|
||||
}
|
||||
|
||||
/*
|
||||
mark=? where ? is replaced with feed or 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 (c *Controller) handleWriteGroups(ctx *core.Context, request *core.Request, response *core.Response) {
|
||||
userID := ctx.UserID()
|
||||
log.Printf("[Fever] Receiving mark=group call for userID=%d\n", userID)
|
||||
|
||||
groupID := request.FormIntegerValue("id")
|
||||
if groupID < 0 {
|
||||
return
|
||||
}
|
||||
|
||||
builder := c.store.GetEntryQueryBuilder(userID, ctx.UserTimezone())
|
||||
builder.WithStatus(model.EntryStatusUnread)
|
||||
builder.WithCategoryID(groupID)
|
||||
|
||||
before := request.FormIntegerValue("before")
|
||||
if before > 0 {
|
||||
t := time.Unix(before, 0)
|
||||
builder.Before(&t)
|
||||
}
|
||||
|
||||
entryIDs, err := builder.GetEntryIDs()
|
||||
if err != nil {
|
||||
response.JSON().ServerError(err)
|
||||
return
|
||||
}
|
||||
|
||||
err = c.store.SetEntriesStatus(userID, entryIDs, model.EntryStatusRead)
|
||||
if err != nil {
|
||||
response.JSON().ServerError(err)
|
||||
return
|
||||
}
|
||||
|
||||
response.JSON().Standard(newBaseResponse())
|
||||
}
|
||||
|
||||
/*
|
||||
A feeds_group object has the following members:
|
||||
|
||||
group_id (positive integer)
|
||||
feed_ids (string/comma-separated list of positive integers)
|
||||
|
||||
*/
|
||||
func (c *Controller) buildFeedGroups(feeds model.Feeds) []feedsGroups {
|
||||
feedsGroupedByCategory := make(map[int64][]string)
|
||||
for _, feed := range feeds {
|
||||
feedsGroupedByCategory[feed.Category.ID] = append(feedsGroupedByCategory[feed.Category.ID], strconv.FormatInt(feed.ID, 10))
|
||||
}
|
||||
|
||||
var result []feedsGroups
|
||||
for categoryID, feedIDs := range feedsGroupedByCategory {
|
||||
result = append(result, feedsGroups{
|
||||
GroupID: categoryID,
|
||||
FeedIDs: strings.Join(feedIDs, ","),
|
||||
})
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// NewController returns a new Fever API.
|
||||
func NewController(store *storage.Storage) *Controller {
|
||||
return &Controller{store: store}
|
||||
}
|
57
server/middleware/fever.go
Normal file
57
server/middleware/fever.go
Normal file
|
@ -0,0 +1,57 @@
|
|||
// Copyright 2017 Frédéric Guillot. All rights reserved.
|
||||
// Use of this source code is governed by the Apache 2.0
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
"net/http"
|
||||
|
||||
"github.com/miniflux/miniflux2/storage"
|
||||
)
|
||||
|
||||
// FeverMiddleware is the middleware that handles Fever API.
|
||||
type FeverMiddleware struct {
|
||||
store *storage.Storage
|
||||
}
|
||||
|
||||
// Handler executes the middleware.
|
||||
func (f *FeverMiddleware) Handler(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
log.Println("[Middleware:Fever]")
|
||||
|
||||
apiKey := r.FormValue("api_key")
|
||||
user, err := f.store.UserByFeverToken(apiKey)
|
||||
if err != nil {
|
||||
log.Println(err)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Write([]byte(`{"api_version": 3, "auth": 0}`))
|
||||
return
|
||||
}
|
||||
|
||||
if user == nil {
|
||||
log.Println("[Middleware:Fever] Fever authentication failure")
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Write([]byte(`{"api_version": 3, "auth": 0}`))
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("[Middleware:Fever] User #%d is authenticated\n", user.ID)
|
||||
f.store.SetLastLogin(user.ID)
|
||||
|
||||
ctx := r.Context()
|
||||
ctx = context.WithValue(ctx, UserIDContextKey, user.ID)
|
||||
ctx = context.WithValue(ctx, UserTimezoneContextKey, user.Timezone)
|
||||
ctx = context.WithValue(ctx, IsAdminUserContextKey, user.IsAdmin)
|
||||
ctx = context.WithValue(ctx, IsAuthenticatedContextKey, true)
|
||||
|
||||
next.ServeHTTP(w, r.WithContext(ctx))
|
||||
})
|
||||
}
|
||||
|
||||
// NewFeverMiddleware returns a new FeverMiddleware.
|
||||
func NewFeverMiddleware(s *storage.Storage) *FeverMiddleware {
|
||||
return &FeverMiddleware{store: s}
|
||||
}
|
|
@ -15,6 +15,7 @@ import (
|
|||
"github.com/miniflux/miniflux2/reader/opml"
|
||||
api_controller "github.com/miniflux/miniflux2/server/api/controller"
|
||||
"github.com/miniflux/miniflux2/server/core"
|
||||
"github.com/miniflux/miniflux2/server/fever"
|
||||
"github.com/miniflux/miniflux2/server/middleware"
|
||||
"github.com/miniflux/miniflux2/server/template"
|
||||
ui_controller "github.com/miniflux/miniflux2/server/ui/controller"
|
||||
|
@ -29,17 +30,24 @@ func getRoutes(cfg *config.Config, store *storage.Storage, feedHandler *feed.Han
|
|||
templateEngine := template.NewEngine(cfg, router, translator)
|
||||
|
||||
apiController := api_controller.NewController(store, feedHandler)
|
||||
feverController := fever.NewController(store)
|
||||
uiController := ui_controller.NewController(cfg, store, pool, feedHandler, opml.NewHandler(store))
|
||||
|
||||
apiHandler := core.NewHandler(store, router, templateEngine, translator, middleware.NewChain(
|
||||
middleware.NewBasicAuthMiddleware(store).Handler,
|
||||
))
|
||||
|
||||
feverHandler := core.NewHandler(store, router, templateEngine, translator, middleware.NewChain(
|
||||
middleware.NewFeverMiddleware(store).Handler,
|
||||
))
|
||||
|
||||
uiHandler := core.NewHandler(store, router, templateEngine, translator, middleware.NewChain(
|
||||
middleware.NewSessionMiddleware(store, router).Handler,
|
||||
middleware.NewTokenMiddleware(store).Handler,
|
||||
))
|
||||
|
||||
router.Handle("/fever/", feverHandler.Use(feverController.Handler))
|
||||
|
||||
router.Handle("/v1/users", apiHandler.Use(apiController.CreateUser)).Methods("POST")
|
||||
router.Handle("/v1/users", apiHandler.Use(apiController.GetUsers)).Methods("GET")
|
||||
router.Handle("/v1/users/{userID}", apiHandler.Use(apiController.GetUser)).Methods("GET")
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
// Code generated by go generate; DO NOT EDIT.
|
||||
// 2017-12-02 21:11:24.016429412 -0800 PST m=+0.007603260
|
||||
// 2017-12-03 17:25:29.40151375 -0800 PST m=+0.014540675
|
||||
|
||||
package static
|
||||
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
// Code generated by go generate; DO NOT EDIT.
|
||||
// 2017-12-02 21:11:24.017204599 -0800 PST m=+0.008378447
|
||||
// 2017-12-03 17:25:29.40458076 -0800 PST m=+0.017607685
|
||||
|
||||
package static
|
||||
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
// Code generated by go generate; DO NOT EDIT.
|
||||
// 2017-12-02 21:11:24.018743922 -0800 PST m=+0.009917770
|
||||
// 2017-12-03 17:25:29.409871548 -0800 PST m=+0.022898473
|
||||
|
||||
package static
|
||||
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
// Code generated by go generate; DO NOT EDIT.
|
||||
// 2017-12-02 21:11:24.027142168 -0800 PST m=+0.018316016
|
||||
// 2017-12-03 17:25:29.427766854 -0800 PST m=+0.040793779
|
||||
|
||||
package template
|
||||
|
||||
|
|
|
@ -28,10 +28,23 @@
|
|||
<div class="alert alert-error">{{ t .errorMessage }}</div>
|
||||
{{ end }}
|
||||
|
||||
<h3>Fever</h3>
|
||||
<div class="form-section">
|
||||
<label>
|
||||
<input type="checkbox" name="fever_enabled" value="1" {{ if .form.FeverEnabled }}checked{{ end }}> {{ t "Activate Fever API" }}
|
||||
</label>
|
||||
|
||||
<label for="form-fever-username">{{ t "Fever Username" }}</label>
|
||||
<input type="text" name="fever_username" id="form-fever-username" value="{{ .form.FeverUsername }}">
|
||||
|
||||
<label for="form-fever-password">{{ t "Fever Password" }}</label>
|
||||
<input type="password" name="fever_password" id="form-fever-password" value="{{ .form.FeverPassword }}">
|
||||
</div>
|
||||
|
||||
<h3>Pinboard</h3>
|
||||
<div class="form-section">
|
||||
<label>
|
||||
<input type="checkbox" name="pinboard_enabled" value="1" {{ if .form.PinboardEnabled }}checked{{ end }}> {{ t "Enable Pinboard" }}
|
||||
<input type="checkbox" name="pinboard_enabled" value="1" {{ if .form.PinboardEnabled }}checked{{ end }}> {{ t "Save articles to Pinboard" }}
|
||||
</label>
|
||||
|
||||
<label for="form-pinboard-token">{{ t "Pinboard API Token" }}</label>
|
||||
|
@ -48,7 +61,7 @@
|
|||
<h3>Instapaper</h3>
|
||||
<div class="form-section">
|
||||
<label>
|
||||
<input type="checkbox" name="instapaper_enabled" value="1" {{ if .form.InstapaperEnabled }}checked{{ end }}> {{ t "Enable Instapaper" }}
|
||||
<input type="checkbox" name="instapaper_enabled" value="1" {{ if .form.InstapaperEnabled }}checked{{ end }}> {{ t "Save articles to Instapaper" }}
|
||||
</label>
|
||||
|
||||
<label for="form-instapaper-username">{{ t "Instapaper Username" }}</label>
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
// Code generated by go generate; DO NOT EDIT.
|
||||
// 2017-12-02 21:11:24.019569008 -0800 PST m=+0.010742856
|
||||
// 2017-12-03 17:25:29.413238818 -0800 PST m=+0.026265743
|
||||
|
||||
package template
|
||||
|
||||
|
@ -811,10 +811,23 @@ var templateViewsMap = map[string]string{
|
|||
<div class="alert alert-error">{{ t .errorMessage }}</div>
|
||||
{{ end }}
|
||||
|
||||
<h3>Fever</h3>
|
||||
<div class="form-section">
|
||||
<label>
|
||||
<input type="checkbox" name="fever_enabled" value="1" {{ if .form.FeverEnabled }}checked{{ end }}> {{ t "Activate Fever API" }}
|
||||
</label>
|
||||
|
||||
<label for="form-fever-username">{{ t "Fever Username" }}</label>
|
||||
<input type="text" name="fever_username" id="form-fever-username" value="{{ .form.FeverUsername }}">
|
||||
|
||||
<label for="form-fever-password">{{ t "Fever Password" }}</label>
|
||||
<input type="password" name="fever_password" id="form-fever-password" value="{{ .form.FeverPassword }}">
|
||||
</div>
|
||||
|
||||
<h3>Pinboard</h3>
|
||||
<div class="form-section">
|
||||
<label>
|
||||
<input type="checkbox" name="pinboard_enabled" value="1" {{ if .form.PinboardEnabled }}checked{{ end }}> {{ t "Enable Pinboard" }}
|
||||
<input type="checkbox" name="pinboard_enabled" value="1" {{ if .form.PinboardEnabled }}checked{{ end }}> {{ t "Save articles to Pinboard" }}
|
||||
</label>
|
||||
|
||||
<label for="form-pinboard-token">{{ t "Pinboard API Token" }}</label>
|
||||
|
@ -831,7 +844,7 @@ var templateViewsMap = map[string]string{
|
|||
<h3>Instapaper</h3>
|
||||
<div class="form-section">
|
||||
<label>
|
||||
<input type="checkbox" name="instapaper_enabled" value="1" {{ if .form.InstapaperEnabled }}checked{{ end }}> {{ t "Enable Instapaper" }}
|
||||
<input type="checkbox" name="instapaper_enabled" value="1" {{ if .form.InstapaperEnabled }}checked{{ end }}> {{ t "Save articles to Instapaper" }}
|
||||
</label>
|
||||
|
||||
<label for="form-instapaper-username">{{ t "Instapaper Username" }}</label>
|
||||
|
@ -1160,7 +1173,7 @@ var templateViewsMapChecksums = map[string]string{
|
|||
"feeds": "c22af39b42ba9ca69ea0914ca789303ec2c5b484abcd4eaa49016e365381257c",
|
||||
"history": "9a67599a5d8d67ef958e3f07da339b749f42892667547c9e60a54477e8d32a56",
|
||||
"import": "73b5112e20bfd232bf73334544186ea419505936bc237d481517a8622901878f",
|
||||
"integrations": "4e51fabe73b4ee2c2268f77dbbf7987c2a176c5a5714ea29ac31986928f22b8a",
|
||||
"integrations": "30249eefa4e2da62051447537ee5c4ed3dad377656fec3080e0e96c3c697c672",
|
||||
"login": "04f3ce79bfa5753f69e0d956c2a8999c0da549c7925634a3e8134975da0b0e0f",
|
||||
"sessions": "878dbe8f8ea783b44130c495814179519fa5c3aa2666ac87508f94d58dd008bf",
|
||||
"settings": "ea2505b9d0a6d6bb594dba87a92079de19baa6d494f0651693a7685489fb7de9",
|
||||
|
|
|
@ -5,7 +5,9 @@
|
|||
package controller
|
||||
|
||||
import (
|
||||
"crypto/md5"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/miniflux/miniflux2/integration"
|
||||
"github.com/miniflux/miniflux2/model"
|
||||
|
@ -38,6 +40,9 @@ func (c *Controller) ShowIntegrations(ctx *core.Context, request *core.Request,
|
|||
InstapaperEnabled: integration.InstapaperEnabled,
|
||||
InstapaperUsername: integration.InstapaperUsername,
|
||||
InstapaperPassword: integration.InstapaperPassword,
|
||||
FeverEnabled: integration.FeverEnabled,
|
||||
FeverUsername: integration.FeverUsername,
|
||||
FeverPassword: integration.FeverPassword,
|
||||
},
|
||||
}))
|
||||
}
|
||||
|
@ -54,6 +59,12 @@ func (c *Controller) UpdateIntegration(ctx *core.Context, request *core.Request,
|
|||
integrationForm := form.NewIntegrationForm(request.Request())
|
||||
integrationForm.Merge(integration)
|
||||
|
||||
if integration.FeverEnabled {
|
||||
integration.FeverToken = fmt.Sprintf("%x", md5.Sum([]byte(integration.FeverUsername+":"+integration.FeverPassword)))
|
||||
} else {
|
||||
integration.FeverToken = ""
|
||||
}
|
||||
|
||||
err = c.store.UpdateIntegration(integration)
|
||||
if err != nil {
|
||||
response.HTML().ServerError(err)
|
||||
|
|
|
@ -19,6 +19,9 @@ type IntegrationForm struct {
|
|||
InstapaperEnabled bool
|
||||
InstapaperUsername string
|
||||
InstapaperPassword string
|
||||
FeverEnabled bool
|
||||
FeverUsername string
|
||||
FeverPassword string
|
||||
}
|
||||
|
||||
// Merge copy form values to the model.
|
||||
|
@ -30,6 +33,9 @@ func (i IntegrationForm) Merge(integration *model.Integration) {
|
|||
integration.InstapaperEnabled = i.InstapaperEnabled
|
||||
integration.InstapaperUsername = i.InstapaperUsername
|
||||
integration.InstapaperPassword = i.InstapaperPassword
|
||||
integration.FeverEnabled = i.FeverEnabled
|
||||
integration.FeverUsername = i.FeverUsername
|
||||
integration.FeverPassword = i.FeverPassword
|
||||
}
|
||||
|
||||
// NewIntegrationForm returns a new AuthForm.
|
||||
|
@ -42,5 +48,8 @@ func NewIntegrationForm(r *http.Request) *IntegrationForm {
|
|||
InstapaperEnabled: r.FormValue("instapaper_enabled") == "1",
|
||||
InstapaperUsername: r.FormValue("instapaper_username"),
|
||||
InstapaperPassword: r.FormValue("instapaper_password"),
|
||||
FeverEnabled: r.FormValue("fever_enabled") == "1",
|
||||
FeverUsername: r.FormValue("fever_username"),
|
||||
FeverPassword: r.FormValue("fever_password"),
|
||||
}
|
||||
}
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue