Compare commits

...

28 Commits

Author SHA1 Message Date
Frédéric Guillot 77cafe9c2c Update ChangeLog 2018-06-04 18:48:27 -07:00
Frédéric Guillot 3b39f0883c Rewrite RealIP() to avoid returning an empty string 2018-06-01 07:22:31 -07:00
Frédéric Guillot cf7a7e25fb Convert IP field from text to inet type 2018-05-21 19:58:36 -04:00
Frédéric Guillot fb49ad24d5 Improve error handling in integration clients 2018-05-21 19:58:36 -04:00
Frédéric Guillot b270159aae Offer the possibility to set Pocket Consumer Key as environment variable 2018-05-21 19:58:36 -04:00
Pascal Widdershoven 44decae557 Fix unread count in menu not triggering click
By moving unread count into anchor.

Previously, clicking the unread count would not cause the anchor to be
invoked. This was confusing. This change includes the unread count span
in the anchor.
2018-05-21 12:51:43 +02:00
Frédéric Guillot 7f2fd1fdd8 Add Pocket authorization flow in the user interface 2018-05-20 15:29:14 -07:00
Allan Reyes 0f3f5e442f Add Pocket integration 2018-05-20 13:31:56 -07:00
Frédéric Guillot f19ab21b7d Archive read entries automatically after 60 days 2018-05-19 16:45:27 -07:00
Frédéric Guillot ff8e0c6b3d Update readme and rebuild CSS 2018-05-19 15:47:46 -07:00
Allan Reyes b0667e1b78 Hide horizontal overflow on hiding articles; fixes #130 2018-05-19 11:48:54 -07:00
Frédéric Guillot 9554d0bd53 Add integration test for /me endpoint 2018-05-14 18:52:12 -07:00
Leviathan Jeanis e81e4f0ac3 Add API endpoint to get logged user 2018-05-14 18:41:41 -07:00
Abhinand 4674d559a4 Fever API: Return response with an empty list if there is no items 2018-05-08 22:08:01 -07:00
Frédéric Guillot 0c2e5ff0dc Handle feeds with dates formatted as Unix timestamp 2018-05-08 20:41:24 -07:00
Frédéric Guillot 39c4452142 Update ChangeLog 2018-05-07 20:14:54 -07:00
Frédéric Guillot 2f4cd59ad9 Make sure to close request body in HTTP client 2018-04-29 23:11:10 -07:00
Frédéric Guillot 5cacae6cf2 Add API endpoint to import OPML file 2018-04-29 18:56:40 -07:00
Frédéric Guillot 7a1653a2e9 Make sure integrations are configured before to make any HTTP requests 2018-04-29 17:58:09 -07:00
Frédéric Guillot 31da4db14f Do not show save link if no integration is configured 2018-04-29 17:43:40 -07:00
Frédéric Guillot b166ceaea7 Avoid people to unlink their OAuth2 account without having a local password 2018-04-29 17:04:43 -07:00
Frédéric Guillot f49b42f70f Use vanilla HTTP handlers (refactoring) 2018-04-29 16:35:04 -07:00
Frédéric Guillot 1eba1730d1 Move HTTP client to its own package 2018-04-28 10:51:07 -07:00
Frédéric Guillot 04adf5fdf5 Add middleware to read X-Forwarded-Proto header 2018-04-27 22:25:00 -07:00
Frédéric Guillot ddd3af4b85 Do not use shared variable to translate templates 2018-04-27 22:07:46 -07:00
Frédéric Guillot 6b360d08c1 Use Gorilla middleware (refactoring) 2018-04-27 20:38:46 -07:00
aniran 322b265d7a Scrape parent element for iframe
Current behavior: if you have an `iframe` scraper rule, `scrapContent`
tries to return the inner HTML of the `iframe`, which turns up blank.

New behavior: like `img` elements, if an `iframe` is matched by a scraper rule,
the parent element's inner HTML (i.e. the `iframe` is returned).
2018-04-27 17:57:22 -07:00
aniran 920dda79b7 Add soundcloud and bandcamp iframe sources 2018-04-27 17:55:58 -07:00
198 changed files with 6616 additions and 4184 deletions
+30
View File
@@ -1,3 +1,33 @@
Version 2.0.8 (June 4, 2018)
----------------------------
* Add Pocket integration
* Rewrite RealIP() to avoid returning an empty string
* Convert IP field from text to inet type
* Improve error handling in integration clients
* Make unread counter clickable
* Archive read entries automatically after 60 days
* Hide horizontal overflow when swiping articles on touch devices
* Add API endpoint to get logged user
* Fever API: Return response with an empty list if there is no item
* Handle feeds with dates formatted as Unix timestamp
Version 2.0.7 (May 7, 2018)
---------------------------
* Add API endpoint to import OPML file
* Make sure to close request body in HTTP client
* Do not show save link if no integration is configured
* Make sure integrations are configured before to make any HTTP requests
* Avoid people to unlink their OAuth2 account without having a local password
* Do not use shared variable to translate templates (avoid concurrency issue)
* Use vanilla HTTP handlers (refactoring)
* Move HTTP client to its own package (refactoring)
* Add middleware to read X-Forwarded-Proto header (refactoring)
* Use Gorilla middleware (refactoring)
* Scrape parent element for iframe
* Add SoundCloud and Bandcamp iframe sources
Version 2.0.6 (Apr 20, 2018)
----------------------------
Generated
+4 -10
View File
@@ -28,8 +28,8 @@
[[projects]]
name = "github.com/gorilla/mux"
packages = ["."]
revision = "7f08801859139f86dfafd1c296e2cba9a80d292e"
version = "v1.6.0"
revision = "53c1911da2b537f792e7cafcb446b05ffe33b996"
version = "v1.6.1"
[[projects]]
branch = "master"
@@ -45,7 +45,7 @@
branch = "master"
name = "github.com/miniflux/miniflux-go"
packages = ["."]
revision = "887ba3b062946784f0e64edb1734f435beb204f9"
revision = "8863d558cbf1f20beeb640328829205971f1a632"
[[projects]]
name = "github.com/tdewolff/minify"
@@ -69,12 +69,6 @@
revision = "639f6272aec6b52094db77b9ec488214b0b4b1a1"
version = "v2.3.2"
[[projects]]
branch = "master"
name = "github.com/tomasen/realip"
packages = ["."]
revision = "b5850897b7b539a1c9f22cdaa3b547d1bd453db8"
[[projects]]
branch = "master"
name = "golang.org/x/crypto"
@@ -158,6 +152,6 @@
[solve-meta]
analyzer-name = "dep"
analyzer-version = 1
inputs-digest = "338222e5111416c46b2b8bde149443abc542b386dd02aff2a0dd6e13334bcf28"
inputs-digest = "f030af166f90ac96d133b7ddf9f1f80785508462cac8cae4c65abbdb7c20474b"
solver-name = "gps-cdcl"
solver-version = 1
+1 -5
View File
@@ -31,7 +31,7 @@
[[constraint]]
name = "github.com/gorilla/mux"
version = "1.6.0"
version = "1.6.1"
[[constraint]]
branch = "master"
@@ -45,10 +45,6 @@
name = "github.com/tdewolff/minify"
version = "2.3.3"
[[constraint]]
branch = "master"
name = "github.com/tomasen/realip"
[[constraint]]
branch = "master"
name = "golang.org/x/crypto"
+2 -1
View File
@@ -25,7 +25,8 @@ The Miniflux documentation is available here: <https://docs.miniflux.net/>
- [Opinionated?](https://docs.miniflux.net/en/latest/opinionated.html)
- [Features](https://docs.miniflux.net/en/latest/features.html)
- [Requirements](https://docs.miniflux.net/en/latest/requirements.html)
- [Installation](https://docs.miniflux.net/en/latest/installation.html)
- [Installation Instructions](https://docs.miniflux.net/en/latest/installation.html)
- [Installation Tutorials](https://docs.miniflux.net/en/latest/tutorials.html)
- [Upgrading to a new version](https://docs.miniflux.net/en/latest/upgrade.html)
- [Configuration](https://docs.miniflux.net/en/latest/configuration.html)
+33 -26
View File
@@ -6,98 +6,105 @@ package api
import (
"errors"
"net/http"
"github.com/miniflux/miniflux/http/handler"
"github.com/miniflux/miniflux/http/context"
"github.com/miniflux/miniflux/http/request"
"github.com/miniflux/miniflux/http/response/json"
)
// CreateCategory is the API handler to create a new category.
func (c *Controller) CreateCategory(ctx *handler.Context, request *handler.Request, response *handler.Response) {
userID := ctx.UserID()
category, err := decodeCategoryPayload(request.Body())
func (c *Controller) CreateCategory(w http.ResponseWriter, r *http.Request) {
category, err := decodeCategoryPayload(r.Body)
if err != nil {
response.JSON().BadRequest(err)
json.BadRequest(w, err)
return
}
ctx := context.New(r)
userID := ctx.UserID()
category.UserID = userID
if err := category.ValidateCategoryCreation(); err != nil {
response.JSON().BadRequest(err)
json.BadRequest(w, err)
return
}
if c, err := c.store.CategoryByTitle(userID, category.Title); err != nil || c != nil {
response.JSON().BadRequest(errors.New("This category already exists"))
json.BadRequest(w, errors.New("This category already exists"))
return
}
err = c.store.CreateCategory(category)
if err != nil {
response.JSON().ServerError(errors.New("Unable to create this category"))
json.ServerError(w, errors.New("Unable to create this category"))
return
}
response.JSON().Created(category)
json.Created(w, category)
}
// UpdateCategory is the API handler to update a category.
func (c *Controller) UpdateCategory(ctx *handler.Context, request *handler.Request, response *handler.Response) {
categoryID, err := request.IntegerParam("categoryID")
func (c *Controller) UpdateCategory(w http.ResponseWriter, r *http.Request) {
categoryID, err := request.IntParam(r, "categoryID")
if err != nil {
response.JSON().BadRequest(err)
json.BadRequest(w, err)
return
}
category, err := decodeCategoryPayload(request.Body())
category, err := decodeCategoryPayload(r.Body)
if err != nil {
response.JSON().BadRequest(err)
json.BadRequest(w, err)
return
}
ctx := context.New(r)
category.UserID = ctx.UserID()
category.ID = categoryID
if err := category.ValidateCategoryModification(); err != nil {
response.JSON().BadRequest(err)
json.BadRequest(w, err)
return
}
err = c.store.UpdateCategory(category)
if err != nil {
response.JSON().ServerError(errors.New("Unable to update this category"))
json.ServerError(w, errors.New("Unable to update this category"))
return
}
response.JSON().Created(category)
json.Created(w, category)
}
// GetCategories is the API handler to get a list of categories for a given user.
func (c *Controller) GetCategories(ctx *handler.Context, request *handler.Request, response *handler.Response) {
func (c *Controller) GetCategories(w http.ResponseWriter, r *http.Request) {
ctx := context.New(r)
categories, err := c.store.Categories(ctx.UserID())
if err != nil {
response.JSON().ServerError(errors.New("Unable to fetch categories"))
json.ServerError(w, errors.New("Unable to fetch categories"))
return
}
response.JSON().Standard(categories)
json.OK(w, categories)
}
// RemoveCategory is the API handler to remove a category.
func (c *Controller) RemoveCategory(ctx *handler.Context, request *handler.Request, response *handler.Response) {
func (c *Controller) RemoveCategory(w http.ResponseWriter, r *http.Request) {
ctx := context.New(r)
userID := ctx.UserID()
categoryID, err := request.IntegerParam("categoryID")
categoryID, err := request.IntParam(r, "categoryID")
if err != nil {
response.JSON().BadRequest(err)
json.BadRequest(w, err)
return
}
if !c.store.CategoryExists(userID, categoryID) {
response.JSON().NotFound(errors.New("Category not found"))
json.NotFound(w, errors.New("Category not found"))
return
}
if err := c.store.RemoveCategory(userID, categoryID); err != nil {
response.JSON().ServerError(errors.New("Unable to remove this category"))
json.ServerError(w, errors.New("Unable to remove this category"))
return
}
response.JSON().NoContent()
json.NoContent(w)
}
+65 -67
View File
@@ -6,107 +6,110 @@ package api
import (
"errors"
"net/http"
"github.com/miniflux/miniflux/http/handler"
"github.com/miniflux/miniflux/http/context"
"github.com/miniflux/miniflux/http/request"
"github.com/miniflux/miniflux/http/response/json"
"github.com/miniflux/miniflux/model"
)
// GetFeedEntry is the API handler to get a single feed entry.
func (c *Controller) GetFeedEntry(ctx *handler.Context, request *handler.Request, response *handler.Response) {
userID := ctx.UserID()
feedID, err := request.IntegerParam("feedID")
func (c *Controller) GetFeedEntry(w http.ResponseWriter, r *http.Request) {
feedID, err := request.IntParam(r, "feedID")
if err != nil {
response.JSON().BadRequest(err)
json.BadRequest(w, err)
return
}
entryID, err := request.IntegerParam("entryID")
entryID, err := request.IntParam(r, "entryID")
if err != nil {
response.JSON().BadRequest(err)
json.BadRequest(w, err)
return
}
ctx := context.New(r)
userID := ctx.UserID()
builder := c.store.NewEntryQueryBuilder(userID)
builder.WithFeedID(feedID)
builder.WithEntryID(entryID)
entry, err := builder.GetEntry()
if err != nil {
response.JSON().ServerError(errors.New("Unable to fetch this entry from the database"))
json.ServerError(w, errors.New("Unable to fetch this entry from the database"))
return
}
if entry == nil {
response.JSON().NotFound(errors.New("Entry not found"))
json.NotFound(w, errors.New("Entry not found"))
return
}
response.JSON().Standard(entry)
json.OK(w, entry)
}
// GetEntry is the API handler to get a single entry.
func (c *Controller) GetEntry(ctx *handler.Context, request *handler.Request, response *handler.Response) {
userID := ctx.UserID()
entryID, err := request.IntegerParam("entryID")
func (c *Controller) GetEntry(w http.ResponseWriter, r *http.Request) {
entryID, err := request.IntParam(r, "entryID")
if err != nil {
response.JSON().BadRequest(err)
json.BadRequest(w, err)
return
}
builder := c.store.NewEntryQueryBuilder(userID)
builder := c.store.NewEntryQueryBuilder(context.New(r).UserID())
builder.WithEntryID(entryID)
entry, err := builder.GetEntry()
if err != nil {
response.JSON().ServerError(errors.New("Unable to fetch this entry from the database"))
json.ServerError(w, errors.New("Unable to fetch this entry from the database"))
return
}
if entry == nil {
response.JSON().NotFound(errors.New("Entry not found"))
json.NotFound(w, errors.New("Entry not found"))
return
}
response.JSON().Standard(entry)
json.OK(w, entry)
}
// GetFeedEntries is the API handler to get all feed entries.
func (c *Controller) GetFeedEntries(ctx *handler.Context, request *handler.Request, response *handler.Response) {
userID := ctx.UserID()
feedID, err := request.IntegerParam("feedID")
func (c *Controller) GetFeedEntries(w http.ResponseWriter, r *http.Request) {
feedID, err := request.IntParam(r, "feedID")
if err != nil {
response.JSON().BadRequest(err)
json.BadRequest(w, err)
return
}
status := request.QueryStringParam("status", "")
status := request.QueryParam(r, "status", "")
if status != "" {
if err := model.ValidateEntryStatus(status); err != nil {
response.JSON().BadRequest(err)
json.BadRequest(w, err)
return
}
}
order := request.QueryStringParam("order", model.DefaultSortingOrder)
order := request.QueryParam(r, "order", model.DefaultSortingOrder)
if err := model.ValidateEntryOrder(order); err != nil {
response.JSON().BadRequest(err)
json.BadRequest(w, err)
return
}
direction := request.QueryStringParam("direction", model.DefaultSortingDirection)
direction := request.QueryParam(r, "direction", model.DefaultSortingDirection)
if err := model.ValidateDirection(direction); err != nil {
response.JSON().BadRequest(err)
json.BadRequest(w, err)
return
}
limit := request.QueryIntegerParam("limit", 100)
offset := request.QueryIntegerParam("offset", 0)
limit := request.QueryIntParam(r, "limit", 100)
offset := request.QueryIntParam(r, "offset", 0)
if err := model.ValidateRange(offset, limit); err != nil {
response.JSON().BadRequest(err)
json.BadRequest(w, err)
return
}
builder := c.store.NewEntryQueryBuilder(userID)
builder := c.store.NewEntryQueryBuilder(context.New(r).UserID())
builder.WithFeedID(feedID)
builder.WithStatus(status)
builder.WithOrder(order)
@@ -116,51 +119,49 @@ func (c *Controller) GetFeedEntries(ctx *handler.Context, request *handler.Reque
entries, err := builder.GetEntries()
if err != nil {
response.JSON().ServerError(errors.New("Unable to fetch the list of entries"))
json.ServerError(w, errors.New("Unable to fetch the list of entries"))
return
}
count, err := builder.CountEntries()
if err != nil {
response.JSON().ServerError(errors.New("Unable to count the number of entries"))
json.ServerError(w, errors.New("Unable to count the number of entries"))
return
}
response.JSON().Standard(&entriesResponse{Total: count, Entries: entries})
json.OK(w, &entriesResponse{Total: count, Entries: entries})
}
// GetEntries is the API handler to fetch entries.
func (c *Controller) GetEntries(ctx *handler.Context, request *handler.Request, response *handler.Response) {
userID := ctx.UserID()
status := request.QueryStringParam("status", "")
func (c *Controller) GetEntries(w http.ResponseWriter, r *http.Request) {
status := request.QueryParam(r, "status", "")
if status != "" {
if err := model.ValidateEntryStatus(status); err != nil {
response.JSON().BadRequest(err)
json.BadRequest(w, err)
return
}
}
order := request.QueryStringParam("order", model.DefaultSortingOrder)
order := request.QueryParam(r, "order", model.DefaultSortingOrder)
if err := model.ValidateEntryOrder(order); err != nil {
response.JSON().BadRequest(err)
json.BadRequest(w, err)
return
}
direction := request.QueryStringParam("direction", model.DefaultSortingDirection)
direction := request.QueryParam(r, "direction", model.DefaultSortingDirection)
if err := model.ValidateDirection(direction); err != nil {
response.JSON().BadRequest(err)
json.BadRequest(w, err)
return
}
limit := request.QueryIntegerParam("limit", 100)
offset := request.QueryIntegerParam("offset", 0)
limit := request.QueryIntParam(r, "limit", 100)
offset := request.QueryIntParam(r, "offset", 0)
if err := model.ValidateRange(offset, limit); err != nil {
response.JSON().BadRequest(err)
json.BadRequest(w, err)
return
}
builder := c.store.NewEntryQueryBuilder(userID)
builder := c.store.NewEntryQueryBuilder(context.New(r).UserID())
builder.WithStatus(status)
builder.WithOrder(order)
builder.WithDirection(direction)
@@ -169,55 +170,52 @@ func (c *Controller) GetEntries(ctx *handler.Context, request *handler.Request,
entries, err := builder.GetEntries()
if err != nil {
response.JSON().ServerError(errors.New("Unable to fetch the list of entries"))
json.ServerError(w, errors.New("Unable to fetch the list of entries"))
return
}
count, err := builder.CountEntries()
if err != nil {
response.JSON().ServerError(errors.New("Unable to count the number of entries"))
json.ServerError(w, errors.New("Unable to count the number of entries"))
return
}
response.JSON().Standard(&entriesResponse{Total: count, Entries: entries})
json.OK(w, &entriesResponse{Total: count, Entries: entries})
}
// SetEntryStatus is the API handler to change the status of entries.
func (c *Controller) SetEntryStatus(ctx *handler.Context, request *handler.Request, response *handler.Response) {
userID := ctx.UserID()
entryIDs, status, err := decodeEntryStatusPayload(request.Body())
func (c *Controller) SetEntryStatus(w http.ResponseWriter, r *http.Request) {
entryIDs, status, err := decodeEntryStatusPayload(r.Body)
if err != nil {
response.JSON().BadRequest(errors.New("Invalid JSON payload"))
json.BadRequest(w, errors.New("Invalid JSON payload"))
return
}
if err := model.ValidateEntryStatus(status); err != nil {
response.JSON().BadRequest(err)
json.BadRequest(w, err)
return
}
if err := c.store.SetEntriesStatus(userID, entryIDs, status); err != nil {
response.JSON().ServerError(errors.New("Unable to change entries status"))
if err := c.store.SetEntriesStatus(context.New(r).UserID(), entryIDs, status); err != nil {
json.ServerError(w, errors.New("Unable to change entries status"))
return
}
response.JSON().NoContent()
json.NoContent(w)
}
// ToggleBookmark is the API handler to toggle bookmark status.
func (c *Controller) ToggleBookmark(ctx *handler.Context, request *handler.Request, response *handler.Response) {
userID := ctx.UserID()
entryID, err := request.IntegerParam("entryID")
func (c *Controller) ToggleBookmark(w http.ResponseWriter, r *http.Request) {
entryID, err := request.IntParam(r, "entryID")
if err != nil {
response.JSON().BadRequest(err)
json.BadRequest(w, err)
return
}
if err := c.store.ToggleBookmark(userID, entryID); err != nil {
response.JSON().ServerError(errors.New("Unable to toggle bookmark value"))
if err := c.store.ToggleBookmark(context.New(r).UserID(), entryID); err != nil {
json.ServerError(w, errors.New("Unable to toggle bookmark value"))
return
}
response.JSON().NoContent()
json.NoContent(w)
}
+59 -63
View File
@@ -6,44 +6,47 @@ package api
import (
"errors"
"net/http"
"github.com/miniflux/miniflux/reader/opml"
"github.com/miniflux/miniflux/http/handler"
"github.com/miniflux/miniflux/http/context"
"github.com/miniflux/miniflux/http/request"
"github.com/miniflux/miniflux/http/response/json"
)
// CreateFeed is the API handler to create a new feed.
func (c *Controller) CreateFeed(ctx *handler.Context, request *handler.Request, response *handler.Response) {
userID := ctx.UserID()
feedURL, categoryID, err := decodeFeedCreationPayload(request.Body())
func (c *Controller) CreateFeed(w http.ResponseWriter, r *http.Request) {
feedURL, categoryID, err := decodeFeedCreationPayload(r.Body)
if err != nil {
response.JSON().BadRequest(err)
json.BadRequest(w, err)
return
}
if feedURL == "" {
response.JSON().BadRequest(errors.New("The feed_url is required"))
json.BadRequest(w, errors.New("The feed_url is required"))
return
}
if categoryID <= 0 {
response.JSON().BadRequest(errors.New("The category_id is required"))
json.BadRequest(w, errors.New("The category_id is required"))
return
}
ctx := context.New(r)
userID := ctx.UserID()
if c.store.FeedURLExists(userID, feedURL) {
response.JSON().BadRequest(errors.New("This feed_url already exists"))
json.BadRequest(w, errors.New("This feed_url already exists"))
return
}
if !c.store.CategoryExists(userID, categoryID) {
response.JSON().BadRequest(errors.New("This category_id doesn't exists or doesn't belongs to this user"))
json.BadRequest(w, errors.New("This category_id doesn't exists or doesn't belongs to this user"))
return
}
feed, err := c.feedHandler.CreateFeed(userID, categoryID, feedURL, false)
if err != nil {
response.JSON().ServerError(errors.New("Unable to create this feed"))
json.ServerError(w, errors.New("Unable to create this feed"))
return
}
@@ -51,142 +54,135 @@ func (c *Controller) CreateFeed(ctx *handler.Context, request *handler.Request,
FeedID int64 `json:"feed_id"`
}
response.JSON().Created(&result{FeedID: feed.ID})
json.Created(w, &result{FeedID: feed.ID})
}
// RefreshFeed is the API handler to refresh a feed.
func (c *Controller) RefreshFeed(ctx *handler.Context, request *handler.Request, response *handler.Response) {
userID := ctx.UserID()
feedID, err := request.IntegerParam("feedID")
func (c *Controller) RefreshFeed(w http.ResponseWriter, r *http.Request) {
feedID, err := request.IntParam(r, "feedID")
if err != nil {
response.JSON().BadRequest(err)
json.BadRequest(w, err)
return
}
ctx := context.New(r)
userID := ctx.UserID()
if !c.store.FeedExists(userID, feedID) {
response.JSON().NotFound(errors.New("Unable to find this feed"))
json.NotFound(w, errors.New("Unable to find this feed"))
return
}
err = c.feedHandler.RefreshFeed(userID, feedID)
if err != nil {
response.JSON().ServerError(errors.New("Unable to refresh this feed"))
json.ServerError(w, errors.New("Unable to refresh this feed"))
return
}
response.JSON().NoContent()
json.NoContent(w)
}
// UpdateFeed is the API handler that is used to update a feed.
func (c *Controller) UpdateFeed(ctx *handler.Context, request *handler.Request, response *handler.Response) {
userID := ctx.UserID()
feedID, err := request.IntegerParam("feedID")
func (c *Controller) UpdateFeed(w http.ResponseWriter, r *http.Request) {
feedID, err := request.IntParam(r, "feedID")
if err != nil {
response.JSON().BadRequest(err)
json.BadRequest(w, err)
return
}
newFeed, err := decodeFeedModificationPayload(request.Body())
newFeed, err := decodeFeedModificationPayload(r.Body)
if err != nil {
response.JSON().BadRequest(err)
json.BadRequest(w, err)
return
}
ctx := context.New(r)
userID := ctx.UserID()
if newFeed.Category != nil && newFeed.Category.ID != 0 && !c.store.CategoryExists(userID, newFeed.Category.ID) {
response.JSON().BadRequest(errors.New("This category_id doesn't exists or doesn't belongs to this user"))
json.BadRequest(w, errors.New("This category_id doesn't exists or doesn't belongs to this user"))
return
}
originalFeed, err := c.store.FeedByID(userID, feedID)
if err != nil {
response.JSON().NotFound(errors.New("Unable to find this feed"))
json.NotFound(w, errors.New("Unable to find this feed"))
return
}
if originalFeed == nil {
response.JSON().NotFound(errors.New("Feed not found"))
json.NotFound(w, errors.New("Feed not found"))
return
}
originalFeed.Merge(newFeed)
if err := c.store.UpdateFeed(originalFeed); err != nil {
response.JSON().ServerError(errors.New("Unable to update this feed"))
json.ServerError(w, errors.New("Unable to update this feed"))
return
}
originalFeed, err = c.store.FeedByID(userID, feedID)
if err != nil {
response.JSON().ServerError(errors.New("Unable to fetch this feed"))
json.ServerError(w, errors.New("Unable to fetch this feed"))
return
}
response.JSON().Created(originalFeed)
json.Created(w, originalFeed)
}
// GetFeeds is the API handler that get all feeds that belongs to the given user.
func (c *Controller) GetFeeds(ctx *handler.Context, request *handler.Request, response *handler.Response) {
feeds, err := c.store.Feeds(ctx.UserID())
func (c *Controller) GetFeeds(w http.ResponseWriter, r *http.Request) {
feeds, err := c.store.Feeds(context.New(r).UserID())
if err != nil {
response.JSON().ServerError(errors.New("Unable to fetch feeds from the database"))
json.ServerError(w, errors.New("Unable to fetch feeds from the database"))
return
}
response.JSON().Standard(feeds)
}
// Export is the API handler that incoves an OPML export.
func (c *Controller) Export(ctx *handler.Context, request *handler.Request, response *handler.Response) {
opmlHandler := opml.NewHandler(c.store)
opml, err := opmlHandler.Export(ctx.LoggedUser().ID)
if err != nil {
response.JSON().ServerError(errors.New("unable to export feeds to OPML"))
}
response.XML().Serve(opml)
json.OK(w, feeds)
}
// GetFeed is the API handler to get a feed.
func (c *Controller) GetFeed(ctx *handler.Context, request *handler.Request, response *handler.Response) {
userID := ctx.UserID()
feedID, err := request.IntegerParam("feedID")
func (c *Controller) GetFeed(w http.ResponseWriter, r *http.Request) {
feedID, err := request.IntParam(r, "feedID")
if err != nil {
response.JSON().BadRequest(err)
json.BadRequest(w, err)
return
}
feed, err := c.store.FeedByID(userID, feedID)
feed, err := c.store.FeedByID(context.New(r).UserID(), feedID)
if err != nil {
response.JSON().ServerError(errors.New("Unable to fetch this feed"))
json.ServerError(w, errors.New("Unable to fetch this feed"))
return
}
if feed == nil {
response.JSON().NotFound(errors.New("Feed not found"))
json.NotFound(w, errors.New("Feed not found"))
return
}
response.JSON().Standard(feed)
json.OK(w, feed)
}
// RemoveFeed is the API handler to remove a feed.
func (c *Controller) RemoveFeed(ctx *handler.Context, request *handler.Request, response *handler.Response) {
userID := ctx.UserID()
feedID, err := request.IntegerParam("feedID")
func (c *Controller) RemoveFeed(w http.ResponseWriter, r *http.Request) {
feedID, err := request.IntParam(r, "feedID")
if err != nil {
response.JSON().BadRequest(err)
json.BadRequest(w, err)
return
}
ctx := context.New(r)
userID := ctx.UserID()
if !c.store.FeedExists(userID, feedID) {
response.JSON().NotFound(errors.New("Feed not found"))
json.NotFound(w, errors.New("Feed not found"))
return
}
if err := c.store.RemoveFeed(userID, feedID); err != nil {
response.JSON().ServerError(errors.New("Unable to remove this feed"))
json.ServerError(w, errors.New("Unable to remove this feed"))
return
}
response.JSON().NoContent()
json.NoContent(w)
}
+12 -10
View File
@@ -6,36 +6,38 @@ package api
import (
"errors"
"net/http"
"github.com/miniflux/miniflux/http/handler"
"github.com/miniflux/miniflux/http/context"
"github.com/miniflux/miniflux/http/request"
"github.com/miniflux/miniflux/http/response/json"
)
// FeedIcon returns a feed icon.
func (c *Controller) FeedIcon(ctx *handler.Context, request *handler.Request, response *handler.Response) {
userID := ctx.UserID()
feedID, err := request.IntegerParam("feedID")
func (c *Controller) FeedIcon(w http.ResponseWriter, r *http.Request) {
feedID, err := request.IntParam(r, "feedID")
if err != nil {
response.JSON().BadRequest(err)
json.BadRequest(w, err)
return
}
if !c.store.HasIcon(feedID) {
response.JSON().NotFound(errors.New("This feed doesn't have any icon"))
json.NotFound(w, errors.New("This feed doesn't have any icon"))
return
}
icon, err := c.store.IconByFeedID(userID, feedID)
icon, err := c.store.IconByFeedID(context.New(r).UserID(), feedID)
if err != nil {
response.JSON().ServerError(errors.New("Unable to fetch feed icon"))
json.ServerError(w, errors.New("Unable to fetch feed icon"))
return
}
if icon == nil {
response.JSON().NotFound(errors.New("This feed doesn't have any icon"))
json.NotFound(w, errors.New("This feed doesn't have any icon"))
return
}
response.JSON().Standard(&feedIcon{
json.OK(w, &feedIcon{
ID: icon.ID,
MimeType: icon.MimeType,
Data: icon.DataURL(),
+39
View File
@@ -0,0 +1,39 @@
// Copyright 2018 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 api
import (
"net/http"
"github.com/miniflux/miniflux/http/context"
"github.com/miniflux/miniflux/http/response/json"
"github.com/miniflux/miniflux/http/response/xml"
"github.com/miniflux/miniflux/reader/opml"
)
// Export is the API handler that export feeds to OPML.
func (c *Controller) Export(w http.ResponseWriter, r *http.Request) {
opmlHandler := opml.NewHandler(c.store)
opml, err := opmlHandler.Export(context.New(r).UserID())
if err != nil {
json.ServerError(w, err)
return
}
xml.OK(w, opml)
}
// Import is the API handler that import an OPML file.
func (c *Controller) Import(w http.ResponseWriter, r *http.Request) {
opmlHandler := opml.NewHandler(c.store)
err := opmlHandler.Import(context.New(r).UserID(), r.Body)
defer r.Body.Close()
if err != nil {
json.ServerError(w, err)
return
}
json.Created(w, map[string]string{"message": "Feeds imported successfully"})
}
+18 -12
View File
@@ -23,10 +23,11 @@ type entriesResponse struct {
Entries model.Entries `json:"entries"`
}
func decodeUserPayload(data io.Reader) (*model.User, error) {
func decodeUserPayload(r io.ReadCloser) (*model.User, error) {
var user model.User
decoder := json.NewDecoder(data)
decoder := json.NewDecoder(r)
defer r.Close()
if err := decoder.Decode(&user); err != nil {
return nil, fmt.Errorf("Unable to decode user JSON object: %v", err)
}
@@ -34,13 +35,14 @@ func decodeUserPayload(data io.Reader) (*model.User, error) {
return &user, nil
}
func decodeURLPayload(data io.Reader) (string, error) {
func decodeURLPayload(r io.ReadCloser) (string, error) {
type payload struct {
URL string `json:"url"`
}
var p payload
decoder := json.NewDecoder(data)
decoder := json.NewDecoder(r)
defer r.Close()
if err := decoder.Decode(&p); err != nil {
return "", fmt.Errorf("invalid JSON payload: %v", err)
}
@@ -48,14 +50,15 @@ func decodeURLPayload(data io.Reader) (string, error) {
return p.URL, nil
}
func decodeEntryStatusPayload(data io.Reader) ([]int64, string, error) {
func decodeEntryStatusPayload(r io.ReadCloser) ([]int64, string, error) {
type payload struct {
EntryIDs []int64 `json:"entry_ids"`
Status string `json:"status"`
}
var p payload
decoder := json.NewDecoder(data)
decoder := json.NewDecoder(r)
defer r.Close()
if err := decoder.Decode(&p); err != nil {
return nil, "", fmt.Errorf("invalid JSON payload: %v", err)
}
@@ -63,14 +66,15 @@ func decodeEntryStatusPayload(data io.Reader) ([]int64, string, error) {
return p.EntryIDs, p.Status, nil
}
func decodeFeedCreationPayload(data io.Reader) (string, int64, error) {
func decodeFeedCreationPayload(r io.ReadCloser) (string, int64, error) {
type payload struct {
FeedURL string `json:"feed_url"`
CategoryID int64 `json:"category_id"`
}
var p payload
decoder := json.NewDecoder(data)
decoder := json.NewDecoder(r)
defer r.Close()
if err := decoder.Decode(&p); err != nil {
return "", 0, fmt.Errorf("invalid JSON payload: %v", err)
}
@@ -78,10 +82,11 @@ func decodeFeedCreationPayload(data io.Reader) (string, int64, error) {
return p.FeedURL, p.CategoryID, nil
}
func decodeFeedModificationPayload(data io.Reader) (*model.Feed, error) {
func decodeFeedModificationPayload(r io.ReadCloser) (*model.Feed, error) {
var feed model.Feed
decoder := json.NewDecoder(data)
decoder := json.NewDecoder(r)
defer r.Close()
if err := decoder.Decode(&feed); err != nil {
return nil, fmt.Errorf("Unable to decode feed JSON object: %v", err)
}
@@ -89,10 +94,11 @@ func decodeFeedModificationPayload(data io.Reader) (*model.Feed, error) {
return &feed, nil
}
func decodeCategoryPayload(data io.Reader) (*model.Category, error) {
func decodeCategoryPayload(r io.ReadCloser) (*model.Category, error) {
var category model.Category
decoder := json.NewDecoder(data)
decoder := json.NewDecoder(r)
defer r.Close()
if err := decoder.Decode(&category); err != nil {
return nil, fmt.Errorf("Unable to decode category JSON object: %v", err)
}
+8 -7
View File
@@ -7,29 +7,30 @@ package api
import (
"errors"
"fmt"
"net/http"
"github.com/miniflux/miniflux/http/handler"
"github.com/miniflux/miniflux/http/response/json"
"github.com/miniflux/miniflux/reader/subscription"
)
// GetSubscriptions is the API handler to find subscriptions.
func (c *Controller) GetSubscriptions(ctx *handler.Context, request *handler.Request, response *handler.Response) {
websiteURL, err := decodeURLPayload(request.Body())
func (c *Controller) GetSubscriptions(w http.ResponseWriter, r *http.Request) {
websiteURL, err := decodeURLPayload(r.Body)
if err != nil {
response.JSON().BadRequest(err)
json.BadRequest(w, err)
return
}
subscriptions, err := subscription.FindSubscriptions(websiteURL)
if err != nil {
response.JSON().ServerError(errors.New("Unable to discover subscriptions"))
json.ServerError(w, errors.New("Unable to discover subscriptions"))
return
}
if subscriptions == nil {
response.JSON().NotFound(fmt.Errorf("No subscription found"))
json.NotFound(w, fmt.Errorf("No subscription found"))
return
}
response.JSON().Standard(subscriptions)
json.OK(w, subscriptions)
}
+68 -47
View File
@@ -6,182 +6,203 @@ package api
import (
"errors"
"net/http"
"github.com/miniflux/miniflux/http/handler"
"github.com/miniflux/miniflux/http/context"
"github.com/miniflux/miniflux/http/request"
"github.com/miniflux/miniflux/http/response/json"
)
// CreateUser is the API handler to create a new user.
func (c *Controller) CreateUser(ctx *handler.Context, request *handler.Request, response *handler.Response) {
if !ctx.IsAdminUser() {
response.JSON().Forbidden()
// CurrentUser is the API handler to retrieve the authenticated user.
func (c *Controller) CurrentUser(w http.ResponseWriter, r *http.Request) {
ctx := context.New(r)
user, err := c.store.UserByID(ctx.UserID())
if err != nil {
json.ServerError(w, err)
return
}
user, err := decodeUserPayload(request.Body())
json.OK(w, user)
}
// CreateUser is the API handler to create a new user.
func (c *Controller) CreateUser(w http.ResponseWriter, r *http.Request) {
ctx := context.New(r)
if !ctx.IsAdminUser() {
json.Forbidden(w)
return
}
user, err := decodeUserPayload(r.Body)
if err != nil {
response.JSON().BadRequest(err)
json.BadRequest(w, err)
return
}
if err := user.ValidateUserCreation(); err != nil {
response.JSON().BadRequest(err)
json.BadRequest(w, err)
return
}
if c.store.UserExists(user.Username) {
response.JSON().BadRequest(errors.New("This user already exists"))
json.BadRequest(w, errors.New("This user already exists"))
return
}
err = c.store.CreateUser(user)
if err != nil {
response.JSON().ServerError(errors.New("Unable to create this user"))
json.ServerError(w, errors.New("Unable to create this user"))
return
}
user.Password = ""
response.JSON().Created(user)
json.Created(w, user)
}
// UpdateUser is the API handler to update the given user.
func (c *Controller) UpdateUser(ctx *handler.Context, request *handler.Request, response *handler.Response) {
func (c *Controller) UpdateUser(w http.ResponseWriter, r *http.Request) {
ctx := context.New(r)
if !ctx.IsAdminUser() {
response.JSON().Forbidden()
json.Forbidden(w)
return
}
userID, err := request.IntegerParam("userID")
userID, err := request.IntParam(r, "userID")
if err != nil {
response.JSON().BadRequest(err)
json.BadRequest(w, err)
return
}
user, err := decodeUserPayload(request.Body())
user, err := decodeUserPayload(r.Body)
if err != nil {
response.JSON().BadRequest(err)
json.BadRequest(w, err)
return
}
if err := user.ValidateUserModification(); err != nil {
response.JSON().BadRequest(err)
json.BadRequest(w, err)
return
}
originalUser, err := c.store.UserByID(userID)
if err != nil {
response.JSON().BadRequest(errors.New("Unable to fetch this user from the database"))
json.BadRequest(w, errors.New("Unable to fetch this user from the database"))
return
}
if originalUser == nil {
response.JSON().NotFound(errors.New("User not found"))
json.NotFound(w, errors.New("User not found"))
return
}
originalUser.Merge(user)
if err = c.store.UpdateUser(originalUser); err != nil {
response.JSON().ServerError(errors.New("Unable to update this user"))
json.ServerError(w, errors.New("Unable to update this user"))
return
}
response.JSON().Created(originalUser)
json.Created(w, originalUser)
}
// Users is the API handler to get the list of users.
func (c *Controller) Users(ctx *handler.Context, request *handler.Request, response *handler.Response) {
func (c *Controller) Users(w http.ResponseWriter, r *http.Request) {
ctx := context.New(r)
if !ctx.IsAdminUser() {
response.JSON().Forbidden()
json.Forbidden(w)
return
}
users, err := c.store.Users()
if err != nil {
response.JSON().ServerError(errors.New("Unable to fetch the list of users"))
json.ServerError(w, errors.New("Unable to fetch the list of users"))
return
}
users.UseTimezone(ctx.UserTimezone())
response.JSON().Standard(users)
json.OK(w, users)
}
// UserByID is the API handler to fetch the given user by the ID.
func (c *Controller) UserByID(ctx *handler.Context, request *handler.Request, response *handler.Response) {
func (c *Controller) UserByID(w http.ResponseWriter, r *http.Request) {
ctx := context.New(r)
if !ctx.IsAdminUser() {
response.JSON().Forbidden()
json.Forbidden(w)
return
}
userID, err := request.IntegerParam("userID")
userID, err := request.IntParam(r, "userID")
if err != nil {
response.JSON().BadRequest(err)
json.BadRequest(w, err)
return
}
user, err := c.store.UserByID(userID)
if err != nil {
response.JSON().BadRequest(errors.New("Unable to fetch this user from the database"))
json.BadRequest(w, errors.New("Unable to fetch this user from the database"))
return
}
if user == nil {
response.JSON().NotFound(errors.New("User not found"))
json.NotFound(w, errors.New("User not found"))
return
}
user.UseTimezone(ctx.UserTimezone())
response.JSON().Standard(user)
json.OK(w, user)
}
// UserByUsername is the API handler to fetch the given user by the username.
func (c *Controller) UserByUsername(ctx *handler.Context, request *handler.Request, response *handler.Response) {
func (c *Controller) UserByUsername(w http.ResponseWriter, r *http.Request) {
ctx := context.New(r)
if !ctx.IsAdminUser() {
response.JSON().Forbidden()
json.Forbidden(w)
return
}
username := request.StringParam("username", "")
username := request.Param(r, "username", "")
user, err := c.store.UserByUsername(username)
if err != nil {
response.JSON().BadRequest(errors.New("Unable to fetch this user from the database"))
json.BadRequest(w, errors.New("Unable to fetch this user from the database"))
return
}
if user == nil {
response.JSON().NotFound(errors.New("User not found"))
json.NotFound(w, errors.New("User not found"))
return
}
response.JSON().Standard(user)
json.OK(w, user)
}
// RemoveUser is the API handler to remove an existing user.
func (c *Controller) RemoveUser(ctx *handler.Context, request *handler.Request, response *handler.Response) {
func (c *Controller) RemoveUser(w http.ResponseWriter, r *http.Request) {
ctx := context.New(r)
if !ctx.IsAdminUser() {
response.JSON().Forbidden()
json.Forbidden(w)
return
}
userID, err := request.IntegerParam("userID")
userID, err := request.IntParam(r, "userID")
if err != nil {
response.JSON().BadRequest(err)
json.BadRequest(w, err)
return
}
user, err := c.store.UserByID(userID)
if err != nil {
response.JSON().ServerError(errors.New("Unable to fetch this user from the database"))
json.ServerError(w, errors.New("Unable to fetch this user from the database"))
return
}
if user == nil {
response.JSON().NotFound(errors.New("User not found"))
json.NotFound(w, errors.New("User not found"))
return
}
if err := c.store.RemoveUser(user.ID); err != nil {
response.JSON().BadRequest(errors.New("Unable to remove this user from the database"))
json.BadRequest(w, errors.New("Unable to remove this user from the database"))
return
}
response.JSON().NoContent()
json.NoContent(w)
}
+20 -15
View File
@@ -13,18 +13,18 @@ import (
)
const (
defaultBaseURL = "http://localhost"
defaultDatabaseURL = "postgres://postgres:postgres@localhost/miniflux2?sslmode=disable"
defaultWorkerPoolSize = 5
defaultPollingFrequency = 60
defaultBatchSize = 10
defaultDatabaseMaxConns = 20
defaultListenAddr = "127.0.0.1:8080"
defaultCertFile = ""
defaultKeyFile = ""
defaultCertDomain = ""
defaultCertCache = "/tmp/cert_cache"
defaultSessionCleanupFrequency = 24
defaultBaseURL = "http://localhost"
defaultDatabaseURL = "postgres://postgres:postgres@localhost/miniflux2?sslmode=disable"
defaultWorkerPoolSize = 5
defaultPollingFrequency = 60
defaultBatchSize = 10
defaultDatabaseMaxConns = 20
defaultListenAddr = "127.0.0.1:8080"
defaultCertFile = ""
defaultKeyFile = ""
defaultCertDomain = ""
defaultCertCache = "/tmp/cert_cache"
defaultCleanupFrequency = 24
)
// Config manages configuration parameters.
@@ -137,9 +137,9 @@ func (c *Config) CertCache() string {
return c.get("CERT_CACHE", defaultCertCache)
}
// SessionCleanupFrequency returns the interval for session cleanup.
func (c *Config) SessionCleanupFrequency() int {
return c.getInt("SESSION_CLEANUP_FREQUENCY", defaultSessionCleanupFrequency)
// CleanupFrequency returns the interval for cleanup jobs.
func (c *Config) CleanupFrequency() int {
return c.getInt("CLEANUP_FREQUENCY", defaultCleanupFrequency)
}
// WorkerPoolSize returns the number of background worker.
@@ -197,6 +197,11 @@ func (c *Config) CreateAdmin() bool {
return c.get("CREATE_ADMIN", "") != ""
}
// PocketConsumerKey returns the Pocket Consumer Key if defined as environment variable.
func (c *Config) PocketConsumerKey(defaultValue string) string {
return c.get("POCKET_CONSUMER_KEY", defaultValue)
}
// NewConfig returns a new Config.
func NewConfig() *Config {
return &Config{IsHTTPS: os.Getenv("HTTPS") != ""}
+1 -1
View File
@@ -50,7 +50,7 @@ func Run(cfg *config.Config, store *storage.Storage) {
cfg.BatchSize(),
)
scheduler.NewSessionScheduler(store, cfg.SessionCleanupFrequency())
scheduler.NewCleanupScheduler(store, cfg.CleanupFrequency())
<-stop
logger.Info("Shutting down the server...")
+108 -128
View File
@@ -10,11 +10,9 @@ import (
"github.com/miniflux/miniflux/api"
"github.com/miniflux/miniflux/config"
"github.com/miniflux/miniflux/fever"
"github.com/miniflux/miniflux/http/handler"
"github.com/miniflux/miniflux/http/middleware"
"github.com/miniflux/miniflux/locale"
"github.com/miniflux/miniflux/middleware"
"github.com/miniflux/miniflux/reader/feed"
"github.com/miniflux/miniflux/reader/opml"
"github.com/miniflux/miniflux/scheduler"
"github.com/miniflux/miniflux/storage"
"github.com/miniflux/miniflux/template"
@@ -26,137 +24,18 @@ import (
func routes(cfg *config.Config, store *storage.Storage, feedHandler *feed.Handler, pool *scheduler.WorkerPool, translator *locale.Translator) *mux.Router {
router := mux.NewRouter()
templateEngine := template.NewEngine(cfg, router, translator)
apiController := api.NewController(store, feedHandler)
feverController := fever.NewController(store)
uiController := ui.NewController(cfg, store, pool, feedHandler, opml.NewHandler(store))
apiHandler := handler.NewHandler(cfg, store, router, templateEngine, translator, middleware.NewChain(
middleware.NewBasicAuthMiddleware(store).Handler,
))
feverHandler := handler.NewHandler(cfg, store, router, templateEngine, translator, middleware.NewChain(
middleware.NewFeverMiddleware(store).Handler,
))
uiHandler := handler.NewHandler(cfg, store, router, templateEngine, translator, middleware.NewChain(
middleware.NewUserSessionMiddleware(store, router).Handler,
middleware.NewSessionMiddleware(cfg, store).Handler,
))
feverController := fever.NewController(cfg, store)
uiController := ui.NewController(cfg, store, pool, feedHandler, templateEngine, translator, router)
middleware := middleware.New(cfg, store, router)
if cfg.BasePath() != "" {
router = router.PathPrefix(cfg.BasePath()).Subrouter()
}
router.Handle("/fever/", feverHandler.Use(feverController.Handler)).Name("feverEndpoint")
router.Handle("/v1/users", apiHandler.Use(apiController.CreateUser)).Methods("POST")
router.Handle("/v1/users", apiHandler.Use(apiController.Users)).Methods("GET")
router.Handle("/v1/users/{userID:[0-9]+}", apiHandler.Use(apiController.UserByID)).Methods("GET")
router.Handle("/v1/users/{userID:[0-9]+}", apiHandler.Use(apiController.UpdateUser)).Methods("PUT")
router.Handle("/v1/users/{userID:[0-9]+}", apiHandler.Use(apiController.RemoveUser)).Methods("DELETE")
router.Handle("/v1/users/{username}", apiHandler.Use(apiController.UserByUsername)).Methods("GET")
router.Handle("/v1/categories", apiHandler.Use(apiController.CreateCategory)).Methods("POST")
router.Handle("/v1/categories", apiHandler.Use(apiController.GetCategories)).Methods("GET")
router.Handle("/v1/categories/{categoryID}", apiHandler.Use(apiController.UpdateCategory)).Methods("PUT")
router.Handle("/v1/categories/{categoryID}", apiHandler.Use(apiController.RemoveCategory)).Methods("DELETE")
router.Handle("/v1/discover", apiHandler.Use(apiController.GetSubscriptions)).Methods("POST")
router.Handle("/v1/feeds", apiHandler.Use(apiController.CreateFeed)).Methods("POST")
router.Handle("/v1/feeds", apiHandler.Use(apiController.GetFeeds)).Methods("Get")
router.Handle("/v1/feeds/{feedID}/refresh", apiHandler.Use(apiController.RefreshFeed)).Methods("PUT")
router.Handle("/v1/feeds/{feedID}", apiHandler.Use(apiController.GetFeed)).Methods("GET")
router.Handle("/v1/feeds/{feedID}", apiHandler.Use(apiController.UpdateFeed)).Methods("PUT")
router.Handle("/v1/feeds/{feedID}", apiHandler.Use(apiController.RemoveFeed)).Methods("DELETE")
router.Handle("/v1/feeds/{feedID}/icon", apiHandler.Use(apiController.FeedIcon)).Methods("GET")
router.Handle("/v1/export", apiHandler.Use(apiController.Export)).Methods("GET")
router.Handle("/v1/feeds/{feedID}/entries", apiHandler.Use(apiController.GetFeedEntries)).Methods("GET")
router.Handle("/v1/feeds/{feedID}/entries/{entryID}", apiHandler.Use(apiController.GetFeedEntry)).Methods("GET")
router.Handle("/v1/entries", apiHandler.Use(apiController.GetEntries)).Methods("GET")
router.Handle("/v1/entries", apiHandler.Use(apiController.SetEntryStatus)).Methods("PUT")
router.Handle("/v1/entries/{entryID}", apiHandler.Use(apiController.GetEntry)).Methods("GET")
router.Handle("/v1/entries/{entryID}/bookmark", apiHandler.Use(apiController.ToggleBookmark)).Methods("PUT")
router.Handle("/stylesheets/{name}.css", uiHandler.Use(uiController.Stylesheet)).Name("stylesheet").Methods("GET")
router.Handle("/js", uiHandler.Use(uiController.Javascript)).Name("javascript").Methods("GET")
router.Handle("/favicon.ico", uiHandler.Use(uiController.Favicon)).Name("favicon").Methods("GET")
router.Handle("/icon/{filename}", uiHandler.Use(uiController.AppIcon)).Name("appIcon").Methods("GET")
router.Handle("/manifest.json", uiHandler.Use(uiController.WebManifest)).Name("webManifest").Methods("GET")
router.Handle("/subscribe", uiHandler.Use(uiController.AddSubscription)).Name("addSubscription").Methods("GET")
router.Handle("/subscribe", uiHandler.Use(uiController.SubmitSubscription)).Name("submitSubscription").Methods("POST")
router.Handle("/subscriptions", uiHandler.Use(uiController.ChooseSubscription)).Name("chooseSubscription").Methods("POST")
router.Handle("/mark-all-as-read", uiHandler.Use(uiController.MarkAllAsRead)).Name("markAllAsRead").Methods("GET")
router.Handle("/unread", uiHandler.Use(uiController.ShowUnreadPage)).Name("unread").Methods("GET")
router.Handle("/history", uiHandler.Use(uiController.ShowHistoryPage)).Name("history").Methods("GET")
router.Handle("/starred", uiHandler.Use(uiController.ShowStarredPage)).Name("starred").Methods("GET")
router.Handle("/feed/{feedID}/refresh", uiHandler.Use(uiController.RefreshFeed)).Name("refreshFeed").Methods("GET")
router.Handle("/feed/{feedID}/edit", uiHandler.Use(uiController.EditFeed)).Name("editFeed").Methods("GET")
router.Handle("/feed/{feedID}/remove", uiHandler.Use(uiController.RemoveFeed)).Name("removeFeed").Methods("POST")
router.Handle("/feed/{feedID}/update", uiHandler.Use(uiController.UpdateFeed)).Name("updateFeed").Methods("POST")
router.Handle("/feed/{feedID}/entries", uiHandler.Use(uiController.ShowFeedEntries)).Name("feedEntries").Methods("GET")
router.Handle("/feeds", uiHandler.Use(uiController.ShowFeedsPage)).Name("feeds").Methods("GET")
router.Handle("/feeds/refresh", uiHandler.Use(uiController.RefreshAllFeeds)).Name("refreshAllFeeds").Methods("GET")
router.Handle("/unread/entry/{entryID}", uiHandler.Use(uiController.ShowUnreadEntry)).Name("unreadEntry").Methods("GET")
router.Handle("/history/entry/{entryID}", uiHandler.Use(uiController.ShowReadEntry)).Name("readEntry").Methods("GET")
router.Handle("/history/flush", uiHandler.Use(uiController.FlushHistory)).Name("flushHistory").Methods("GET")
router.Handle("/feed/{feedID}/entry/{entryID}", uiHandler.Use(uiController.ShowFeedEntry)).Name("feedEntry").Methods("GET")
router.Handle("/category/{categoryID}/entry/{entryID}", uiHandler.Use(uiController.ShowCategoryEntry)).Name("categoryEntry").Methods("GET")
router.Handle("/starred/entry/{entryID}", uiHandler.Use(uiController.ShowStarredEntry)).Name("starredEntry").Methods("GET")
router.Handle("/entry/status", uiHandler.Use(uiController.UpdateEntriesStatus)).Name("updateEntriesStatus").Methods("POST")
router.Handle("/entry/save/{entryID}", uiHandler.Use(uiController.SaveEntry)).Name("saveEntry").Methods("POST")
router.Handle("/entry/download/{entryID}", uiHandler.Use(uiController.FetchContent)).Name("fetchContent").Methods("POST")
router.Handle("/entry/bookmark/{entryID}", uiHandler.Use(uiController.ToggleBookmark)).Name("toggleBookmark").Methods("POST")
router.Handle("/categories", uiHandler.Use(uiController.ShowCategories)).Name("categories").Methods("GET")
router.Handle("/category/create", uiHandler.Use(uiController.CreateCategory)).Name("createCategory").Methods("GET")
router.Handle("/category/save", uiHandler.Use(uiController.SaveCategory)).Name("saveCategory").Methods("POST")
router.Handle("/category/{categoryID}/entries", uiHandler.Use(uiController.ShowCategoryEntries)).Name("categoryEntries").Methods("GET")
router.Handle("/category/{categoryID}/edit", uiHandler.Use(uiController.EditCategory)).Name("editCategory").Methods("GET")
router.Handle("/category/{categoryID}/update", uiHandler.Use(uiController.UpdateCategory)).Name("updateCategory").Methods("POST")
router.Handle("/category/{categoryID}/remove", uiHandler.Use(uiController.RemoveCategory)).Name("removeCategory").Methods("POST")
router.Handle("/feed/icon/{iconID}", uiHandler.Use(uiController.ShowIcon)).Name("icon").Methods("GET")
router.Handle("/proxy/{encodedURL}", uiHandler.Use(uiController.ImageProxy)).Name("proxy").Methods("GET")
router.Handle("/users", uiHandler.Use(uiController.ShowUsers)).Name("users").Methods("GET")
router.Handle("/user/create", uiHandler.Use(uiController.CreateUser)).Name("createUser").Methods("GET")
router.Handle("/user/save", uiHandler.Use(uiController.SaveUser)).Name("saveUser").Methods("POST")
router.Handle("/users/{userID}/edit", uiHandler.Use(uiController.EditUser)).Name("editUser").Methods("GET")
router.Handle("/users/{userID}/update", uiHandler.Use(uiController.UpdateUser)).Name("updateUser").Methods("POST")
router.Handle("/users/{userID}/remove", uiHandler.Use(uiController.RemoveUser)).Name("removeUser").Methods("POST")
router.Handle("/about", uiHandler.Use(uiController.AboutPage)).Name("about").Methods("GET")
router.Handle("/settings", uiHandler.Use(uiController.ShowSettings)).Name("settings").Methods("GET")
router.Handle("/settings", uiHandler.Use(uiController.UpdateSettings)).Name("updateSettings").Methods("POST")
router.Handle("/bookmarklet", uiHandler.Use(uiController.Bookmarklet)).Name("bookmarklet").Methods("GET")
router.Handle("/integrations", uiHandler.Use(uiController.ShowIntegrations)).Name("integrations").Methods("GET")
router.Handle("/integration", uiHandler.Use(uiController.UpdateIntegration)).Name("updateIntegration").Methods("POST")
router.Handle("/sessions", uiHandler.Use(uiController.ShowSessions)).Name("sessions").Methods("GET")
router.Handle("/sessions/{sessionID}/remove", uiHandler.Use(uiController.RemoveSession)).Name("removeSession").Methods("POST")
router.Handle("/export", uiHandler.Use(uiController.Export)).Name("export").Methods("GET")
router.Handle("/import", uiHandler.Use(uiController.Import)).Name("import").Methods("GET")
router.Handle("/upload", uiHandler.Use(uiController.UploadOPML)).Name("uploadOPML").Methods("POST")
router.Handle("/oauth2/{provider}/unlink", uiHandler.Use(uiController.OAuth2Unlink)).Name("oauth2Unlink").Methods("GET")
router.Handle("/oauth2/{provider}/redirect", uiHandler.Use(uiController.OAuth2Redirect)).Name("oauth2Redirect").Methods("GET")
router.Handle("/oauth2/{provider}/callback", uiHandler.Use(uiController.OAuth2Callback)).Name("oauth2Callback").Methods("GET")
router.Handle("/login", uiHandler.Use(uiController.CheckLogin)).Name("checkLogin").Methods("POST")
router.Handle("/logout", uiHandler.Use(uiController.Logout)).Name("logout").Methods("GET")
router.Handle("/", uiHandler.Use(uiController.ShowLoginPage)).Name("login").Methods("GET")
router.Use(middleware.HeaderConfig)
router.Use(middleware.Logging)
router.Use(middleware.CommonHeaders)
router.HandleFunc("/healthcheck", func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("OK"))
@@ -167,5 +46,106 @@ func routes(cfg *config.Config, store *storage.Storage, feedHandler *feed.Handle
w.Write([]byte("User-agent: *\nDisallow: /"))
})
feverRouter := router.PathPrefix("/fever").Subrouter()
feverRouter.Use(middleware.FeverAuth)
feverRouter.HandleFunc("/", feverController.Handler).Name("feverEndpoint")
apiRouter := router.PathPrefix("/v1").Subrouter()
apiRouter.Use(middleware.BasicAuth)
apiRouter.HandleFunc("/users", apiController.CreateUser).Methods("POST")
apiRouter.HandleFunc("/users", apiController.Users).Methods("GET")
apiRouter.HandleFunc("/users/{userID:[0-9]+}", apiController.UserByID).Methods("GET")
apiRouter.HandleFunc("/users/{userID:[0-9]+}", apiController.UpdateUser).Methods("PUT")
apiRouter.HandleFunc("/users/{userID:[0-9]+}", apiController.RemoveUser).Methods("DELETE")
apiRouter.HandleFunc("/users/{username}", apiController.UserByUsername).Methods("GET")
apiRouter.HandleFunc("/me", apiController.CurrentUser).Methods("GET")
apiRouter.HandleFunc("/categories", apiController.CreateCategory).Methods("POST")
apiRouter.HandleFunc("/categories", apiController.GetCategories).Methods("GET")
apiRouter.HandleFunc("/categories/{categoryID}", apiController.UpdateCategory).Methods("PUT")
apiRouter.HandleFunc("/categories/{categoryID}", apiController.RemoveCategory).Methods("DELETE")
apiRouter.HandleFunc("/discover", apiController.GetSubscriptions).Methods("POST")
apiRouter.HandleFunc("/feeds", apiController.CreateFeed).Methods("POST")
apiRouter.HandleFunc("/feeds", apiController.GetFeeds).Methods("GET")
apiRouter.HandleFunc("/feeds/{feedID}/refresh", apiController.RefreshFeed).Methods("PUT")
apiRouter.HandleFunc("/feeds/{feedID}", apiController.GetFeed).Methods("GET")
apiRouter.HandleFunc("/feeds/{feedID}", apiController.UpdateFeed).Methods("PUT")
apiRouter.HandleFunc("/feeds/{feedID}", apiController.RemoveFeed).Methods("DELETE")
apiRouter.HandleFunc("/feeds/{feedID}/icon", apiController.FeedIcon).Methods("GET")
apiRouter.HandleFunc("/export", apiController.Export).Methods("GET")
apiRouter.HandleFunc("/import", apiController.Import).Methods("POST")
apiRouter.HandleFunc("/feeds/{feedID}/entries", apiController.GetFeedEntries).Methods("GET")
apiRouter.HandleFunc("/feeds/{feedID}/entries/{entryID}", apiController.GetFeedEntry).Methods("GET")
apiRouter.HandleFunc("/entries", apiController.GetEntries).Methods("GET")
apiRouter.HandleFunc("/entries", apiController.SetEntryStatus).Methods("PUT")
apiRouter.HandleFunc("/entries/{entryID}", apiController.GetEntry).Methods("GET")
apiRouter.HandleFunc("/entries/{entryID}/bookmark", apiController.ToggleBookmark).Methods("PUT")
uiRouter := router.NewRoute().Subrouter()
uiRouter.Use(middleware.AppSession)
uiRouter.Use(middleware.UserSession)
uiRouter.HandleFunc("/stylesheets/{name}.css", uiController.Stylesheet).Name("stylesheet").Methods("GET")
uiRouter.HandleFunc("/js", uiController.Javascript).Name("javascript").Methods("GET")
uiRouter.HandleFunc("/favicon.ico", uiController.Favicon).Name("favicon").Methods("GET")
uiRouter.HandleFunc("/icon/{filename}", uiController.AppIcon).Name("appIcon").Methods("GET")
uiRouter.HandleFunc("/manifest.json", uiController.WebManifest).Name("webManifest").Methods("GET")
uiRouter.HandleFunc("/subscribe", uiController.AddSubscription).Name("addSubscription").Methods("GET")
uiRouter.HandleFunc("/subscribe", uiController.SubmitSubscription).Name("submitSubscription").Methods("POST")
uiRouter.HandleFunc("/subscriptions", uiController.ChooseSubscription).Name("chooseSubscription").Methods("POST")
uiRouter.HandleFunc("/mark-all-as-read", uiController.MarkAllAsRead).Name("markAllAsRead").Methods("GET")
uiRouter.HandleFunc("/unread", uiController.ShowUnreadPage).Name("unread").Methods("GET")
uiRouter.HandleFunc("/history", uiController.ShowHistoryPage).Name("history").Methods("GET")
uiRouter.HandleFunc("/starred", uiController.ShowStarredPage).Name("starred").Methods("GET")
uiRouter.HandleFunc("/feed/{feedID}/refresh", uiController.RefreshFeed).Name("refreshFeed").Methods("GET")
uiRouter.HandleFunc("/feed/{feedID}/edit", uiController.EditFeed).Name("editFeed").Methods("GET")
uiRouter.HandleFunc("/feed/{feedID}/remove", uiController.RemoveFeed).Name("removeFeed").Methods("POST")
uiRouter.HandleFunc("/feed/{feedID}/update", uiController.UpdateFeed).Name("updateFeed").Methods("POST")
uiRouter.HandleFunc("/feed/{feedID}/entries", uiController.ShowFeedEntries).Name("feedEntries").Methods("GET")
uiRouter.HandleFunc("/feeds", uiController.ShowFeedsPage).Name("feeds").Methods("GET")
uiRouter.HandleFunc("/feeds/refresh", uiController.RefreshAllFeeds).Name("refreshAllFeeds").Methods("GET")
uiRouter.HandleFunc("/unread/entry/{entryID}", uiController.ShowUnreadEntry).Name("unreadEntry").Methods("GET")
uiRouter.HandleFunc("/history/entry/{entryID}", uiController.ShowReadEntry).Name("readEntry").Methods("GET")
uiRouter.HandleFunc("/history/flush", uiController.FlushHistory).Name("flushHistory").Methods("GET")
uiRouter.HandleFunc("/feed/{feedID}/entry/{entryID}", uiController.ShowFeedEntry).Name("feedEntry").Methods("GET")
uiRouter.HandleFunc("/category/{categoryID}/entry/{entryID}", uiController.ShowCategoryEntry).Name("categoryEntry").Methods("GET")
uiRouter.HandleFunc("/starred/entry/{entryID}", uiController.ShowStarredEntry).Name("starredEntry").Methods("GET")
uiRouter.HandleFunc("/entry/status", uiController.UpdateEntriesStatus).Name("updateEntriesStatus").Methods("POST")
uiRouter.HandleFunc("/entry/save/{entryID}", uiController.SaveEntry).Name("saveEntry").Methods("POST")
uiRouter.HandleFunc("/entry/download/{entryID}", uiController.FetchContent).Name("fetchContent").Methods("POST")
uiRouter.HandleFunc("/entry/bookmark/{entryID}", uiController.ToggleBookmark).Name("toggleBookmark").Methods("POST")
uiRouter.HandleFunc("/categories", uiController.CategoryList).Name("categories").Methods("GET")
uiRouter.HandleFunc("/category/create", uiController.CreateCategory).Name("createCategory").Methods("GET")
uiRouter.HandleFunc("/category/save", uiController.SaveCategory).Name("saveCategory").Methods("POST")
uiRouter.HandleFunc("/category/{categoryID}/entries", uiController.CategoryEntries).Name("categoryEntries").Methods("GET")
uiRouter.HandleFunc("/category/{categoryID}/edit", uiController.EditCategory).Name("editCategory").Methods("GET")
uiRouter.HandleFunc("/category/{categoryID}/update", uiController.UpdateCategory).Name("updateCategory").Methods("POST")
uiRouter.HandleFunc("/category/{categoryID}/remove", uiController.RemoveCategory).Name("removeCategory").Methods("POST")
uiRouter.HandleFunc("/feed/icon/{iconID}", uiController.ShowIcon).Name("icon").Methods("GET")
uiRouter.HandleFunc("/proxy/{encodedURL}", uiController.ImageProxy).Name("proxy").Methods("GET")
uiRouter.HandleFunc("/users", uiController.ShowUsers).Name("users").Methods("GET")
uiRouter.HandleFunc("/user/create", uiController.CreateUser).Name("createUser").Methods("GET")
uiRouter.HandleFunc("/user/save", uiController.SaveUser).Name("saveUser").Methods("POST")
uiRouter.HandleFunc("/users/{userID}/edit", uiController.EditUser).Name("editUser").Methods("GET")
uiRouter.HandleFunc("/users/{userID}/update", uiController.UpdateUser).Name("updateUser").Methods("POST")
uiRouter.HandleFunc("/users/{userID}/remove", uiController.RemoveUser).Name("removeUser").Methods("POST")
uiRouter.HandleFunc("/about", uiController.About).Name("about").Methods("GET")
uiRouter.HandleFunc("/settings", uiController.ShowSettings).Name("settings").Methods("GET")
uiRouter.HandleFunc("/settings", uiController.UpdateSettings).Name("updateSettings").Methods("POST")
uiRouter.HandleFunc("/bookmarklet", uiController.Bookmarklet).Name("bookmarklet").Methods("GET")
uiRouter.HandleFunc("/integrations", uiController.ShowIntegrations).Name("integrations").Methods("GET")
uiRouter.HandleFunc("/integration", uiController.UpdateIntegration).Name("updateIntegration").Methods("POST")
uiRouter.HandleFunc("/integration/pocket/authorize", uiController.PocketAuthorize).Name("pocketAuthorize").Methods("GET")
uiRouter.HandleFunc("/integration/pocket/callback", uiController.PocketCallback).Name("pocketCallback").Methods("GET")
uiRouter.HandleFunc("/sessions", uiController.ShowSessions).Name("sessions").Methods("GET")
uiRouter.HandleFunc("/sessions/{sessionID}/remove", uiController.RemoveSession).Name("removeSession").Methods("POST")
uiRouter.HandleFunc("/export", uiController.Export).Name("export").Methods("GET")
uiRouter.HandleFunc("/import", uiController.Import).Name("import").Methods("GET")
uiRouter.HandleFunc("/upload", uiController.UploadOPML).Name("uploadOPML").Methods("POST")
uiRouter.HandleFunc("/oauth2/{provider}/unlink", uiController.OAuth2Unlink).Name("oauth2Unlink").Methods("GET")
uiRouter.HandleFunc("/oauth2/{provider}/redirect", uiController.OAuth2Redirect).Name("oauth2Redirect").Methods("GET")
uiRouter.HandleFunc("/oauth2/{provider}/callback", uiController.OAuth2Callback).Name("oauth2Callback").Methods("GET")
uiRouter.HandleFunc("/login", uiController.CheckLogin).Name("checkLogin").Methods("POST")
uiRouter.HandleFunc("/logout", uiController.Logout).Name("logout").Methods("GET")
uiRouter.HandleFunc("/", uiController.ShowLoginPage).Name("login").Methods("GET")
return router
}
+84 -66
View File
@@ -5,11 +5,15 @@
package fever
import (
"net/http"
"strconv"
"strings"
"time"
"github.com/miniflux/miniflux/http/handler"
"github.com/miniflux/miniflux/config"
"github.com/miniflux/miniflux/http/context"
"github.com/miniflux/miniflux/http/request"
"github.com/miniflux/miniflux/http/response/json"
"github.com/miniflux/miniflux/integration"
"github.com/miniflux/miniflux/logger"
"github.com/miniflux/miniflux/model"
@@ -125,32 +129,33 @@ type favicon struct {
// Controller implements the Fever API.
type Controller struct {
cfg *config.Config
store *storage.Storage
}
// Handler handles Fever API calls
func (c *Controller) Handler(ctx *handler.Context, request *handler.Request, response *handler.Response) {
func (c *Controller) Handler(w http.ResponseWriter, r *http.Request) {
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.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)
case request.HasQueryParam(r, "groups"):
c.handleGroups(w, r)
case request.HasQueryParam(r, "feeds"):
c.handleFeeds(w, r)
case request.HasQueryParam(r, "favicons"):
c.handleFavicons(w, r)
case request.HasQueryParam(r, "unread_item_ids"):
c.handleUnreadItems(w, r)
case request.HasQueryParam(r, "saved_item_ids"):
c.handleSavedItems(w, r)
case request.HasQueryParam(r, "items"):
c.handleItems(w, r)
case r.FormValue("mark") == "item":
c.handleWriteItems(w, r)
case r.FormValue("mark") == "feed":
c.handleWriteFeeds(w, r)
case r.FormValue("mark") == "group":
c.handleWriteGroups(w, r)
default:
response.JSON().Standard(newBaseResponse())
json.OK(w, newBaseResponse())
}
}
@@ -174,19 +179,20 @@ The “Sparks” super group is not included in this response and is composed of
is_spark equal to 1.
*/
func (c *Controller) handleGroups(ctx *handler.Context, request *handler.Request, response *handler.Response) {
func (c *Controller) handleGroups(w http.ResponseWriter, r *http.Request) {
ctx := context.New(r)
userID := ctx.UserID()
logger.Debug("[Fever] Fetching groups for userID=%d", userID)
categories, err := c.store.Categories(userID)
if err != nil {
response.JSON().ServerError(err)
json.ServerError(w, err)
return
}
feeds, err := c.store.Feeds(userID)
if err != nil {
response.JSON().ServerError(err)
json.ServerError(w, err)
return
}
@@ -197,7 +203,7 @@ func (c *Controller) handleGroups(ctx *handler.Context, request *handler.Request
result.FeedsGroups = c.buildFeedGroups(feeds)
result.SetCommonValues()
response.JSON().Standard(result)
json.OK(w, result)
}
/*
@@ -224,13 +230,14 @@ 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 *handler.Context, request *handler.Request, response *handler.Response) {
func (c *Controller) handleFeeds(w http.ResponseWriter, r *http.Request) {
ctx := context.New(r)
userID := ctx.UserID()
logger.Debug("[Fever] Fetching feeds for userID=%d", userID)
feeds, err := c.store.Feeds(userID)
if err != nil {
response.JSON().ServerError(err)
json.ServerError(w, err)
return
}
@@ -255,7 +262,7 @@ func (c *Controller) handleFeeds(ctx *handler.Context, request *handler.Request,
result.FeedsGroups = c.buildFeedGroups(feeds)
result.SetCommonValues()
response.JSON().Standard(result)
json.OK(w, result)
}
/*
@@ -277,13 +284,14 @@ A PHP/HTML example:
echo '<img src="data:'.$favicon['data'].'">';
*/
func (c *Controller) handleFavicons(ctx *handler.Context, request *handler.Request, response *handler.Response) {
func (c *Controller) handleFavicons(w http.ResponseWriter, r *http.Request) {
ctx := context.New(r)
userID := ctx.UserID()
logger.Debug("[Fever] Fetching favicons for userID=%d", userID)
icons, err := c.store.Icons(userID)
if err != nil {
response.JSON().ServerError(err)
json.ServerError(w, err)
return
}
@@ -296,7 +304,7 @@ func (c *Controller) handleFavicons(ctx *handler.Context, request *handler.Reque
}
result.SetCommonValues()
response.JSON().Standard(result)
json.OK(w, result)
}
/*
@@ -330,9 +338,10 @@ Three optional arguments control determine the items included in the response.
(added in API version 2)
*/
func (c *Controller) handleItems(ctx *handler.Context, request *handler.Request, response *handler.Response) {
func (c *Controller) handleItems(w http.ResponseWriter, r *http.Request) {
var result itemsResponse
ctx := context.New(r)
userID := ctx.UserID()
logger.Debug("[Fever] Fetching items for userID=%d", userID)
@@ -342,17 +351,17 @@ func (c *Controller) handleItems(ctx *handler.Context, request *handler.Request,
builder.WithOrder("id")
builder.WithDirection(model.DefaultSortingDirection)
sinceID := request.QueryIntegerParam("since_id", 0)
sinceID := request.QueryIntParam(r, "since_id", 0)
if sinceID > 0 {
builder.WithGreaterThanEntryID(int64(sinceID))
}
maxID := request.QueryIntegerParam("max_id", 0)
maxID := request.QueryIntParam(r, "max_id", 0)
if maxID > 0 {
builder.WithOffset(maxID)
}
csvItemIDs := request.QueryStringParam("with_ids", "")
csvItemIDs := request.QueryParam(r, "with_ids", "")
if csvItemIDs != "" {
var itemIDs []int64
@@ -367,7 +376,7 @@ func (c *Controller) handleItems(ctx *handler.Context, request *handler.Request,
entries, err := builder.GetEntries()
if err != nil {
response.JSON().ServerError(err)
json.ServerError(w, err)
return
}
@@ -375,10 +384,11 @@ func (c *Controller) handleItems(ctx *handler.Context, request *handler.Request,
builder.WithoutStatus(model.EntryStatusRemoved)
result.Total, err = builder.CountEntries()
if err != nil {
response.JSON().ServerError(err)
json.ServerError(w, err)
return
}
result.Items = make([]item, 0)
for _, entry := range entries {
isRead := 0
if entry.Status == model.EntryStatusRead {
@@ -404,7 +414,7 @@ func (c *Controller) handleItems(ctx *handler.Context, request *handler.Request,
}
result.SetCommonValues()
response.JSON().Standard(result)
json.OK(w, result)
}
/*
@@ -414,7 +424,8 @@ 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 *handler.Context, request *handler.Request, response *handler.Response) {
func (c *Controller) handleUnreadItems(w http.ResponseWriter, r *http.Request) {
ctx := context.New(r)
userID := ctx.UserID()
logger.Debug("[Fever] Fetching unread items for userID=%d", userID)
@@ -422,7 +433,7 @@ func (c *Controller) handleUnreadItems(ctx *handler.Context, request *handler.Re
builder.WithStatus(model.EntryStatusUnread)
entries, err := builder.GetEntries()
if err != nil {
response.JSON().ServerError(err)
json.ServerError(w, err)
return
}
@@ -434,7 +445,7 @@ func (c *Controller) handleUnreadItems(ctx *handler.Context, request *handler.Re
var result unreadResponse
result.ItemIDs = strings.Join(itemIDs, ",")
result.SetCommonValues()
response.JSON().Standard(result)
json.OK(w, result)
}
/*
@@ -445,7 +456,8 @@ with the remote Fever installation.
saved_item_ids (string/comma-separated list of positive integers)
*/
func (c *Controller) handleSavedItems(ctx *handler.Context, request *handler.Request, response *handler.Response) {
func (c *Controller) handleSavedItems(w http.ResponseWriter, r *http.Request) {
ctx := context.New(r)
userID := ctx.UserID()
logger.Debug("[Fever] Fetching saved items for userID=%d", userID)
@@ -454,7 +466,7 @@ func (c *Controller) handleSavedItems(ctx *handler.Context, request *handler.Req
entryIDs, err := builder.GetEntryIDs()
if err != nil {
response.JSON().ServerError(err)
json.ServerError(w, err)
return
}
@@ -465,7 +477,7 @@ func (c *Controller) handleSavedItems(ctx *handler.Context, request *handler.Req
result := &savedResponse{ItemIDs: strings.Join(itemsIDs, ",")}
result.SetCommonValues()
response.JSON().Standard(result)
json.OK(w, result)
}
/*
@@ -473,11 +485,12 @@ func (c *Controller) handleSavedItems(ctx *handler.Context, request *handler.Req
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 *handler.Context, request *handler.Request, response *handler.Response) {
func (c *Controller) handleWriteItems(w http.ResponseWriter, r *http.Request) {
ctx := context.New(r)
userID := ctx.UserID()
logger.Debug("[Fever] Receiving mark=item call for userID=%d", userID)
entryID := request.FormIntegerValue("id")
entryID := request.FormIntValue(r, "id")
if entryID <= 0 {
return
}
@@ -488,7 +501,7 @@ func (c *Controller) handleWriteItems(ctx *handler.Context, request *handler.Req
entry, err := builder.GetEntry()
if err != nil {
response.JSON().ServerError(err)
json.ServerError(w, err)
return
}
@@ -496,29 +509,32 @@ func (c *Controller) handleWriteItems(ctx *handler.Context, request *handler.Req
return
}
switch request.FormValue("as") {
switch r.FormValue("as") {
case "read":
logger.Debug("[Fever] Mark entry #%d as read", entryID)
c.store.SetEntriesStatus(userID, []int64{entryID}, model.EntryStatusRead)
case "unread":
logger.Debug("[Fever] Mark entry #%d as unread", entryID)
c.store.SetEntriesStatus(userID, []int64{entryID}, model.EntryStatusUnread)
case "saved", "unsaved":
logger.Debug("[Fever] Mark entry #%d as saved/unsaved", entryID)
if err := c.store.ToggleBookmark(userID, entryID); err != nil {
response.JSON().ServerError(err)
json.ServerError(w, err)
return
}
settings, err := c.store.Integration(userID)
if err != nil {
response.JSON().ServerError(err)
json.ServerError(w, err)
return
}
go func() {
integration.SendEntry(entry, settings)
integration.SendEntry(c.cfg, entry, settings)
}()
}
response.JSON().Standard(newBaseResponse())
json.OK(w, newBaseResponse())
}
/*
@@ -527,11 +543,12 @@ func (c *Controller) handleWriteItems(ctx *handler.Context, request *handler.Req
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 clients most recent items API request
*/
func (c *Controller) handleWriteFeeds(ctx *handler.Context, request *handler.Request, response *handler.Response) {
func (c *Controller) handleWriteFeeds(w http.ResponseWriter, r *http.Request) {
ctx := context.New(r)
userID := ctx.UserID()
logger.Debug("[Fever] Receiving mark=feed call for userID=%d", userID)
feedID := request.FormIntegerValue("id")
feedID := request.FormIntValue(r, "id")
if feedID <= 0 {
return
}
@@ -540,7 +557,7 @@ func (c *Controller) handleWriteFeeds(ctx *handler.Context, request *handler.Req
builder.WithStatus(model.EntryStatusUnread)
builder.WithFeedID(feedID)
before := request.FormIntegerValue("before")
before := request.FormIntValue(r, "before")
if before > 0 {
t := time.Unix(before, 0)
builder.Before(&t)
@@ -548,17 +565,17 @@ func (c *Controller) handleWriteFeeds(ctx *handler.Context, request *handler.Req
entryIDs, err := builder.GetEntryIDs()
if err != nil {
response.JSON().ServerError(err)
json.ServerError(w, err)
return
}
err = c.store.SetEntriesStatus(userID, entryIDs, model.EntryStatusRead)
if err != nil {
response.JSON().ServerError(err)
json.ServerError(w, err)
return
}
response.JSON().Standard(newBaseResponse())
json.OK(w, newBaseResponse())
}
/*
@@ -567,11 +584,12 @@ func (c *Controller) handleWriteFeeds(ctx *handler.Context, request *handler.Req
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 clients most recent items API request
*/
func (c *Controller) handleWriteGroups(ctx *handler.Context, request *handler.Request, response *handler.Response) {
func (c *Controller) handleWriteGroups(w http.ResponseWriter, r *http.Request) {
ctx := context.New(r)
userID := ctx.UserID()
logger.Debug("[Fever] Receiving mark=group call for userID=%d", userID)
groupID := request.FormIntegerValue("id")
groupID := request.FormIntValue(r, "id")
if groupID < 0 {
return
}
@@ -580,7 +598,7 @@ func (c *Controller) handleWriteGroups(ctx *handler.Context, request *handler.Re
builder.WithStatus(model.EntryStatusUnread)
builder.WithCategoryID(groupID)
before := request.FormIntegerValue("before")
before := request.FormIntValue(r, "before")
if before > 0 {
t := time.Unix(before, 0)
builder.Before(&t)
@@ -588,17 +606,17 @@ func (c *Controller) handleWriteGroups(ctx *handler.Context, request *handler.Re
entryIDs, err := builder.GetEntryIDs()
if err != nil {
response.JSON().ServerError(err)
json.ServerError(w, err)
return
}
err = c.store.SetEntriesStatus(userID, entryIDs, model.EntryStatusRead)
if err != nil {
response.JSON().ServerError(err)
json.ServerError(w, err)
return
}
response.JSON().Standard(newBaseResponse())
json.OK(w, newBaseResponse())
}
/*
@@ -626,6 +644,6 @@ func (c *Controller) buildFeedGroups(feeds model.Feeds) []feedsGroups {
}
// NewController returns a new Fever API.
func NewController(store *storage.Storage) *Controller {
return &Controller{store: store}
func NewController(cfg *config.Config, store *storage.Storage) *Controller {
return &Controller{cfg, store}
}
+32 -20
View File
@@ -1,8 +1,8 @@
// Copyright 2017 Frédéric Guillot. All rights reserved.
// Copyright 2018 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 http
package client
import (
"bytes"
@@ -11,6 +11,7 @@ import (
"encoding/json"
"fmt"
"io"
"io/ioutil"
"net"
"net/http"
"net/url"
@@ -49,6 +50,26 @@ type Client struct {
Insecure bool
}
// WithCredentials defines the username/password for HTTP Basic authentication.
func (c *Client) WithCredentials(username, password string) *Client {
c.username = username
c.password = password
return c
}
// WithAuthorization defines authorization header value.
func (c *Client) WithAuthorization(authorization string) *Client {
c.authorizationHeader = authorization
return c
}
// WithCacheHeaders defines caching headers.
func (c *Client) WithCacheHeaders(etagHeader, lastModifiedHeader string) *Client {
c.etagHeader = etagHeader
c.lastModifiedHeader = lastModifiedHeader
return c
}
// Get execute a GET HTTP request.
func (c *Client) Get() (*Response, error) {
request, err := c.buildRequest(http.MethodGet, nil)
@@ -114,13 +135,19 @@ func (c *Client) executeRequest(request *http.Request) (*Response, error) {
return nil, err
}
defer resp.Body.Close()
if resp.ContentLength > maxBodySize {
return nil, fmt.Errorf("client: response too large (%d bytes)", resp.ContentLength)
}
buf, err := ioutil.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("client: error while reading body %v", err)
}
response := &Response{
Body: resp.Body,
Body: bytes.NewReader(buf),
StatusCode: resp.StatusCode,
EffectiveURL: resp.Request.URL.String(),
LastModified: resp.Header.Get("Last-Modified"),
@@ -197,22 +224,7 @@ func (c *Client) buildHeaders() http.Header {
return headers
}
// NewClient returns a new HTTP client.
func NewClient(url string) *Client {
// New returns a new HTTP client.
func New(url string) *Client {
return &Client{url: url, Insecure: false}
}
// NewClientWithCredentials returns a new HTTP client that requires authentication.
func NewClientWithCredentials(url, username, password string) *Client {
return &Client{url: url, Insecure: false, username: username, password: password}
}
// NewClientWithAuthorization returns a new client with a custom authorization header.
func NewClientWithAuthorization(url, authorization string) *Client {
return &Client{url: url, Insecure: false, authorizationHeader: authorization}
}
// NewClientWithCacheHeaders returns a new HTTP client that send cache headers.
func NewClientWithCacheHeaders(url, etagHeader, lastModifiedHeader string) *Client {
return &Client{url: url, etagHeader: etagHeader, lastModifiedHeader: lastModifiedHeader, Insecure: false}
}
+1 -1
View File
@@ -2,7 +2,7 @@
// Use of this source code is governed by the Apache 2.0
// license that can be found in the LICENSE file.
package http
package client
import (
"io"
@@ -2,7 +2,7 @@
// Use of this source code is governed by the Apache 2.0
// license that can be found in the LICENSE file.
package http
package client
import "testing"
+113
View File
@@ -0,0 +1,113 @@
// Copyright 2018 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 context
import (
"net/http"
"github.com/miniflux/miniflux/middleware"
)
// Context contains helper functions related to the current request.
type Context struct {
request *http.Request
}
// IsAdminUser checks if the logged user is administrator.
func (c *Context) IsAdminUser() bool {
return c.getContextBoolValue(middleware.IsAdminUserContextKey)
}
// IsAuthenticated returns a boolean if the user is authenticated.
func (c *Context) IsAuthenticated() bool {
return c.getContextBoolValue(middleware.IsAuthenticatedContextKey)
}
// UserID returns the UserID of the logged user.
func (c *Context) UserID() int64 {
return c.getContextIntValue(middleware.UserIDContextKey)
}
// UserTimezone returns the timezone used by the logged user.
func (c *Context) UserTimezone() string {
value := c.getContextStringValue(middleware.UserTimezoneContextKey)
if value == "" {
value = "UTC"
}
return value
}
// UserLanguage get the locale used by the current logged user.
func (c *Context) UserLanguage() string {
language := c.getContextStringValue(middleware.UserLanguageContextKey)
if language == "" {
language = "en_US"
}
return language
}
// CSRF returns the current CSRF token.
func (c *Context) CSRF() string {
return c.getContextStringValue(middleware.CSRFContextKey)
}
// SessionID returns the current session ID.
func (c *Context) SessionID() string {
return c.getContextStringValue(middleware.SessionIDContextKey)
}
// UserSessionToken returns the current user session token.
func (c *Context) UserSessionToken() string {
return c.getContextStringValue(middleware.UserSessionTokenContextKey)
}
// OAuth2State returns the current OAuth2 state.
func (c *Context) OAuth2State() string {
return c.getContextStringValue(middleware.OAuth2StateContextKey)
}
// FlashMessage returns the message message if any.
func (c *Context) FlashMessage() string {
return c.getContextStringValue(middleware.FlashMessageContextKey)
}
// FlashErrorMessage returns the message error message if any.
func (c *Context) FlashErrorMessage() string {
return c.getContextStringValue(middleware.FlashErrorMessageContextKey)
}
// PocketRequestToken returns the Pocket Request Token if any.
func (c *Context) PocketRequestToken() string {
return c.getContextStringValue(middleware.PocketRequestTokenContextKey)
}
func (c *Context) getContextStringValue(key *middleware.ContextKey) string {
if v := c.request.Context().Value(key); v != nil {
return v.(string)
}
return ""
}
func (c *Context) getContextBoolValue(key *middleware.ContextKey) bool {
if v := c.request.Context().Value(key); v != nil {
return v.(bool)
}
return false
}
func (c *Context) getContextIntValue(key *middleware.ContextKey) int64 {
if v := c.request.Context().Value(key); v != nil {
return v.(int64)
}
return 0
}
// New creates a new Context.
func New(r *http.Request) *Context {
return &Context{r}
}
-10
View File
@@ -1,10 +0,0 @@
// Copyright 2018 Frédéric Guillot. All rights reserved.
// Use of this source code is governed by the MIT license
// that can be found in the LICENSE file.
/*
Package http implements a set of utilities related to the HTTP protocol.
*/
package http
-163
View File
@@ -1,163 +0,0 @@
// 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 handler
import (
"net/http"
"github.com/miniflux/miniflux/crypto"
"github.com/miniflux/miniflux/http/middleware"
"github.com/miniflux/miniflux/http/route"
"github.com/miniflux/miniflux/locale"
"github.com/miniflux/miniflux/logger"
"github.com/miniflux/miniflux/model"
"github.com/miniflux/miniflux/storage"
"github.com/gorilla/mux"
)
// Context contains helper functions related to the current request.
type Context struct {
writer http.ResponseWriter
request *http.Request
store *storage.Storage
router *mux.Router
user *model.User
translator *locale.Translator
}
// IsAdminUser checks if the logged user is administrator.
func (c *Context) IsAdminUser() bool {
if v := c.request.Context().Value(middleware.IsAdminUserContextKey); v != nil {
return v.(bool)
}
return false
}
// UserTimezone returns the timezone used by the logged user.
func (c *Context) UserTimezone() string {
value := c.getContextStringValue(middleware.UserTimezoneContextKey)
if value == "" {
value = "UTC"
}
return value
}
// IsAuthenticated returns a boolean if the user is authenticated.
func (c *Context) IsAuthenticated() bool {
if v := c.request.Context().Value(middleware.IsAuthenticatedContextKey); v != nil {
return v.(bool)
}
return false
}
// UserID returns the UserID of the logged user.
func (c *Context) UserID() int64 {
if v := c.request.Context().Value(middleware.UserIDContextKey); v != nil {
return v.(int64)
}
return 0
}
// LoggedUser returns all properties related to the logged user.
func (c *Context) LoggedUser() *model.User {
if c.user == nil {
var err error
c.user, err = c.store.UserByID(c.UserID())
if err != nil {
logger.Fatal("[Context] %v", err)
}
if c.user == nil {
logger.Fatal("Unable to find user from context")
}
}
return c.user
}
// UserLanguage get the locale used by the current logged user.
func (c *Context) UserLanguage() string {
if c.IsAuthenticated() {
user := c.LoggedUser()
return user.Language
}
return c.getContextStringValue(middleware.UserLanguageContextKey)
}
// Translate translates a message in the current language.
func (c *Context) Translate(message string, args ...interface{}) string {
return c.translator.GetLanguage(c.UserLanguage()).Get(message, args...)
}
// CSRF returns the current CSRF token.
func (c *Context) CSRF() string {
return c.getContextStringValue(middleware.CSRFContextKey)
}
// SessionID returns the current session ID.
func (c *Context) SessionID() string {
return c.getContextStringValue(middleware.SessionIDContextKey)
}
// UserSessionToken returns the current user session token.
func (c *Context) UserSessionToken() string {
return c.getContextStringValue(middleware.UserSessionTokenContextKey)
}
// OAuth2State returns the current OAuth2 state.
func (c *Context) OAuth2State() string {
return c.getContextStringValue(middleware.OAuth2StateContextKey)
}
// GenerateOAuth2State generate a new OAuth2 state.
func (c *Context) GenerateOAuth2State() string {
state := crypto.GenerateRandomString(32)
c.store.UpdateSessionField(c.SessionID(), "oauth2_state", state)
return state
}
// SetFlashMessage defines a new flash message.
func (c *Context) SetFlashMessage(message string) {
c.store.UpdateSessionField(c.SessionID(), "flash_message", message)
}
// FlashMessage returns the flash message and remove it.
func (c *Context) FlashMessage() string {
message := c.getContextStringValue(middleware.FlashMessageContextKey)
c.store.UpdateSessionField(c.SessionID(), "flash_message", "")
return message
}
// SetFlashErrorMessage defines a new flash error message.
func (c *Context) SetFlashErrorMessage(message string) {
c.store.UpdateSessionField(c.SessionID(), "flash_error_message", message)
}
// FlashErrorMessage returns the error flash message and remove it.
func (c *Context) FlashErrorMessage() string {
message := c.getContextStringValue(middleware.FlashErrorMessageContextKey)
c.store.UpdateSessionField(c.SessionID(), "flash_error_message", "")
return message
}
func (c *Context) getContextStringValue(key *middleware.ContextKey) string {
if v := c.request.Context().Value(key); v != nil {
return v.(string)
}
return ""
}
// Route returns the path for the given arguments.
func (c *Context) Route(name string, args ...interface{}) string {
return route.Path(c.router, name, args...)
}
// NewContext creates a new Context.
func NewContext(r *http.Request, store *storage.Storage, router *mux.Router, translator *locale.Translator) *Context {
return &Context{request: r, store: store, router: router, translator: translator}
}
-71
View File
@@ -1,71 +0,0 @@
// 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 handler
import (
"net/http"
"time"
"github.com/miniflux/miniflux/config"
"github.com/miniflux/miniflux/http/middleware"
"github.com/miniflux/miniflux/locale"
"github.com/miniflux/miniflux/logger"
"github.com/miniflux/miniflux/storage"
"github.com/miniflux/miniflux/template"
"github.com/miniflux/miniflux/timer"
"github.com/gorilla/mux"
"github.com/tomasen/realip"
)
// ControllerFunc is an application HTTP handler.
type ControllerFunc func(ctx *Context, request *Request, response *Response)
// Handler manages HTTP handlers and middlewares.
type Handler struct {
cfg *config.Config
store *storage.Storage
translator *locale.Translator
template *template.Engine
router *mux.Router
middleware *middleware.Chain
}
// Use is a wrapper around an HTTP handler.
func (h *Handler) Use(f ControllerFunc) http.Handler {
return h.middleware.WrapFunc(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
defer timer.ExecutionTime(time.Now(), r.URL.Path)
logger.Debug("[HTTP] %s %s %s", realip.RealIP(r), r.Method, r.URL.Path)
if r.Header.Get("X-Forwarded-Proto") == "https" {
h.cfg.IsHTTPS = true
}
ctx := NewContext(r, h.store, h.router, h.translator)
request := NewRequest(r)
response := NewResponse(h.cfg, w, r, h.template)
language := ctx.UserLanguage()
if language != "" {
h.template.SetLanguage(language)
} else {
h.template.SetLanguage("en_US")
}
f(ctx, request, response)
}))
}
// NewHandler returns a new Handler.
func NewHandler(cfg *config.Config, store *storage.Storage, router *mux.Router, template *template.Engine, translator *locale.Translator, middleware *middleware.Chain) *Handler {
return &Handler{
cfg: cfg,
store: store,
translator: translator,
router: router,
template: template,
middleware: middleware,
}
}
-65
View File
@@ -1,65 +0,0 @@
// 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 handler
import (
"net/http"
"github.com/miniflux/miniflux/logger"
"github.com/miniflux/miniflux/template"
)
// HTMLResponse handles HTML responses.
type HTMLResponse struct {
writer http.ResponseWriter
request *http.Request
template *template.Engine
}
// Render execute a template and send to the client the generated HTML.
func (h *HTMLResponse) Render(template string, args map[string]interface{}) {
h.writer.Header().Set("Content-Type", "text/html; charset=utf-8")
h.template.Execute(h.writer, template, args)
}
// ServerError sends a 500 error to the browser.
func (h *HTMLResponse) ServerError(err error) {
h.writer.Header().Set("Content-Type", "text/html; charset=utf-8")
h.writer.WriteHeader(http.StatusInternalServerError)
if err != nil {
logger.Error("[Internal Server Error] %v", err)
h.writer.Write([]byte("Internal Server Error: " + err.Error()))
} else {
h.writer.Write([]byte("Internal Server Error"))
}
}
// BadRequest sends a 400 error to the browser.
func (h *HTMLResponse) BadRequest(err error) {
h.writer.Header().Set("Content-Type", "text/html; charset=utf-8")
h.writer.WriteHeader(http.StatusBadRequest)
if err != nil {
logger.Error("[Bad Request] %v", err)
h.writer.Write([]byte("Bad Request: " + err.Error()))
} else {
h.writer.Write([]byte("Bad Request"))
}
}
// NotFound sends a 404 error to the browser.
func (h *HTMLResponse) NotFound() {
h.writer.Header().Set("Content-Type", "text/html; charset=utf-8")
h.writer.WriteHeader(http.StatusNotFound)
h.writer.Write([]byte("Page Not Found"))
}
// Forbidden sends a 403 error to the browser.
func (h *HTMLResponse) Forbidden() {
h.writer.Header().Set("Content-Type", "text/html; charset=utf-8")
h.writer.WriteHeader(http.StatusForbidden)
h.writer.Write([]byte("Access Forbidden"))
}
-111
View File
@@ -1,111 +0,0 @@
// 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 handler
import (
"encoding/json"
"errors"
"net/http"
"github.com/miniflux/miniflux/logger"
)
// JSONResponse handles JSON responses.
type JSONResponse struct {
writer http.ResponseWriter
request *http.Request
}
// Standard sends a JSON response with the status code 200.
func (j *JSONResponse) Standard(v interface{}) {
j.commonHeaders()
j.writer.WriteHeader(http.StatusOK)
j.writer.Write(j.toJSON(v))
}
// Created sends a JSON response with the status code 201.
func (j *JSONResponse) Created(v interface{}) {
j.commonHeaders()
j.writer.WriteHeader(http.StatusCreated)
j.writer.Write(j.toJSON(v))
}
// NoContent sends a JSON response with the status code 204.
func (j *JSONResponse) NoContent() {
j.commonHeaders()
j.writer.WriteHeader(http.StatusNoContent)
}
// BadRequest sends a JSON response with the status code 400.
func (j *JSONResponse) BadRequest(err error) {
logger.Error("[Bad Request] %v", err)
j.commonHeaders()
j.writer.WriteHeader(http.StatusBadRequest)
if err != nil {
j.writer.Write(j.encodeError(err))
}
}
// NotFound sends a JSON response with the status code 404.
func (j *JSONResponse) NotFound(err error) {
logger.Error("[Not Found] %v", err)
j.commonHeaders()
j.writer.WriteHeader(http.StatusNotFound)
j.writer.Write(j.encodeError(err))
}
// ServerError sends a JSON response with the status code 500.
func (j *JSONResponse) ServerError(err error) {
logger.Error("[Internal Server Error] %v", err)
j.commonHeaders()
j.writer.WriteHeader(http.StatusInternalServerError)
if err != nil {
j.writer.Write(j.encodeError(err))
}
}
// Forbidden sends a JSON response with the status code 403.
func (j *JSONResponse) Forbidden() {
logger.Info("[API:Forbidden]")
j.commonHeaders()
j.writer.WriteHeader(http.StatusForbidden)
j.writer.Write(j.encodeError(errors.New("Access Forbidden")))
}
func (j *JSONResponse) commonHeaders() {
j.writer.Header().Set("Accept", "application/json")
j.writer.Header().Set("Content-Type", "application/json; charset=utf-8")
}
func (j *JSONResponse) encodeError(err error) []byte {
type errorMsg struct {
ErrorMessage string `json:"error_message"`
}
tmp := errorMsg{ErrorMessage: err.Error()}
data, err := json.Marshal(tmp)
if err != nil {
logger.Error("encoding error: %v", err)
}
return data
}
func (j *JSONResponse) toJSON(v interface{}) []byte {
b, err := json.Marshal(v)
if err != nil {
logger.Error("encoding error: %v", err)
return []byte("")
}
return b
}
// NewJSONResponse returns a new JSONResponse.
func NewJSONResponse(w http.ResponseWriter, r *http.Request) *JSONResponse {
return &JSONResponse{request: r, writer: w}
}
-124
View File
@@ -1,124 +0,0 @@
// 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 handler
import (
"fmt"
"io"
"mime/multipart"
"net/http"
"strconv"
"github.com/gorilla/mux"
"github.com/miniflux/miniflux/logger"
)
// Request is a thin wrapper around "http.Request".
type Request struct {
request *http.Request
}
// Request returns the raw Request struct.
func (r *Request) Request() *http.Request {
return r.request
}
// Body returns the request body.
func (r *Request) Body() io.ReadCloser {
return r.request.Body
}
// File returns uploaded file properties.
func (r *Request) File(name string) (multipart.File, *multipart.FileHeader, error) {
return r.request.FormFile(name)
}
// Cookie returns the cookie value.
func (r *Request) Cookie(name string) string {
cookie, err := r.request.Cookie(name)
if err == http.ErrNoCookie {
return ""
}
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)
value, err := strconv.Atoi(vars[param])
if err != nil {
logger.Error("[IntegerParam] %v", err)
return 0, fmt.Errorf("%s parameter is not an integer", param)
}
if value < 0 {
return 0, nil
}
return int64(value), nil
}
// StringParam returns an URL parameter as string.
func (r *Request) StringParam(param, defaultValue string) string {
vars := mux.Vars(r.request)
value := vars[param]
if value == "" {
value = defaultValue
}
return value
}
// QueryStringParam returns a querystring parameter as string.
func (r *Request) QueryStringParam(param, defaultValue string) string {
value := r.request.URL.Query().Get(param)
if value == "" {
value = defaultValue
}
return value
}
// QueryIntegerParam returns a querystring parameter as string.
func (r *Request) QueryIntegerParam(param string, defaultValue int) int {
value := r.request.URL.Query().Get(param)
if value == "" {
return defaultValue
}
val, err := strconv.Atoi(value)
if err != nil {
return defaultValue
}
if val < 0 {
return defaultValue
}
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.
func NewRequest(r *http.Request) *Request {
return &Request{r}
}
-88
View File
@@ -1,88 +0,0 @@
// 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 handler
import (
"net/http"
"time"
"github.com/miniflux/miniflux/config"
"github.com/miniflux/miniflux/template"
)
// Response handles HTTP responses.
type Response struct {
cfg *config.Config
writer http.ResponseWriter
request *http.Request
template *template.Engine
}
// SetCookie send a cookie to the client.
func (r *Response) SetCookie(cookie *http.Cookie) {
http.SetCookie(r.writer, cookie)
}
// JSON returns a JSONResponse.
func (r *Response) JSON() *JSONResponse {
r.commonHeaders()
return NewJSONResponse(r.writer, r.request)
}
// HTML returns a HTMLResponse.
func (r *Response) HTML() *HTMLResponse {
r.commonHeaders()
return &HTMLResponse{writer: r.writer, request: r.request, template: r.template}
}
// XML returns a XMLResponse.
func (r *Response) XML() *XMLResponse {
r.commonHeaders()
return &XMLResponse{writer: r.writer, request: r.request}
}
// Redirect redirects the user to another location.
func (r *Response) Redirect(path string) {
http.Redirect(r.writer, r.request, path, http.StatusFound)
}
// NotModified sends a response with a 304 status code.
func (r *Response) NotModified() {
r.commonHeaders()
r.writer.WriteHeader(http.StatusNotModified)
}
// Cache returns a response with caching headers.
func (r *Response) Cache(mimeType, etag string, content []byte, duration time.Duration) {
r.writer.Header().Set("Content-Type", mimeType)
r.writer.Header().Set("ETag", etag)
r.writer.Header().Set("Cache-Control", "public")
r.writer.Header().Set("Expires", time.Now().Add(duration).Format(time.RFC1123))
if etag == r.request.Header.Get("If-None-Match") {
r.writer.WriteHeader(http.StatusNotModified)
} else {
r.writer.Write(content)
}
}
func (r *Response) commonHeaders() {
r.writer.Header().Set("X-XSS-Protection", "1; mode=block")
r.writer.Header().Set("X-Content-Type-Options", "nosniff")
r.writer.Header().Set("X-Frame-Options", "DENY")
// Even if the directive "frame-src" has been deprecated in Firefox,
// we keep it to stay compatible with other browsers.
r.writer.Header().Set("Content-Security-Policy", "default-src 'self'; img-src *; media-src *; frame-src *; child-src *")
if r.cfg.IsHTTPS && r.cfg.HasHSTS() {
r.writer.Header().Set("Strict-Transport-Security", "max-age=31536000")
}
}
// NewResponse returns a new Response.
func NewResponse(cfg *config.Config, w http.ResponseWriter, r *http.Request, template *template.Engine) *Response {
return &Response{cfg: cfg, writer: w, request: r, template: template}
}
-29
View File
@@ -1,29 +0,0 @@
// 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 handler
import (
"fmt"
"net/http"
)
// XMLResponse handles XML responses.
type XMLResponse struct {
writer http.ResponseWriter
request *http.Request
}
// Download force the download of a XML document.
func (x *XMLResponse) Download(filename, data string) {
x.writer.Header().Set("Content-Type", "text/xml")
x.writer.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%s", filename))
x.writer.Write([]byte(data))
}
// Serve forces the XML to be sent to browser.
func (x *XMLResponse) Serve(data string) {
x.writer.Header().Set("Content-Type", "text/xml")
x.writer.Write([]byte(data))
}
-36
View File
@@ -1,36 +0,0 @@
// 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 (
"net/http"
)
// Middleware represents a HTTP middleware.
type Middleware func(http.Handler) http.Handler
// Chain handles a list of middlewares.
type Chain struct {
middlewares []Middleware
}
// Wrap adds a HTTP handler into the chain.
func (m *Chain) Wrap(h http.Handler) http.Handler {
for i := range m.middlewares {
h = m.middlewares[len(m.middlewares)-1-i](h)
}
return h
}
// WrapFunc adds a HTTP handler function into the chain.
func (m *Chain) WrapFunc(fn http.HandlerFunc) http.Handler {
return m.Wrap(fn)
}
// NewChain returns a new Chain.
func NewChain(middlewares ...Middleware) *Chain {
return &Chain{append(([]Middleware)(nil), middlewares...)}
}
-85
View File
@@ -1,85 +0,0 @@
// 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"
"net/http"
"github.com/miniflux/miniflux/config"
"github.com/miniflux/miniflux/http/cookie"
"github.com/miniflux/miniflux/logger"
"github.com/miniflux/miniflux/model"
"github.com/miniflux/miniflux/storage"
)
// SessionMiddleware represents a session middleware.
type SessionMiddleware struct {
cfg *config.Config
store *storage.Storage
}
// Handler execute the middleware.
func (s *SessionMiddleware) Handler(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var err error
session := s.getSessionValueFromCookie(r)
if session == nil {
logger.Debug("[Middleware:Session] Session not found")
session, err = s.store.CreateSession()
if err != nil {
logger.Error("[Middleware:Session] %v", err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
http.SetCookie(w, cookie.New(cookie.CookieSessionID, session.ID, s.cfg.IsHTTPS, s.cfg.BasePath()))
} else {
logger.Debug("[Middleware:Session] %s", session)
}
if r.Method == "POST" {
formValue := r.FormValue("csrf")
headerValue := r.Header.Get("X-Csrf-Token")
if session.Data.CSRF != formValue && session.Data.CSRF != headerValue {
logger.Error(`[Middleware:Session] Invalid or missing CSRF token: Form="%s", Header="%s"`, formValue, headerValue)
w.WriteHeader(http.StatusBadRequest)
w.Write([]byte("Invalid or missing CSRF session!"))
return
}
}
ctx := r.Context()
ctx = context.WithValue(ctx, SessionIDContextKey, session.ID)
ctx = context.WithValue(ctx, CSRFContextKey, session.Data.CSRF)
ctx = context.WithValue(ctx, OAuth2StateContextKey, session.Data.OAuth2State)
ctx = context.WithValue(ctx, FlashMessageContextKey, session.Data.FlashMessage)
ctx = context.WithValue(ctx, FlashErrorMessageContextKey, session.Data.FlashErrorMessage)
ctx = context.WithValue(ctx, UserLanguageContextKey, session.Data.Language)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
func (s *SessionMiddleware) getSessionValueFromCookie(r *http.Request) *model.Session {
sessionCookie, err := r.Cookie(cookie.CookieSessionID)
if err == http.ErrNoCookie {
return nil
}
session, err := s.store.Session(sessionCookie.Value)
if err != nil {
logger.Error("[Middleware:Session] %v", err)
return nil
}
return session
}
// NewSessionMiddleware returns a new SessionMiddleware.
func NewSessionMiddleware(cfg *config.Config, store *storage.Storage) *SessionMiddleware {
return &SessionMiddleware{cfg, store}
}
+119
View File
@@ -0,0 +1,119 @@
// Copyright 2018 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 request
import (
"fmt"
"net"
"net/http"
"strconv"
"strings"
"github.com/gorilla/mux"
)
// Cookie returns the cookie value.
func Cookie(r *http.Request, name string) string {
cookie, err := r.Cookie(name)
if err == http.ErrNoCookie {
return ""
}
return cookie.Value
}
// FormIntValue returns a form value as integer.
func FormIntValue(r *http.Request, param string) int64 {
value := r.FormValue(param)
integer, _ := strconv.Atoi(value)
return int64(integer)
}
// IntParam returns an URL route parameter as integer.
func IntParam(r *http.Request, param string) (int64, error) {
vars := mux.Vars(r)
value, err := strconv.Atoi(vars[param])
if err != nil {
return 0, fmt.Errorf("request: %s parameter is not an integer", param)
}
if value < 0 {
return 0, nil
}
return int64(value), nil
}
// Param returns an URL route parameter as string.
func Param(r *http.Request, param, defaultValue string) string {
vars := mux.Vars(r)
value := vars[param]
if value == "" {
value = defaultValue
}
return value
}
// QueryParam returns a querystring parameter as string.
func QueryParam(r *http.Request, param, defaultValue string) string {
value := r.URL.Query().Get(param)
if value == "" {
value = defaultValue
}
return value
}
// QueryIntParam returns a querystring parameter as integer.
func QueryIntParam(r *http.Request, param string, defaultValue int) int {
value := r.URL.Query().Get(param)
if value == "" {
return defaultValue
}
val, err := strconv.Atoi(value)
if err != nil {
return defaultValue
}
if val < 0 {
return defaultValue
}
return val
}
// HasQueryParam checks if the query string contains the given parameter.
func HasQueryParam(r *http.Request, param string) bool {
values := r.URL.Query()
_, ok := values[param]
return ok
}
// RealIP returns client's real IP address.
func RealIP(r *http.Request) string {
headers := []string{"X-Forwarded-For", "X-Real-Ip"}
for _, header := range headers {
value := r.Header.Get(header)
if value != "" {
addresses := strings.Split(value, ",")
address := strings.TrimSpace(addresses[0])
if net.ParseIP(address) != nil {
return address
}
}
}
// Fallback to TCP/IP source IP address.
var remoteIP string
if strings.ContainsRune(r.RemoteAddr, ':') {
remoteIP, _, _ = net.SplitHostPort(r.RemoteAddr)
} else {
remoteIP = r.RemoteAddr
}
return remoteIP
}
+82
View File
@@ -0,0 +1,82 @@
// Copyright 2018 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 request
import (
"net/http"
"testing"
)
func TestRealIPWithoutHeaders(t *testing.T) {
r := &http.Request{RemoteAddr: "192.168.0.1:4242"}
if ip := RealIP(r); ip != "192.168.0.1" {
t.Fatalf(`Unexpected result, got: %q`, ip)
}
r = &http.Request{RemoteAddr: "192.168.0.1"}
if ip := RealIP(r); ip != "192.168.0.1" {
t.Fatalf(`Unexpected result, got: %q`, ip)
}
}
func TestRealIPWithXFFHeader(t *testing.T) {
// Test with multiple IPv4 addresses.
headers := http.Header{}
headers.Set("X-Forwarded-For", "203.0.113.195, 70.41.3.18, 150.172.238.178")
r := &http.Request{RemoteAddr: "192.168.0.1:4242", Header: headers}
if ip := RealIP(r); ip != "203.0.113.195" {
t.Fatalf(`Unexpected result, got: %q`, ip)
}
// Test with single IPv6 address.
headers = http.Header{}
headers.Set("X-Forwarded-For", "2001:db8:85a3:8d3:1319:8a2e:370:7348")
r = &http.Request{RemoteAddr: "192.168.0.1:4242", Header: headers}
if ip := RealIP(r); ip != "2001:db8:85a3:8d3:1319:8a2e:370:7348" {
t.Fatalf(`Unexpected result, got: %q`, ip)
}
// Test with single IPv4 address.
headers = http.Header{}
headers.Set("X-Forwarded-For", "70.41.3.18")
r = &http.Request{RemoteAddr: "192.168.0.1:4242", Header: headers}
if ip := RealIP(r); ip != "70.41.3.18" {
t.Fatalf(`Unexpected result, got: %q`, ip)
}
// Test with invalid IP address.
headers = http.Header{}
headers.Set("X-Forwarded-For", "fake IP")
r = &http.Request{RemoteAddr: "192.168.0.1:4242", Header: headers}
if ip := RealIP(r); ip != "192.168.0.1" {
t.Fatalf(`Unexpected result, got: %q`, ip)
}
}
func TestRealIPWithXRealIPHeader(t *testing.T) {
headers := http.Header{}
headers.Set("X-Real-Ip", "192.168.122.1")
r := &http.Request{RemoteAddr: "192.168.0.1:4242", Header: headers}
if ip := RealIP(r); ip != "192.168.122.1" {
t.Fatalf(`Unexpected result, got: %q`, ip)
}
}
func TestRealIPWithBothHeaders(t *testing.T) {
headers := http.Header{}
headers.Set("X-Forwarded-For", "203.0.113.195, 70.41.3.18, 150.172.238.178")
headers.Set("X-Real-Ip", "192.168.122.1")
r := &http.Request{RemoteAddr: "192.168.0.1:4242", Header: headers}
if ip := RealIP(r); ip != "203.0.113.195" {
t.Fatalf(`Unexpected result, got: %q`, ip)
}
}
+57
View File
@@ -0,0 +1,57 @@
// Copyright 2018 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 html
import (
"net/http"
"github.com/miniflux/miniflux/logger"
)
// OK writes a standard HTML response.
func OK(w http.ResponseWriter, b []byte) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Write(b)
}
// ServerError sends a 500 error to the browser.
func ServerError(w http.ResponseWriter, err error) {
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
w.WriteHeader(http.StatusInternalServerError)
if err != nil {
logger.Error("[Internal Server Error] %v", err)
w.Write([]byte("Internal Server Error: " + err.Error()))
} else {
w.Write([]byte("Internal Server Error"))
}
}
// BadRequest sends a 400 error to the browser.
func BadRequest(w http.ResponseWriter, err error) {
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
w.WriteHeader(http.StatusBadRequest)
if err != nil {
logger.Error("[Bad Request] %v", err)
w.Write([]byte("Bad Request: " + err.Error()))
} else {
w.Write([]byte("Bad Request"))
}
}
// NotFound sends a 404 error to the browser.
func NotFound(w http.ResponseWriter) {
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
w.WriteHeader(http.StatusNotFound)
w.Write([]byte("Page Not Found"))
}
// Forbidden sends a 403 error to the browser.
func Forbidden(w http.ResponseWriter) {
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
w.WriteHeader(http.StatusForbidden)
w.Write([]byte("Access Forbidden"))
}
+107
View File
@@ -0,0 +1,107 @@
// Copyright 2018 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 json
import (
"encoding/json"
"errors"
"net/http"
"github.com/miniflux/miniflux/logger"
)
// OK sends a JSON response with the status code 200.
func OK(w http.ResponseWriter, v interface{}) {
commonHeaders(w)
w.WriteHeader(http.StatusOK)
w.Write(toJSON(v))
}
// Created sends a JSON response with the status code 201.
func Created(w http.ResponseWriter, v interface{}) {
commonHeaders(w)
w.WriteHeader(http.StatusCreated)
w.Write(toJSON(v))
}
// NoContent sends a JSON response with the status code 204.
func NoContent(w http.ResponseWriter) {
commonHeaders(w)
w.WriteHeader(http.StatusNoContent)
}
// NotFound sends a JSON response with the status code 404.
func NotFound(w http.ResponseWriter, err error) {
logger.Error("[Not Found] %v", err)
commonHeaders(w)
w.WriteHeader(http.StatusNotFound)
w.Write(encodeError(err))
}
// ServerError sends a JSON response with the status code 500.
func ServerError(w http.ResponseWriter, err error) {
logger.Error("[Internal Server Error] %v", err)
commonHeaders(w)
w.WriteHeader(http.StatusInternalServerError)
if err != nil {
w.Write(encodeError(err))
}
}
// Forbidden sends a JSON response with the status code 403.
func Forbidden(w http.ResponseWriter) {
logger.Info("[Forbidden]")
commonHeaders(w)
w.WriteHeader(http.StatusForbidden)
w.Write(encodeError(errors.New("Access Forbidden")))
}
// Unauthorized sends a JSON response with the status code 401.
func Unauthorized(w http.ResponseWriter) {
commonHeaders(w)
w.WriteHeader(http.StatusUnauthorized)
w.Write(encodeError(errors.New("Access Unauthorized")))
}
// BadRequest sends a JSON response with the status code 400.
func BadRequest(w http.ResponseWriter, err error) {
logger.Error("[Bad Request] %v", err)
commonHeaders(w)
w.WriteHeader(http.StatusBadRequest)
if err != nil {
w.Write(encodeError(err))
}
}
func commonHeaders(w http.ResponseWriter) {
w.Header().Set("Accept", "application/json")
w.Header().Set("Content-Type", "application/json; charset=utf-8")
}
func encodeError(err error) []byte {
type errorMsg struct {
ErrorMessage string `json:"error_message"`
}
tmp := errorMsg{ErrorMessage: err.Error()}
data, err := json.Marshal(tmp)
if err != nil {
logger.Error("json encoding error: %v", err)
}
return data
}
func toJSON(v interface{}) []byte {
b, err := json.Marshal(v)
if err != nil {
logger.Error("json encoding error: %v", err)
return []byte("")
}
return b
}
+34
View File
@@ -0,0 +1,34 @@
// Copyright 2018 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 response
import (
"net/http"
"time"
)
// Redirect redirects the user to another location.
func Redirect(w http.ResponseWriter, r *http.Request, path string) {
http.Redirect(w, r, path, http.StatusFound)
}
// NotModified sends a response with a 304 status code.
func NotModified(w http.ResponseWriter) {
w.WriteHeader(http.StatusNotModified)
}
// Cache returns a response with caching headers.
func Cache(w http.ResponseWriter, r *http.Request, mimeType, etag string, content []byte, duration time.Duration) {
w.Header().Set("Content-Type", mimeType)
w.Header().Set("ETag", etag)
w.Header().Set("Cache-Control", "public")
w.Header().Set("Expires", time.Now().Add(duration).Format(time.RFC1123))
if etag == r.Header.Get("If-None-Match") {
w.WriteHeader(http.StatusNotModified)
} else {
w.Write(content)
}
}
+23
View File
@@ -0,0 +1,23 @@
// Copyright 2018 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 xml
import (
"fmt"
"net/http"
)
// OK sends a XML document.
func OK(w http.ResponseWriter, data string) {
w.Header().Set("Content-Type", "text/xml")
w.Write([]byte(data))
}
// Attachment forces the download of a XML document.
func Attachment(w http.ResponseWriter, filename, data string) {
w.Header().Set("Content-Type", "text/xml")
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%s", filename))
w.Write([]byte(data))
}
+13 -4
View File
@@ -8,7 +8,7 @@ import (
"fmt"
"net/url"
"github.com/miniflux/miniflux/http"
"github.com/miniflux/miniflux/http/client"
)
// Client represents an Instapaper client.
@@ -19,18 +19,27 @@ type Client struct {
// AddURL sends a link to Instapaper.
func (c *Client) AddURL(link, title string) error {
if c.username == "" || c.password == "" {
return fmt.Errorf("instapaper: missing credentials")
}
values := url.Values{}
values.Add("url", link)
values.Add("title", title)
apiURL := "https://www.instapaper.com/api/add?" + values.Encode()
client := http.NewClientWithCredentials(apiURL, c.username, c.password)
response, err := client.Get()
clt := client.New(apiURL)
clt.WithCredentials(c.username, c.password)
response, err := clt.Get()
if err != nil {
return fmt.Errorf("instapaper: unable to send url: %v", err)
}
if response.HasServerFailure() {
return fmt.Errorf("instapaper: unable to send url, status=%d", response.StatusCode)
}
return err
return nil
}
// NewClient returns a new Instapaper client.
+14 -5
View File
@@ -5,16 +5,18 @@
package integration
import (
"github.com/miniflux/miniflux/config"
"github.com/miniflux/miniflux/integration/instapaper"
"github.com/miniflux/miniflux/integration/nunuxkeeper"
"github.com/miniflux/miniflux/integration/pinboard"
"github.com/miniflux/miniflux/integration/pocket"
"github.com/miniflux/miniflux/integration/wallabag"
"github.com/miniflux/miniflux/logger"
"github.com/miniflux/miniflux/model"
)
// SendEntry send the entry to the activated providers.
func SendEntry(entry *model.Entry, integration *model.Integration) {
func SendEntry(cfg *config.Config, entry *model.Entry, integration *model.Integration) {
if integration.PinboardEnabled {
client := pinboard.NewClient(integration.PinboardToken)
err := client.AddBookmark(
@@ -25,14 +27,14 @@ func SendEntry(entry *model.Entry, integration *model.Integration) {
)
if err != nil {
logger.Error("[Integration] %v", err)
logger.Error("[Integration] UserID #%d: %v", integration.UserID, err)
}
}
if integration.InstapaperEnabled {
client := instapaper.NewClient(integration.InstapaperUsername, integration.InstapaperPassword)
if err := client.AddURL(entry.URL, entry.Title); err != nil {
logger.Error("[Integration] %v", err)
logger.Error("[Integration] UserID #%d: %v", integration.UserID, err)
}
}
@@ -46,7 +48,7 @@ func SendEntry(entry *model.Entry, integration *model.Integration) {
)
if err := client.AddEntry(entry.URL, entry.Title); err != nil {
logger.Error("[Integration] %v", err)
logger.Error("[Integration] UserID #%d: %v", integration.UserID, err)
}
}
@@ -57,7 +59,14 @@ func SendEntry(entry *model.Entry, integration *model.Integration) {
)
if err := client.AddEntry(entry.URL, entry.Title, entry.Content); err != nil {
logger.Error("[Integration] %v", err)
logger.Error("[Integration] UserID #%d: %v", integration.UserID, err)
}
}
if integration.PocketEnabled {
client := pocket.NewClient(cfg.PocketConsumerKey(integration.PocketConsumerKey), integration.PocketAccessToken)
if err := client.AddURL(entry.URL, entry.Title); err != nil {
logger.Error("[Integration] UserID #%d: %v", integration.UserID, err)
}
}
}
+14 -4
View File
@@ -9,7 +9,7 @@ import (
"net/url"
"path"
"github.com/miniflux/miniflux/http"
"github.com/miniflux/miniflux/http/client"
)
// Document structure of a Nununx Keeper document
@@ -28,6 +28,10 @@ type Client struct {
// AddEntry sends an entry to Nunux Keeper.
func (c *Client) AddEntry(link, title, content string) error {
if c.baseURL == "" || c.apiKey == "" {
return fmt.Errorf("nunux-keeper: missing credentials")
}
doc := &Document{
Title: title,
Origin: link,
@@ -39,13 +43,19 @@ func (c *Client) AddEntry(link, title, content string) error {
if err != nil {
return err
}
client := http.NewClientWithCredentials(apiURL, "api", c.apiKey)
response, err := client.PostJSON(doc)
clt := client.New(apiURL)
clt.WithCredentials("api", c.apiKey)
response, err := clt.PostJSON(doc)
if err != nil {
return fmt.Errorf("nunux-keeper: unable to send entry: %v", err)
}
if response.HasServerFailure() {
return fmt.Errorf("nunux-keeper: unable to send entry, status=%d", response.StatusCode)
}
return err
return nil
}
// NewClient returns a new Nunux Keeepr client.
+12 -4
View File
@@ -8,7 +8,7 @@ import (
"fmt"
"net/url"
"github.com/miniflux/miniflux/http"
"github.com/miniflux/miniflux/http/client"
)
// Client represents a Pinboard client.
@@ -18,6 +18,10 @@ type Client struct {
// AddBookmark sends a link to Pinboard.
func (c *Client) AddBookmark(link, title, tags string, markAsUnread bool) error {
if c.authToken == "" {
return fmt.Errorf("pinboard: missing credentials")
}
toRead := "no"
if markAsUnread {
toRead = "yes"
@@ -30,13 +34,17 @@ func (c *Client) AddBookmark(link, title, tags string, markAsUnread bool) error
values.Add("tags", tags)
values.Add("toread", toRead)
client := http.NewClient("https://api.pinboard.in/v1/posts/add?" + values.Encode())
response, err := client.Get()
clt := client.New("https://api.pinboard.in/v1/posts/add?" + values.Encode())
response, err := clt.Get()
if err != nil {
return fmt.Errorf("pinboard: unable to send bookmark: %v", err)
}
if response.HasServerFailure() {
return fmt.Errorf("pinboard: unable to send bookmark, status=%d", response.StatusCode)
}
return err
return nil
}
// NewClient returns a new Pinboard client.
+103
View File
@@ -0,0 +1,103 @@
// Copyright 2018 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 pocket
import (
"errors"
"fmt"
"io/ioutil"
"net/url"
"github.com/miniflux/miniflux/http/client"
)
// Connector manages the authorization flow with Pocket to get a personal access token.
type Connector struct {
consumerKey string
}
// RequestToken fetches a new request token from Pocket API.
func (c *Connector) RequestToken(redirectURL string) (string, error) {
type req struct {
ConsumerKey string `json:"consumer_key"`
RedirectURI string `json:"redirect_uri"`
}
clt := client.New("https://getpocket.com/v3/oauth/request")
response, err := clt.PostJSON(&req{ConsumerKey: c.consumerKey, RedirectURI: redirectURL})
if err != nil {
return "", fmt.Errorf("pocket: unable to fetch request token: %v", err)
}
if response.HasServerFailure() {
return "", fmt.Errorf("pocket: unable to fetch request token, status=%d", response.StatusCode)
}
body, err := ioutil.ReadAll(response.Body)
if err != nil {
return "", fmt.Errorf("pocket: unable to read response body: %v", err)
}
values, err := url.ParseQuery(string(body))
if err != nil {
return "", fmt.Errorf("pocket: unable to parse response: %v", err)
}
code := values.Get("code")
if code == "" {
return "", errors.New("pocket: code is empty")
}
return code, nil
}
// AccessToken fetches a new access token once the end-user authorized the application.
func (c *Connector) AccessToken(requestToken string) (string, error) {
type req struct {
ConsumerKey string `json:"consumer_key"`
Code string `json:"code"`
}
clt := client.New("https://getpocket.com/v3/oauth/authorize")
response, err := clt.PostJSON(&req{ConsumerKey: c.consumerKey, Code: requestToken})
if err != nil {
return "", fmt.Errorf("pocket: unable to fetch access token: %v", err)
}
if response.HasServerFailure() {
return "", fmt.Errorf("pocket: unable to fetch access token, status=%d", response.StatusCode)
}
body, err := ioutil.ReadAll(response.Body)
if err != nil {
return "", fmt.Errorf("pocket: unable to read response body: %v", err)
}
values, err := url.ParseQuery(string(body))
if err != nil {
return "", fmt.Errorf("pocket: unable to parse response: %v", err)
}
token := values.Get("access_token")
if token == "" {
return "", errors.New("pocket: access_token is empty")
}
return token, nil
}
// AuthorizationURL returns the authorization URL for the end-user.
func (c *Connector) AuthorizationURL(requestToken, redirectURL string) string {
return fmt.Sprintf(
"https://getpocket.com/auth/authorize?request_token=%s&redirect_uri=%s",
requestToken,
redirectURL,
)
}
// NewConnector returns a new Pocket Connector.
func NewConnector(consumerKey string) *Connector {
return &Connector{consumerKey}
}
+55
View File
@@ -0,0 +1,55 @@
// Copyright 2018 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 pocket
import (
"fmt"
"github.com/miniflux/miniflux/http/client"
)
// Client represents a Pocket client.
type Client struct {
consumerKey string
accessToken string
}
// AddURL sends a single link to Pocket.
func (c *Client) AddURL(link, title string) error {
if c.consumerKey == "" || c.accessToken == "" {
return fmt.Errorf("pocket: missing credentials")
}
type body struct {
AccessToken string `json:"access_token"`
ConsumerKey string `json:"consumer_key"`
Title string `json:"title,omitempty"`
URL string `json:"url"`
}
data := &body{
AccessToken: c.accessToken,
ConsumerKey: c.consumerKey,
Title: title,
URL: link,
}
clt := client.New("https://getpocket.com/v3/add")
response, err := clt.PostJSON(data)
if err != nil {
return fmt.Errorf("pocket: unable to send url: %v", err)
}
if response.HasServerFailure() {
return fmt.Errorf("pocket: unable to send url, status=%d", response.StatusCode)
}
return nil
}
// NewClient returns a new Pocket client.
func NewClient(consumerKey, accessToken string) *Client {
return &Client{consumerKey, accessToken}
}
+10 -5
View File
@@ -10,7 +10,7 @@ import (
"io"
"net/url"
"github.com/miniflux/miniflux/http"
"github.com/miniflux/miniflux/http/client"
)
// Client represents a Wallabag client.
@@ -24,6 +24,10 @@ type Client struct {
// AddEntry sends a link to Wallabag.
func (c *Client) AddEntry(link, title string) error {
if c.baseURL == "" || c.clientID == "" || c.clientSecret == "" || c.username == "" || c.password == "" {
return fmt.Errorf("wallabag: missing credentials")
}
accessToken, err := c.getAccessToken()
if err != nil {
return err
@@ -38,8 +42,9 @@ func (c *Client) createEntry(accessToken, link, title string) error {
return fmt.Errorf("wallbag: unable to get entries endpoint: %v", err)
}
client := http.NewClientWithAuthorization(endpoint, "Bearer "+accessToken)
response, err := client.PostJSON(map[string]string{"url": link, "title": title})
clt := client.New(endpoint)
clt.WithAuthorization("Bearer " + accessToken)
response, err := clt.PostJSON(map[string]string{"url": link, "title": title})
if err != nil {
return fmt.Errorf("wallabag: unable to post entry: %v", err)
}
@@ -64,8 +69,8 @@ func (c *Client) getAccessToken() (string, error) {
return "", fmt.Errorf("wallbag: unable to get token endpoint: %v", err)
}
client := http.NewClient(endpoint)
response, err := client.PostForm(values)
clt := client.New(endpoint)
response, err := clt.PostForm(values)
if err != nil {
return "", fmt.Errorf("wallabag: unable to get access token: %v", err)
}
+44
View File
@@ -7,6 +7,8 @@
package main
import (
"bytes"
"io/ioutil"
"math/rand"
"strconv"
"strings"
@@ -42,6 +44,22 @@ func TestWithWrongCredentials(t *testing.T) {
}
}
func TestGetCurrentLoggedUser(t *testing.T) {
client := miniflux.NewClient(testBaseURL, testAdminUsername, testAdminPassword)
user, err := client.Me()
if err != nil {
t.Fatal(err)
}
if user.ID == 0 {
t.Fatalf(`Invalid userID, got %q`, user.ID)
}
if user.Username != testAdminUsername {
t.Fatalf(`Invalid username, got %q`, user.Username)
}
}
func TestGetUsers(t *testing.T) {
client := miniflux.NewClient(testBaseURL, testAdminUsername, testAdminPassword)
users, err := client.Users()
@@ -653,6 +671,32 @@ func TestExport(t *testing.T) {
}
}
func TestImport(t *testing.T) {
username := getRandomUsername()
client := miniflux.NewClient(testBaseURL, testAdminUsername, testAdminPassword)
_, err := client.CreateUser(username, testStandardPassword, false)
if err != nil {
t.Fatal(err)
}
client = miniflux.NewClient(testBaseURL, username, testStandardPassword)
data := `<?xml version="1.0" encoding="UTF-8"?>
<opml version="2.0">
<body>
<outline text="Test Category">
<outline title="Test" text="Test" xmlUrl="` + testFeedURL + `" htmlUrl="` + testWebsiteURL + `"></outline>
</outline>
</body>
</opml>`
b := bytes.NewReader([]byte(data))
err = client.Import(ioutil.NopCloser(b))
if err != nil {
t.Fatal(err)
}
}
func TestUpdateFeed(t *testing.T) {
username := getRandomUsername()
client := miniflux.NewClient(testBaseURL, testAdminUsername, testAdminPassword)
+22 -12
View File
@@ -1,5 +1,5 @@
// Code generated by go generate; DO NOT EDIT.
// 2018-04-09 20:38:50.319066697 -0700 PDT m=+0.026775461
// 2018-05-20 15:22:33.795349513 -0700 PDT m=+0.026820026
package locale
@@ -161,7 +161,7 @@ var translations = map[string]string{
"Scraper Rules": "Extraktionsregeln",
"Rewrite Rules": "Umschreiberegeln",
"Preferences saved!": "Einstellungen gespeichert!",
"Your external account is now linked !": "Ihr externes Konto wurde verlinkt!",
"Your external account is now linked!": "Ihr externes Konto wurde verlinkt!",
"Save articles to Wallabag": "Artikel in Wallabag speichern",
"Wallabag API Endpoint": "Wallabag URL",
"Wallabag Client ID": "Wallabag Client-ID",
@@ -395,7 +395,7 @@ var translations = map[string]string{
"Scraper Rules": "Règles pour récupérer le contenu original",
"Rewrite Rules": "Règles de réécriture",
"Preferences saved!": "Préférences sauvegardées !",
"Your external account is now linked !": "Votre compte externe est maintenant associé !",
"Your external account is now linked!": "Votre compte externe est maintenant associé !",
"Save articles to Wallabag": "Sauvegarder les articles vers Wallabag",
"Wallabag API Endpoint": "URL de l'API de Wallabag",
"Wallabag Client ID": "Identifiant du client Wallabag",
@@ -459,7 +459,17 @@ var translations = map[string]string{
"This website is permanently unreachable (original error: %q)": "Ce site web n'est pas joignable de façon permanente (erreur originale : %q)",
"Website unreachable, the request timed out after %d seconds": "Site web injoignable, la requête à échouée après %d secondes",
"Comments": "Commentaires",
"View Comments": "Voir les commentaires"
"View Comments": "Voir les commentaires",
"This file is empty": "Ce fichier est vide",
"Your external account is now dissociated!": "Votre compte externe est maintenant dissocié !",
"You must define a password otherwise you won't be able to login again.": "Vous devez définir un mot de passe sinon vous ne pourrez plus vous connecter par la suite.",
"Save articles to Pocket": "Sauvegarder les articles vers Pocket",
"Connect your Pocket account": "Connectez votre compte Pocket",
"Pocket Consumer Key": "« Pocket Consumer Key »",
"Pocket Access Token": "« Pocket Access Token »",
"Your Pocket account is now linked!": "Votre compte Pocket est maintenant connecté !",
"Unable to fetch access token from Pocket!": "Impossible de récupérer le jeton d'accès depuis Pocket !",
"Unable to fetch request token from Pocket!": "Impossible de récupérer le jeton d'accès depuis Pocket !"
}
`,
"nl_NL": `{
@@ -618,7 +628,7 @@ var translations = map[string]string{
"Scraper Rules": "Scraper regels",
"Rewrite Rules": "Rewrite regels",
"Preferences saved!": "Instellingen opgeslagen!",
"Your external account is now linked !": "Jouw externe account is nu gekoppeld!",
"Your external account is now linked!": "Jouw externe account is nu gekoppeld!",
"Save articles to Wallabag": "Sauvegarder les articles vers Wallabag",
"Wallabag API Endpoint": "Wallabag URL",
"Wallabag Client ID": "Wallabag Client-ID",
@@ -842,7 +852,7 @@ var translations = map[string]string{
"Scraper Rules": "Zasady ekstrakcji",
"Rewrite Rules": "Reguły zapisu",
"Preferences saved!": "Ustawienia zapisane!",
"Your external account is now linked !": "Twoje zewnętrzne konto jest teraz połączone!",
"Your external account is now linked!": "Twoje zewnętrzne konto jest teraz połączone!",
"Save articles to Wallabag": "Zapisz artykuły do Wallabag",
"Wallabag API Endpoint": "Wallabag URL",
"Wallabag Client ID": "Wallabag Client-ID",
@@ -1064,7 +1074,7 @@ var translations = map[string]string{
"Scraper Rules": "Scraper规则",
"Rewrite Rules": "重写规则",
"Preferences saved!": "偏好已存储!",
"Your external account is now linked !": "您的外部账号已关联!",
"Your external account is now linked!": "您的外部账号已关联!",
"Save articles to Wallabag": "保存文章到Wallabag",
"Wallabag API Endpoint": "Wallabag URL",
"Wallabag Client ID": "Wallabag 客户端ID",
@@ -1132,10 +1142,10 @@ var translations = map[string]string{
}
var translationsChecksums = map[string]string{
"de_DE": "df47fc009e6a021579c7e004ebc0b00eae7bf47c23daaf74489fb4c15881296f",
"de_DE": "791d72c96137ab03b729017bdfa27c8eed2f65912e372fcb5b2796d5099d5498",
"en_US": "6fe95384260941e8a5a3c695a655a932e0a8a6a572c1e45cb2b1ae8baa01b897",
"fr_FR": "fb572aee29b90fcaa866e97d2e1cab8c6b8cd8a1cb76c7e83d7aa778748dd283",
"nl_NL": "d427d6a5e843be576040dee004df2b685a839a38b2e5f06435faa2973f1f4c70",
"pl_PL": "4dcf7c3f44c80ca81ecdbef96bdb21d1ae1a8a6caf60cc11403e5e041efc5ca9",
"zh_CN": "bfa05d3b3396df6222414a3a6949b73b486cd021499ecd3a34ce8e04e93aad93",
"fr_FR": "808f1561135bb3d0f886833f00a51402113925e9086c01f71b30bcc679d9b7c2",
"nl_NL": "1a73f1dd1c4c0d2c2adc8695cdd050c2dad81c14876caed3892b44adc2491265",
"pl_PL": "da709c14ff71f3b516eec66cb2758d89c5feab1472c94b2b518f425162a9f806",
"zh_CN": "d80594c1b67d15e9f4673d3d62fe4949e8606a5fdfb741d8a8921f21dceb8cf2",
}
+1 -1
View File
@@ -155,7 +155,7 @@
"Scraper Rules": "Extraktionsregeln",
"Rewrite Rules": "Umschreiberegeln",
"Preferences saved!": "Einstellungen gespeichert!",
"Your external account is now linked !": "Ihr externes Konto wurde verlinkt!",
"Your external account is now linked!": "Ihr externes Konto wurde verlinkt!",
"Save articles to Wallabag": "Artikel in Wallabag speichern",
"Wallabag API Endpoint": "Wallabag URL",
"Wallabag Client ID": "Wallabag Client-ID",
+12 -2
View File
@@ -155,7 +155,7 @@
"Scraper Rules": "Règles pour récupérer le contenu original",
"Rewrite Rules": "Règles de réécriture",
"Preferences saved!": "Préférences sauvegardées !",
"Your external account is now linked !": "Votre compte externe est maintenant associé !",
"Your external account is now linked!": "Votre compte externe est maintenant associé !",
"Save articles to Wallabag": "Sauvegarder les articles vers Wallabag",
"Wallabag API Endpoint": "URL de l'API de Wallabag",
"Wallabag Client ID": "Identifiant du client Wallabag",
@@ -219,5 +219,15 @@
"This website is permanently unreachable (original error: %q)": "Ce site web n'est pas joignable de façon permanente (erreur originale : %q)",
"Website unreachable, the request timed out after %d seconds": "Site web injoignable, la requête à échouée après %d secondes",
"Comments": "Commentaires",
"View Comments": "Voir les commentaires"
"View Comments": "Voir les commentaires",
"This file is empty": "Ce fichier est vide",
"Your external account is now dissociated!": "Votre compte externe est maintenant dissocié !",
"You must define a password otherwise you won't be able to login again.": "Vous devez définir un mot de passe sinon vous ne pourrez plus vous connecter par la suite.",
"Save articles to Pocket": "Sauvegarder les articles vers Pocket",
"Connect your Pocket account": "Connectez votre compte Pocket",
"Pocket Consumer Key": "« Pocket Consumer Key »",
"Pocket Access Token": "« Pocket Access Token »",
"Your Pocket account is now linked!": "Votre compte Pocket est maintenant connecté !",
"Unable to fetch access token from Pocket!": "Impossible de récupérer le jeton d'accès depuis Pocket !",
"Unable to fetch request token from Pocket!": "Impossible de récupérer le jeton d'accès depuis Pocket !"
}
+1 -1
View File
@@ -154,7 +154,7 @@
"Scraper Rules": "Scraper regels",
"Rewrite Rules": "Rewrite regels",
"Preferences saved!": "Instellingen opgeslagen!",
"Your external account is now linked !": "Jouw externe account is nu gekoppeld!",
"Your external account is now linked!": "Jouw externe account is nu gekoppeld!",
"Save articles to Wallabag": "Sauvegarder les articles vers Wallabag",
"Wallabag API Endpoint": "Wallabag URL",
"Wallabag Client ID": "Wallabag Client-ID",
+1 -1
View File
@@ -157,7 +157,7 @@
"Scraper Rules": "Zasady ekstrakcji",
"Rewrite Rules": "Reguły zapisu",
"Preferences saved!": "Ustawienia zapisane!",
"Your external account is now linked !": "Twoje zewnętrzne konto jest teraz połączone!",
"Your external account is now linked!": "Twoje zewnętrzne konto jest teraz połączone!",
"Save articles to Wallabag": "Zapisz artykuły do Wallabag",
"Wallabag API Endpoint": "Wallabag URL",
"Wallabag Client ID": "Wallabag Client-ID",
+1 -1
View File
@@ -155,7 +155,7 @@
"Scraper Rules": "Scraper规则",
"Rewrite Rules": "重写规则",
"Preferences saved!": "偏好已存储!",
"Your external account is now linked !": "您的外部账号已关联!",
"Your external account is now linked!": "您的外部账号已关联!",
"Save articles to Wallabag": "保存文章到Wallabag",
"Wallabag API Endpoint": "Wallabag URL",
"Wallabag Client ID": "Wallabag 客户端ID",
+76
View File
@@ -0,0 +1,76 @@
// Copyright 2018 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"
"errors"
"net/http"
"github.com/miniflux/miniflux/http/cookie"
"github.com/miniflux/miniflux/http/request"
"github.com/miniflux/miniflux/http/response/html"
"github.com/miniflux/miniflux/logger"
"github.com/miniflux/miniflux/model"
)
// AppSession handles application session middleware.
func (m *Middleware) AppSession(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var err error
session := m.getAppSessionValueFromCookie(r)
if session == nil {
logger.Debug("[Middleware:AppSession] Session not found")
session, err = m.store.CreateSession()
if err != nil {
logger.Error("[Middleware:AppSession] %v", err)
html.ServerError(w, err)
return
}
http.SetCookie(w, cookie.New(cookie.CookieSessionID, session.ID, m.cfg.IsHTTPS, m.cfg.BasePath()))
} else {
logger.Debug("[Middleware:AppSession] %s", session)
}
if r.Method == "POST" {
formValue := r.FormValue("csrf")
headerValue := r.Header.Get("X-Csrf-Token")
if session.Data.CSRF != formValue && session.Data.CSRF != headerValue {
logger.Error(`[Middleware:AppSession] Invalid or missing CSRF token: Form="%s", Header="%s"`, formValue, headerValue)
html.BadRequest(w, errors.New("invalid or missing CSRF"))
return
}
}
ctx := r.Context()
ctx = context.WithValue(ctx, SessionIDContextKey, session.ID)
ctx = context.WithValue(ctx, CSRFContextKey, session.Data.CSRF)
ctx = context.WithValue(ctx, OAuth2StateContextKey, session.Data.OAuth2State)
ctx = context.WithValue(ctx, FlashMessageContextKey, session.Data.FlashMessage)
ctx = context.WithValue(ctx, FlashErrorMessageContextKey, session.Data.FlashErrorMessage)
ctx = context.WithValue(ctx, UserLanguageContextKey, session.Data.Language)
ctx = context.WithValue(ctx, PocketRequestTokenContextKey, session.Data.PocketRequestToken)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
func (m *Middleware) getAppSessionValueFromCookie(r *http.Request) *model.Session {
cookieValue := request.Cookie(r, cookie.CookieSessionID)
if cookieValue == "" {
return nil
}
session, err := m.store.Session(cookieValue)
if err != nil {
logger.Error("[Middleware:AppSession] %v", err)
return nil
}
return session
}
@@ -1,4 +1,4 @@
// Copyright 2017 Frédéric Guillot. All rights reserved.
// Copyright 2018 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.
@@ -8,53 +8,43 @@ import (
"context"
"net/http"
"github.com/miniflux/miniflux/http/response/json"
"github.com/miniflux/miniflux/logger"
"github.com/miniflux/miniflux/storage"
)
// BasicAuthMiddleware is the middleware for HTTP Basic authentication.
type BasicAuthMiddleware struct {
store *storage.Storage
}
// Handler executes the middleware.
func (b *BasicAuthMiddleware) Handler(next http.Handler) http.Handler {
// BasicAuth handles HTTP basic authentication.
func (m *Middleware) BasicAuth(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("WWW-Authenticate", `Basic realm="Restricted"`)
errorResponse := `{"error_message": "Not Authorized"}`
username, password, authOK := r.BasicAuth()
if !authOK {
logger.Debug("[Middleware:BasicAuth] No authentication headers sent")
w.WriteHeader(http.StatusUnauthorized)
w.Write([]byte(errorResponse))
json.Unauthorized(w)
return
}
if err := b.store.CheckPassword(username, password); err != nil {
if err := m.store.CheckPassword(username, password); err != nil {
logger.Info("[Middleware:BasicAuth] Invalid username or password: %s", username)
w.WriteHeader(http.StatusUnauthorized)
w.Write([]byte(errorResponse))
json.Unauthorized(w)
return
}
user, err := b.store.UserByUsername(username)
user, err := m.store.UserByUsername(username)
if err != nil {
logger.Error("[Middleware:BasicAuth] %v", err)
w.WriteHeader(http.StatusInternalServerError)
w.Write([]byte(errorResponse))
json.ServerError(w, err)
return
}
if user == nil {
logger.Info("[Middleware:BasicAuth] User not found: %s", username)
w.WriteHeader(http.StatusUnauthorized)
w.Write([]byte(errorResponse))
json.Unauthorized(w)
return
}
logger.Info("[Middleware:BasicAuth] User authenticated: %s", username)
b.store.SetLastLogin(user.ID)
m.store.SetLastLogin(user.ID)
ctx := r.Context()
ctx = context.WithValue(ctx, UserIDContextKey, user.ID)
@@ -65,8 +55,3 @@ func (b *BasicAuthMiddleware) Handler(next http.Handler) http.Handler {
next.ServeHTTP(w, r.WithContext(ctx))
})
}
// NewBasicAuthMiddleware returns a new BasicAuthMiddleware.
func NewBasicAuthMiddleware(s *storage.Storage) *BasicAuthMiddleware {
return &BasicAuthMiddleware{store: s}
}
+25
View File
@@ -0,0 +1,25 @@
// Copyright 2018 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 (
"net/http"
)
// CommonHeaders sends common HTTP headers.
func (m *Middleware) CommonHeaders(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("X-XSS-Protection", "1; mode=block")
w.Header().Set("X-Content-Type-Options", "nosniff")
w.Header().Set("X-Frame-Options", "DENY")
w.Header().Set("Content-Security-Policy", "default-src 'self'; img-src *; media-src *; frame-src *; child-src *")
if m.cfg.IsHTTPS && m.cfg.HasHSTS() {
w.Header().Set("Strict-Transport-Security", "max-age=31536000")
}
next.ServeHTTP(w, r)
})
}
@@ -1,4 +1,4 @@
// Copyright 2017 Frédéric Guillot. All rights reserved.
// Copyright 2018 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.
@@ -46,4 +46,7 @@ var (
// FlashErrorMessageContextKey is the context key used to store a flash error message.
FlashErrorMessageContextKey = &ContextKey{"FlashErrorMessage"}
// PocketRequestTokenContextKey is the context key for Pocket Request Token.
PocketRequestTokenContextKey = &ContextKey{"PocketRequestToken"}
)
@@ -1,4 +1,4 @@
// Copyright 2017 Frédéric Guillot. All rights reserved.
// Copyright 2018 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.
@@ -8,38 +8,30 @@ import (
"context"
"net/http"
"github.com/miniflux/miniflux/http/response/json"
"github.com/miniflux/miniflux/logger"
"github.com/miniflux/miniflux/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 {
// FeverAuth handles Fever API authentication.
func (m *Middleware) FeverAuth(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
logger.Debug("[Middleware:Fever]")
apiKey := r.FormValue("api_key")
user, err := f.store.UserByFeverToken(apiKey)
user, err := m.store.UserByFeverToken(apiKey)
if err != nil {
logger.Error("[Fever] %v", err)
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{"api_version": 3, "auth": 0}`))
logger.Error("[Middleware:Fever] %v", err)
json.OK(w, map[string]int{"api_version": 3, "auth": 0})
return
}
if user == nil {
logger.Info("[Middleware:Fever] Fever authentication failure")
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{"api_version": 3, "auth": 0}`))
json.OK(w, map[string]int{"api_version": 3, "auth": 0})
return
}
logger.Info("[Middleware:Fever] User #%d is authenticated", user.ID)
f.store.SetLastLogin(user.ID)
m.store.SetLastLogin(user.ID)
ctx := r.Context()
ctx = context.WithValue(ctx, UserIDContextKey, user.ID)
@@ -50,8 +42,3 @@ func (f *FeverMiddleware) Handler(next http.Handler) http.Handler {
next.ServeHTTP(w, r.WithContext(ctx))
})
}
// NewFeverMiddleware returns a new FeverMiddleware.
func NewFeverMiddleware(s *storage.Storage) *FeverMiddleware {
return &FeverMiddleware{store: s}
}
+19
View File
@@ -0,0 +1,19 @@
// Copyright 2018 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 (
"net/http"
)
// HeaderConfig changes config values according to HTTP headers.
func (m *Middleware) HeaderConfig(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("X-Forwarded-Proto") == "https" {
m.cfg.IsHTTPS = true
}
next.ServeHTTP(w, r)
})
}
+20
View File
@@ -0,0 +1,20 @@
// Copyright 2018 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 (
"net/http"
"github.com/miniflux/miniflux/http/request"
"github.com/miniflux/miniflux/logger"
)
// Logging logs the HTTP request.
func (m *Middleware) Logging(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
logger.Debug("[HTTP] %s %s %s", request.RealIP(r), r.Method, r.RequestURI)
next.ServeHTTP(w, r)
})
}
+23
View File
@@ -0,0 +1,23 @@
// Copyright 2018 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 (
"github.com/gorilla/mux"
"github.com/miniflux/miniflux/config"
"github.com/miniflux/miniflux/storage"
)
// Middleware handles different middleware handlers.
type Middleware struct {
cfg *config.Config
store *storage.Storage
router *mux.Router
}
// New returns a new middleware.
func New(cfg *config.Config, store *storage.Storage, router *mux.Router) *Middleware {
return &Middleware{cfg, store, router}
}
@@ -9,34 +9,30 @@ import (
"net/http"
"github.com/miniflux/miniflux/http/cookie"
"github.com/miniflux/miniflux/http/request"
"github.com/miniflux/miniflux/http/response"
"github.com/miniflux/miniflux/http/route"
"github.com/miniflux/miniflux/logger"
"github.com/miniflux/miniflux/model"
"github.com/miniflux/miniflux/storage"
"github.com/gorilla/mux"
)
// UserSessionMiddleware represents a user session middleware.
type UserSessionMiddleware struct {
store *storage.Storage
router *mux.Router
}
// Handler execute the middleware.
func (s *UserSessionMiddleware) Handler(next http.Handler) http.Handler {
// UserSession handles the user session middleware.
func (m *Middleware) UserSession(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
session := s.getSessionFromCookie(r)
session := m.getUserSessionFromCookie(r)
if session == nil {
logger.Debug("[Middleware:UserSession] Session not found")
if s.isPublicRoute(r) {
if m.isPublicRoute(r) {
next.ServeHTTP(w, r)
} else {
http.Redirect(w, r, route.Path(s.router, "login"), http.StatusFound)
response.Redirect(w, r, route.Path(m.router, "login"))
}
} else {
logger.Debug("[Middleware:UserSession] %s", session)
ctx := r.Context()
ctx = context.WithValue(ctx, UserIDContextKey, session.UserID)
ctx = context.WithValue(ctx, IsAuthenticatedContextKey, true)
@@ -47,7 +43,7 @@ func (s *UserSessionMiddleware) Handler(next http.Handler) http.Handler {
})
}
func (s *UserSessionMiddleware) isPublicRoute(r *http.Request) bool {
func (m *Middleware) isPublicRoute(r *http.Request) bool {
route := mux.CurrentRoute(r)
switch route.GetName() {
case "login",
@@ -65,13 +61,13 @@ func (s *UserSessionMiddleware) isPublicRoute(r *http.Request) bool {
}
}
func (s *UserSessionMiddleware) getSessionFromCookie(r *http.Request) *model.UserSession {
sessionCookie, err := r.Cookie(cookie.CookieUserSessionID)
if err == http.ErrNoCookie {
func (m *Middleware) getUserSessionFromCookie(r *http.Request) *model.UserSession {
cookieValue := request.Cookie(r, cookie.CookieUserSessionID)
if cookieValue == "" {
return nil
}
session, err := s.store.UserSessionByToken(sessionCookie.Value)
session, err := m.store.UserSessionByToken(cookieValue)
if err != nil {
logger.Error("[Middleware:UserSession] %v", err)
return nil
@@ -79,8 +75,3 @@ func (s *UserSessionMiddleware) getSessionFromCookie(r *http.Request) *model.Use
return session
}
// NewUserSessionMiddleware returns a new UserSessionMiddleware.
func NewUserSessionMiddleware(s *storage.Storage, r *mux.Router) *UserSessionMiddleware {
return &UserSessionMiddleware{store: s, router: r}
}
+3
View File
@@ -27,4 +27,7 @@ type Integration struct {
NunuxKeeperEnabled bool
NunuxKeeperURL string
NunuxKeeperAPIKey string
PocketEnabled bool
PocketAccessToken string
PocketConsumerKey string
}
+6 -5
View File
@@ -13,11 +13,12 @@ import (
// SessionData represents the data attached to the session.
type SessionData struct {
CSRF string `json:"csrf"`
OAuth2State string `json:"oauth2_state"`
FlashMessage string `json:"flash_message"`
FlashErrorMessage string `json:"flash_error_message"`
Language string `json:"language"`
CSRF string `json:"csrf"`
OAuth2State string `json:"oauth2_state"`
FlashMessage string `json:"flash_message"`
FlashErrorMessage string `json:"flash_error_message"`
Language string `json:"language"`
PocketRequestToken string `json:"pocket_request_token"`
}
func (s SessionData) String() string {
+6
View File
@@ -7,6 +7,7 @@ package date
import (
"errors"
"fmt"
"strconv"
"strings"
"time"
)
@@ -194,6 +195,11 @@ var dateFormats = []string{
// Parse parses a given date string using a large
// list of commonly found feed date formats.
func Parse(ds string) (t time.Time, err error) {
timestamp, err := strconv.ParseInt(ds, 10, 64)
if err == nil {
return time.Unix(timestamp, 0), nil
}
ds = replaceNonEnglishWords(ds)
d := strings.TrimSpace(ds)
if d == "" {
+1
View File
@@ -51,6 +51,7 @@ func TestParseWeirdDateFormat(t *testing.T) {
"Di, 23 Jan 2018 00:00:00 +0100",
"Do, 29 Mär 2018 00:00:00 +0200",
"mer, 9 avr 2018 00:00:00 +0200",
"1520932969",
}
for _, date := range dates {
+6 -5
View File
@@ -9,7 +9,7 @@ import (
"time"
"github.com/miniflux/miniflux/errors"
"github.com/miniflux/miniflux/http"
"github.com/miniflux/miniflux/http/client"
"github.com/miniflux/miniflux/locale"
"github.com/miniflux/miniflux/logger"
"github.com/miniflux/miniflux/model"
@@ -43,8 +43,8 @@ func (h *Handler) CreateFeed(userID, categoryID int64, url string, crawler bool)
return nil, errors.NewLocalizedError(errCategoryNotFound)
}
client := http.NewClient(url)
response, err := client.Get()
clt := client.New(url)
response, err := clt.Get()
if err != nil {
if _, ok := err.(*errors.LocalizedError); ok {
return nil, err
@@ -129,8 +129,9 @@ func (h *Handler) RefreshFeed(userID, feedID int64) error {
return errors.NewLocalizedError(errNotFound, feedID)
}
client := http.NewClientWithCacheHeaders(originalFeed.FeedURL, originalFeed.EtagHeader, originalFeed.LastModifiedHeader)
response, err := client.Get()
clt := client.New(originalFeed.FeedURL)
clt.WithCacheHeaders(originalFeed.EtagHeader, originalFeed.LastModifiedHeader)
response, err := clt.Get()
if err != nil {
var customErr errors.LocalizedError
if lerr, ok := err.(*errors.LocalizedError); ok {
+5 -5
View File
@@ -12,7 +12,7 @@ import (
"strings"
"github.com/miniflux/miniflux/crypto"
"github.com/miniflux/miniflux/http"
"github.com/miniflux/miniflux/http/client"
"github.com/miniflux/miniflux/logger"
"github.com/miniflux/miniflux/model"
"github.com/miniflux/miniflux/url"
@@ -23,8 +23,8 @@ import (
// FindIcon try to find the website's icon.
func FindIcon(websiteURL string) (*model.Icon, error) {
rootURL := url.RootURL(websiteURL)
client := http.NewClient(rootURL)
response, err := client.Get()
clt := client.New(rootURL)
response, err := clt.Get()
if err != nil {
return nil, fmt.Errorf("unable to download website index page: %v", err)
}
@@ -87,8 +87,8 @@ func parseDocument(websiteURL string, data io.Reader) (string, error) {
}
func downloadIcon(iconURL string) (*model.Icon, error) {
client := http.NewClient(iconURL)
response, err := client.Get()
clt := client.New(iconURL)
response, err := clt.Get()
if err != nil {
return nil, fmt.Errorf("unable to download iconURL: %v", err)
}
+2 -3
View File
@@ -23,8 +23,7 @@ type Handler struct {
func (h *Handler) Export(userID int64) (string, error) {
feeds, err := h.store.Feeds(userID)
if err != nil {
logger.Error("[OPML:Export] %v", err)
return "", errors.New("unable to fetch feeds")
return "", err
}
var subscriptions SubcriptionList
@@ -74,7 +73,7 @@ func (h *Handler) Import(userID int64, data io.Reader) error {
err := h.store.CreateCategory(category)
if err != nil {
logger.Error("[OPML:Import] %v", err)
return fmt.Errorf(`unable to create this category: "%s"`, subscription.CategoryName)
return fmt.Errorf(`unable to create this category: %q`, subscription.CategoryName)
}
}
}
+6
View File
@@ -280,6 +280,12 @@ func isValidIframeSource(src string) bool {
"https://www.dailymotion.com",
"http://vk.com",
"https://vk.com",
"http://soundcloud.com",
"https://soundcloud.com",
"http://w.soundcloud.com",
"https://w.soundcloud.com",
"http://bandcamp.com",
"https://bandcamp.com",
}
for _, prefix := range whitelist {
+4 -4
View File
@@ -11,7 +11,7 @@ import (
"strings"
"github.com/PuerkitoBio/goquery"
"github.com/miniflux/miniflux/http"
"github.com/miniflux/miniflux/http/client"
"github.com/miniflux/miniflux/logger"
"github.com/miniflux/miniflux/reader/readability"
"github.com/miniflux/miniflux/url"
@@ -19,8 +19,8 @@ import (
// Fetch downloads a web page a returns relevant contents.
func Fetch(websiteURL, rules string) (string, error) {
client := http.NewClient(websiteURL)
response, err := client.Get()
clt := client.New(websiteURL)
response, err := clt.Get()
if err != nil {
return "", err
}
@@ -72,7 +72,7 @@ func scrapContent(page io.Reader, rules string) (string, error) {
var content string
// For some inline elements, we get the parent.
if s.Is("img") {
if s.Is("img") || s.Is("iframe") {
content, _ = s.Parent().Html()
} else {
content, _ = s.Html()
+3 -3
View File
@@ -11,7 +11,7 @@ import (
"time"
"github.com/miniflux/miniflux/errors"
"github.com/miniflux/miniflux/http"
"github.com/miniflux/miniflux/http/client"
"github.com/miniflux/miniflux/logger"
"github.com/miniflux/miniflux/reader/feed"
"github.com/miniflux/miniflux/timer"
@@ -30,8 +30,8 @@ var (
func FindSubscriptions(websiteURL string) (Subscriptions, error) {
defer timer.ExecutionTime(time.Now(), fmt.Sprintf("[FindSubscriptions] url=%s", websiteURL))
client := http.NewClient(websiteURL)
response, err := client.Get()
clt := client.New(websiteURL)
response, err := clt.Get()
if err != nil {
if _, ok := err.(errors.LocalizedError); ok {
return nil, err
+7 -3
View File
@@ -27,14 +27,18 @@ func NewFeedScheduler(store *storage.Storage, workerPool *WorkerPool, frequency,
}()
}
// NewSessionScheduler starts a new scheduler that clean old sessions.
func NewSessionScheduler(store *storage.Storage, frequency int) {
// NewCleanupScheduler starts a new scheduler that clean old sessions and archive read items.
func NewCleanupScheduler(store *storage.Storage, frequency int) {
go func() {
c := time.Tick(time.Duration(frequency) * time.Hour)
for range c {
nbSessions := store.CleanOldSessions()
nbUserSessions := store.CleanOldUserSessions()
logger.Info("[SessionScheduler] cleaned %d sessions and %d user sessions", nbSessions, nbUserSessions)
logger.Info("[CleanupScheduler] Cleaned %d sessions and %d user sessions", nbSessions, nbUserSessions)
if err := store.ArchiveEntries(); err != nil {
logger.Error("[CleanupScheduler] %v", err)
}
}
}()
}
+3
View File
@@ -0,0 +1,3 @@
alter table integrations add column pocket_enabled bool default 'f';
alter table integrations add column pocket_access_token text default '';
alter table integrations add column pocket_consumer_key text default '';
+1
View File
@@ -0,0 +1 @@
alter table user_sessions alter column ip set data type inet using ip::inet;
+8 -1
View File
@@ -1,5 +1,5 @@
// Code generated by go generate; DO NOT EDIT.
// 2018-04-06 23:00:49.983090069 +0100 BST m=+0.002610702
// 2018-05-21 12:33:12.475674107 -0700 PDT m=+0.003076147
package sql
@@ -131,6 +131,11 @@ alter table integrations add column nunux_keeper_url text default '';
alter table integrations add column nunux_keeper_api_key text default '';`,
"schema_version_15": `alter table enclosures alter column size set data type bigint;`,
"schema_version_16": `alter table entries add column comments_url text default '';`,
"schema_version_17": `alter table integrations add column pocket_enabled bool default 'f';
alter table integrations add column pocket_access_token text default '';
alter table integrations add column pocket_consumer_key text default '';
`,
"schema_version_18": `alter table user_sessions alter column ip set data type inet using ip::inet;`,
"schema_version_2": `create extension if not exists hstore;
alter table users add column extra hstore;
create index users_extra_idx on users using gin(extra);
@@ -178,6 +183,8 @@ var SqlMapChecksums = map[string]string{
"schema_version_14": "4622e42c4a5a88b6fe1e61f3d367b295968f7260ab5b96481760775ba9f9e1fe",
"schema_version_15": "13ff91462bdf4cda5a94a4c7a09f757761b0f2c32b4be713ba4786a4837750e4",
"schema_version_16": "9d006faca62fd7ab787f64aef0e0a5933d142466ec4cab0e096bb920d2797e34",
"schema_version_17": "b9f15d6217275fedcf6d948dd85ebe978b869bf37f42a86fd5b50a51919fa0e1",
"schema_version_18": "c0ec24847612c7f2dc326cf735baffba79391a56aedd73292371a39f38724a71",
"schema_version_2": "e8e9ff32478df04fcddad10a34cba2e8bb1e67e7977b5bd6cdc4c31ec94282b4",
"schema_version_3": "a54745dbc1c51c000f74d4e5068f1e2f43e83309f023415b1749a47d5c1e0f12",
"schema_version_4": "216ea3a7d3e1704e40c797b5dc47456517c27dbb6ca98bf88812f4f63d74b5d9",
+2 -1
View File
@@ -112,7 +112,8 @@ func (s *Storage) CategoriesWithFeedCount(userID int64) (model.Categories, error
query := `SELECT
c.id, c.user_id, c.title,
(SELECT count(*) FROM feeds WHERE feeds.category_id=c.id) AS count
FROM categories c WHERE user_id=$1`
FROM categories c WHERE user_id=$1
ORDER BY c.title ASC`
rows, err := s.db.Query(query, userID)
if err != nil {
+27
View File
@@ -16,6 +16,20 @@ import (
"github.com/lib/pq"
)
// CountUnreadEntries returns the number of unread entries.
func (s *Storage) CountUnreadEntries(userID int64) int {
builder := s.NewEntryQueryBuilder(userID)
builder.WithStatus(model.EntryStatusUnread)
n, err := builder.CountEntries()
if err != nil {
logger.Error("unable to count unread entries: %v", err)
return 0
}
return n
}
// NewEntryQueryBuilder returns a new EntryQueryBuilder
func (s *Storage) NewEntryQueryBuilder(userID int64) *EntryQueryBuilder {
return NewEntryQueryBuilder(s, userID)
@@ -162,6 +176,19 @@ func (s *Storage) cleanupEntries(feedID int64, entryHashes []string) error {
return nil
}
// ArchiveEntries changes the status of read items to "removed" after 60 days.
func (s *Storage) ArchiveEntries() error {
query := `
UPDATE entries SET status='removed'
WHERE id=ANY(SELECT id FROM entries WHERE status='read' AND starred is false AND published_at < now () - '60 days'::interval LIMIT 500)
`
if _, err := s.db.Exec(query); err != nil {
return fmt.Errorf("unable to archive read entries: %v", err)
}
return nil
}
// SetEntriesStatus update the status of the given list of entries.
func (s *Storage) SetEntriesStatus(userID int64, entryIDs []int64, status string) error {
defer timer.ExecutionTime(time.Now(), fmt.Sprintf("[Storage:SetEntriesStatus] userID=%d, entryIDs=%v, status=%s", userID, entryIDs, status))
+30 -3
View File
@@ -70,7 +70,10 @@ func (s *Storage) Integration(userID int64) (*model.Integration, error) {
wallabag_password,
nunux_keeper_enabled,
nunux_keeper_url,
nunux_keeper_api_key
nunux_keeper_api_key,
pocket_enabled,
pocket_access_token,
pocket_consumer_key
FROM integrations
WHERE user_id=$1
`
@@ -97,6 +100,9 @@ func (s *Storage) Integration(userID int64) (*model.Integration, error) {
&integration.NunuxKeeperEnabled,
&integration.NunuxKeeperURL,
&integration.NunuxKeeperAPIKey,
&integration.PocketEnabled,
&integration.PocketAccessToken,
&integration.PocketConsumerKey,
)
switch {
case err == sql.ErrNoRows:
@@ -131,8 +137,11 @@ func (s *Storage) UpdateIntegration(integration *model.Integration) error {
wallabag_password=$17,
nunux_keeper_enabled=$18,
nunux_keeper_url=$19,
nunux_keeper_api_key=$20
WHERE user_id=$21
nunux_keeper_api_key=$20,
pocket_enabled=$21,
pocket_access_token=$22,
pocket_consumer_key=$23
WHERE user_id=$24
`
_, err := s.db.Exec(
query,
@@ -156,6 +165,9 @@ func (s *Storage) UpdateIntegration(integration *model.Integration) error {
integration.NunuxKeeperEnabled,
integration.NunuxKeeperURL,
integration.NunuxKeeperAPIKey,
integration.PocketEnabled,
integration.PocketAccessToken,
integration.PocketConsumerKey,
integration.UserID,
)
@@ -176,3 +188,18 @@ func (s *Storage) CreateIntegration(userID int64) error {
return nil
}
// HasSaveEntry returns true if the given user can save articles to third-parties.
func (s *Storage) HasSaveEntry(userID int64) (result bool) {
query := `
SELECT true FROM integrations
WHERE user_id=$1 AND
(pinboard_enabled='t' OR instapaper_enabled='t' OR wallabag_enabled='t' OR nunux_keeper_enabled='t' OR pocket_enabled='t')
`
if err := s.db.QueryRow(query, userID).Scan(&result); err != nil {
result = false
}
return result
}
+1 -1
View File
@@ -12,7 +12,7 @@ import (
"github.com/miniflux/miniflux/sql"
)
const schemaVersion = 16
const schemaVersion = 18
// Migrate run database migrations.
func (s *Storage) Migrate() {
+18
View File
@@ -339,6 +339,24 @@ func (s *Storage) CheckPassword(username, password string) error {
return nil
}
// HasPassword returns true if the given user has a password defined.
func (s *Storage) HasPassword(userID int64) (bool, error) {
var result bool
query := `SELECT true FROM users WHERE id=$1 AND password <> ''`
err := s.db.QueryRow(query, userID).Scan(&result)
if err == sql.ErrNoRows {
return false, nil
} else if err != nil {
return false, fmt.Errorf("unable to execute query: %v", err)
}
if result {
return true, nil
}
return false, nil
}
func hashPassword(password string) (string, error) {
bytes, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
return string(bytes), err
+4 -8
View File
@@ -47,24 +47,20 @@ func (s *Storage) UserSessions(userID int64) (model.UserSessions, error) {
}
// CreateUserSession creates a new sessions.
func (s *Storage) CreateUserSession(username, userAgent, ip string) (sessionID string, err error) {
var userID int64
func (s *Storage) CreateUserSession(username, userAgent, ip string) (sessionID string, userID int64, err error) {
err = s.db.QueryRow("SELECT id FROM users WHERE username = LOWER($1)", username).Scan(&userID)
if err != nil {
return "", fmt.Errorf("unable to fetch UserID: %v", err)
return "", 0, fmt.Errorf("unable to fetch user ID: %v", err)
}
token := crypto.GenerateRandomString(64)
query := "INSERT INTO user_sessions (token, user_id, user_agent, ip) VALUES ($1, $2, $3, $4)"
_, err = s.db.Exec(query, token, userID, userAgent, ip)
if err != nil {
return "", fmt.Errorf("unable to create user session: %v", err)
return "", 0, fmt.Errorf("unable to create user session: %v", err)
}
s.SetLastLogin(userID)
return token, nil
return token, userID, nil
}
// UserSessionByToken finds a session by the token.
+24 -21
View File
@@ -1,5 +1,5 @@
// Code generated by go generate; DO NOT EDIT.
// 2018-04-07 13:51:33.926223471 -0700 PDT m=+0.022666283
// 2018-05-21 12:40:10.613112 +0200 CEST m=+0.010961753
package template
@@ -32,18 +32,25 @@ var templateCommonMap = map[string]string{
<li>
<time datetime="{{ isodate .entry.Date }}" title="{{ isodate .entry.Date }}">{{ elapsed .user.Timezone .entry.Date }}</time>
</li>
<li>
<a href="#"
title="{{ t "Save this article" }}"
data-save-entry="true"
data-save-url="{{ route "saveEntry" "entryID" .entry.ID }}"
data-label-loading="{{ t "Saving..." }}"
data-label-done="{{ t "Done!" }}"
>{{ t "Save" }}</a>
</li>
{{ if .hasSaveEntry }}
<li>
<a href="#"
title="{{ t "Save this article" }}"
data-save-entry="true"
data-save-url="{{ route "saveEntry" "entryID" .entry.ID }}"
data-label-loading="{{ t "Saving..." }}"
data-label-done="{{ t "Done!" }}"
>{{ t "Save" }}</a>
</li>
{{ end }}
<li>
<a href="{{ .entry.URL }}" target="_blank" rel="noopener noreferrer" referrerpolicy="no-referrer" data-original-link="true">{{ t "Original" }}</a>
</li>
{{ if .entry.CommentsURL }}
<li>
<a href="{{ .entry.CommentsURL }}" title="{{ t "View Comments" }}" target="_blank" rel="noopener noreferrer" referrerpolicy="no-referrer">{{ t "Comments" }}</a>
</li>
{{ end }}
<li>
<a href="#"
data-toggle-bookmark="true"
@@ -63,11 +70,6 @@ var templateCommonMap = map[string]string{
data-value="{{ if eq .entry.Status "read" }}read{{ else }}unread{{ end }}"
>{{ if eq .entry.Status "read" }}{{ t "Unread" }}{{ else }}{{ t "Read" }}{{ end }}</a>
</li>
{{ if .entry.CommentsURL }}
<li>
<a href="{{ .entry.CommentsURL }}" title="{{ t "View Comments" }}" target="_blank" rel="noopener noreferrer" referrerpolicy="no-referrer">{{ t "Comments" }}</a>
</li>
{{ end }}
</ul>
</div>
{{ end }}`,
@@ -113,10 +115,11 @@ var templateCommonMap = map[string]string{
</div>
<ul>
<li {{ if eq .menu "unread" }}class="active"{{ end }} title="{{ t "Keyboard Shortcut: %s" "g u" }}">
<a href="{{ route "unread" }}" data-page="unread">{{ t "Unread" }}</a>
{{ if gt .countUnread 0 }}
<span class="unread-counter-wrapper">(<span class="unread-counter">{{ .countUnread }}</span>)</span>
{{ end }}
<a href="{{ route "unread" }}" data-page="unread">{{ t "Unread" }}
{{ if gt .countUnread 0 }}
<span class="unread-counter-wrapper">(<span class="unread-counter">{{ .countUnread }}</span>)</span>
{{ end }}
</a>
</li>
<li {{ if eq .menu "starred" }}class="active"{{ end }} title="{{ t "Keyboard Shortcut: %s" "g b" }}">
<a href="{{ route "starred" }}" data-page="starred">{{ t "Starred" }}</a>
@@ -220,7 +223,7 @@ var templateCommonMap = map[string]string{
var templateCommonMapChecksums = map[string]string{
"entry_pagination": "f1465fa70f585ae8043b200ec9de5bf437ffbb0c19fb7aefc015c3555614ee27",
"item_meta": "c14b5b36076e087346def2ebcef4876ed5e8165218f40dc017db44e754c22d03",
"layout": "c7565e2cf904612e236bc1d7167c6c124ffe5d27348608eb5c2336606f266896",
"item_meta": "6cff8ae243f19dac936e523867d2975f70aa749b2a461ae63f6ebbca94cf7419",
"layout": "2226ad243581d200b3d9499787a5ba38d6b2fff38d2ce86a40eb395a6efc17de",
"pagination": "6ff462c2b2a53bc5448b651da017f40a39f1d4f16cef4b2f09784f0797286924",
}
+30 -10
View File
@@ -7,9 +7,10 @@ package template
import (
"bytes"
"html/template"
"io"
"time"
"github.com/miniflux/miniflux/config"
"github.com/miniflux/miniflux/errors"
"github.com/miniflux/miniflux/locale"
"github.com/miniflux/miniflux/logger"
@@ -35,25 +36,44 @@ func (e *Engine) parseAll() {
}
}
// SetLanguage change the language for template processing.
func (e *Engine) SetLanguage(language string) {
e.funcMap.Language = e.translator.GetLanguage(language)
}
// Execute process a template.
func (e *Engine) Execute(w io.Writer, name string, data interface{}) {
// Render process a template and write the ouput.
func (e *Engine) Render(name, language string, data interface{}) []byte {
tpl, ok := e.templates[name]
if !ok {
logger.Fatal("[Template] The template %s does not exists", name)
}
lang := e.translator.GetLanguage(language)
tpl.Funcs(template.FuncMap{
"elapsed": func(timezone string, t time.Time) string {
return elapsedTime(lang, timezone, t)
},
"t": func(key interface{}, args ...interface{}) string {
switch key.(type) {
case string:
return lang.Get(key.(string), args...)
case errors.LocalizedError:
return key.(errors.LocalizedError).Localize(lang)
case *errors.LocalizedError:
return key.(*errors.LocalizedError).Localize(lang)
case error:
return key.(error).Error()
default:
return ""
}
},
"plural": func(key string, n int, args ...interface{}) string {
return lang.Plural(key, n, args...)
},
})
var b bytes.Buffer
err := tpl.ExecuteTemplate(&b, "base", data)
if err != nil {
logger.Fatal("[Template] Unable to render template: %v", err)
}
b.WriteTo(w)
return b.Bytes()
}
// NewEngine returns a new template engine.
@@ -61,7 +81,7 @@ func NewEngine(cfg *config.Config, router *mux.Router, translator *locale.Transl
tpl := &Engine{
templates: make(map[string]*template.Template),
translator: translator,
funcMap: newFuncMap(cfg, router, translator.GetLanguage("en_US")),
funcMap: newFuncMap(cfg, router),
}
tpl.parseAll()
+10 -22
View File
@@ -12,17 +12,14 @@ import (
"github.com/gorilla/mux"
"github.com/miniflux/miniflux/config"
"github.com/miniflux/miniflux/errors"
"github.com/miniflux/miniflux/filter"
"github.com/miniflux/miniflux/http/route"
"github.com/miniflux/miniflux/locale"
"github.com/miniflux/miniflux/url"
)
type funcMap struct {
cfg *config.Config
router *mux.Router
Language *locale.Language
cfg *config.Config
router *mux.Router
}
func (f *funcMap) Map() template.FuncMap {
@@ -77,30 +74,21 @@ func (f *funcMap) Map() template.FuncMap {
"isodate": func(ts time.Time) string {
return ts.Format("2006-01-02 15:04:05")
},
"dict": dict,
// These functions are overrided at runtime after the parsing.
"elapsed": func(timezone string, t time.Time) string {
return elapsedTime(f.Language, timezone, t)
return ""
},
"t": func(key interface{}, args ...interface{}) string {
switch key.(type) {
case string:
return f.Language.Get(key.(string), args...)
case errors.LocalizedError:
return key.(errors.LocalizedError).Localize(f.Language)
case *errors.LocalizedError:
return key.(*errors.LocalizedError).Localize(f.Language)
case error:
return key.(error).Error()
default:
return ""
}
return ""
},
"plural": func(key string, n int, args ...interface{}) string {
return f.Language.Plural(key, n, args...)
return ""
},
"dict": dict,
}
}
func newFuncMap(cfg *config.Config, router *mux.Router, language *locale.Language) *funcMap {
return &funcMap{cfg, router, language}
func newFuncMap(cfg *config.Config, router *mux.Router) *funcMap {
return &funcMap{cfg, router}
}
@@ -20,7 +20,7 @@
</span>
<span class="category"><a href="{{ route "categoryEntries" "categoryID" .Feed.Category.ID }}">{{ .Feed.Category.Title }}</a></span>
</div>
{{ template "item_meta" dict "user" $.user "entry" . }}
{{ template "item_meta" dict "user" $.user "entry" . "hasSaveEntry" $.hasSaveEntry }}
</article>
{{ end }}
</div>
+1 -1
View File
@@ -27,7 +27,7 @@
</span>
<span class="category"><a href="{{ route "categoryEntries" "categoryID" .Feed.Category.ID }}">{{ .Feed.Category.Title }}</a></span>
</div>
{{ template "item_meta" dict "user" $.user "entry" . }}
{{ template "item_meta" dict "user" $.user "entry" . "hasSaveEntry" $.hasSaveEntry }}
</article>
{{ end }}
</div>
+16 -14
View File
@@ -7,18 +7,25 @@
<li>
<time datetime="{{ isodate .entry.Date }}" title="{{ isodate .entry.Date }}">{{ elapsed .user.Timezone .entry.Date }}</time>
</li>
<li>
<a href="#"
title="{{ t "Save this article" }}"
data-save-entry="true"
data-save-url="{{ route "saveEntry" "entryID" .entry.ID }}"
data-label-loading="{{ t "Saving..." }}"
data-label-done="{{ t "Done!" }}"
>{{ t "Save" }}</a>
</li>
{{ if .hasSaveEntry }}
<li>
<a href="#"
title="{{ t "Save this article" }}"
data-save-entry="true"
data-save-url="{{ route "saveEntry" "entryID" .entry.ID }}"
data-label-loading="{{ t "Saving..." }}"
data-label-done="{{ t "Done!" }}"
>{{ t "Save" }}</a>
</li>
{{ end }}
<li>
<a href="{{ .entry.URL }}" target="_blank" rel="noopener noreferrer" referrerpolicy="no-referrer" data-original-link="true">{{ t "Original" }}</a>
</li>
{{ if .entry.CommentsURL }}
<li>
<a href="{{ .entry.CommentsURL }}" title="{{ t "View Comments" }}" target="_blank" rel="noopener noreferrer" referrerpolicy="no-referrer">{{ t "Comments" }}</a>
</li>
{{ end }}
<li>
<a href="#"
data-toggle-bookmark="true"
@@ -38,11 +45,6 @@
data-value="{{ if eq .entry.Status "read" }}read{{ else }}unread{{ end }}"
>{{ if eq .entry.Status "read" }}✘ {{ t "Unread" }}{{ else }}✔ {{ t "Read" }}{{ end }}</a>
</li>
{{ if .entry.CommentsURL }}
<li>
<a href="{{ .entry.CommentsURL }}" title="{{ t "View Comments" }}" target="_blank" rel="noopener noreferrer" referrerpolicy="no-referrer">{{ t "Comments" }}</a>
</li>
{{ end }}
</ul>
</div>
{{ end }}
+5 -4
View File
@@ -40,10 +40,11 @@
</div>
<ul>
<li {{ if eq .menu "unread" }}class="active"{{ end }} title="{{ t "Keyboard Shortcut: %s" "g u" }}">
<a href="{{ route "unread" }}" data-page="unread">{{ t "Unread" }}</a>
{{ if gt .countUnread 0 }}
<span class="unread-counter-wrapper">(<span class="unread-counter">{{ .countUnread }}</span>)</span>
{{ end }}
<a href="{{ route "unread" }}" data-page="unread">{{ t "Unread" }}
{{ if gt .countUnread 0 }}
<span class="unread-counter-wrapper">(<span class="unread-counter">{{ .countUnread }}</span>)</span>
{{ end }}
</a>
</li>
<li {{ if eq .menu "starred" }}class="active"{{ end }} title="{{ t "Keyboard Shortcut: %s" "g b" }}">
<a href="{{ route "starred" }}" data-page="starred">{{ t "Starred" }}</a>
+11 -9
View File
@@ -18,15 +18,17 @@
data-value="{{ if .Starred }}star{{ else }}unstar{{ end }}"
>{{ if .entry.Starred }}★ {{ t "Unstar" }}{{ else }}☆ {{ t "Star" }}{{ end }}</a>
</li>
<li>
<a href="#"
title="{{ t "Save this article" }}"
data-save-entry="true"
data-save-url="{{ route "saveEntry" "entryID" .entry.ID }}"
data-label-loading="{{ t "Saving..." }}"
data-label-done="{{ t "Done!" }}"
>{{ t "Save" }}</a>
</li>
{{ if .hasSaveEntry }}
<li>
<a href="#"
title="{{ t "Save this article" }}"
data-save-entry="true"
data-save-url="{{ route "saveEntry" "entryID" .entry.ID }}"
data-label-loading="{{ t "Saving..." }}"
data-label-done="{{ t "Done!" }}"
>{{ t "Save" }}</a>
</li>
{{ end }}
<li>
<a href="#"
title="{{ t "Fetch original content" }}"
+1 -1
View File
@@ -38,7 +38,7 @@
</span>
<span class="category"><a href="{{ route "categoryEntries" "categoryID" .Feed.Category.ID }}">{{ .Feed.Category.Title }}</a></span>
</div>
{{ template "item_meta" dict "user" $.user "entry" . }}
{{ template "item_meta" dict "user" $.user "entry" . "hasSaveEntry" $.hasSaveEntry }}
</article>
{{ end }}
</div>
@@ -27,7 +27,7 @@
</span>
<span class="category"><a href="{{ route "categoryEntries" "categoryID" .Feed.Category.ID }}">{{ .Feed.Category.Title }}</a></span>
</div>
{{ template "item_meta" dict "user" $.user "entry" . }}
{{ template "item_meta" dict "user" $.user "entry" . "hasSaveEntry" $.hasSaveEntry }}
</article>
{{ end }}
</div>
+20 -1
View File
@@ -73,6 +73,25 @@
<input type="password" name="instapaper_password" id="form-instapaper-password" value="{{ .form.InstapaperPassword }}">
</div>
<h3>Pocket</h3>
<div class="form-section">
<label>
<input type="checkbox" name="pocket_enabled" value="1" {{ if .form.PocketEnabled }}checked{{ end }}> {{ t "Save articles to Pocket" }}
</label>
{{ if not .hasPocketConsumerKeyConfigured }}
<label for="form-pocket-consumer-key">{{ t "Pocket Consumer Key" }}</label>
<input type="text" name="pocket_consumer_key" id="form-pocket-consumer-key" value="{{ .form.PocketConsumerKey }}">
{{ end }}
<label for="form-pocket-access-token">{{ t "Pocket Access Token" }}</label>
<input type="password" name="pocket_access_token" id="form-pocket-access-token" value="{{ .form.PocketAccessToken }}">
{{ if not .form.PocketAccessToken }}
<p><a href="{{ route "pocketAuthorize" }}">{{ t "Connect your Pocket account" }}</a></p>
{{ end }}
</div>
<h3>Wallabag</h3>
<div class="form-section">
<label>
@@ -94,7 +113,7 @@
<label for="form-wallabag-password">{{ t "Wallabag Password" }}</label>
<input type="password" name="wallabag_password" id="form-wallabag-password" value="{{ .form.WallabagPassword }}">
</div>
<h3>Nunux Keeper</h3>
<div class="form-section">
<label>
@@ -30,7 +30,7 @@
</span>
<span class="category"><a href="{{ route "categoryEntries" "categoryID" .Feed.Category.ID }}">{{ .Feed.Category.Title }}</a></span>
</div>
{{ template "item_meta" dict "user" $.user "entry" . }}
{{ template "item_meta" dict "user" $.user "entry" . "hasSaveEntry" $.hasSaveEntry }}
</article>
{{ end }}
</div>
+76 -55
View File
@@ -1,5 +1,5 @@
// Code generated by go generate; DO NOT EDIT.
// 2018-04-07 13:51:33.918407222 -0700 PDT m=+0.014850034
// 2018-05-21 12:14:59.39668234 -0700 PDT m=+0.007850936
package template
@@ -91,6 +91,37 @@ var templateViewsMap = map[string]string{
</form>
{{ end }}
{{ end }}
`,
"bookmark_entries": `{{ define "title"}}{{ t "Favorites" }} ({{ .total }}){{ end }}
{{ define "content"}}
<section class="page-header">
<h1>{{ t "Favorites" }} ({{ .total }})</h1>
</section>
{{ if not .entries }}
<p class="alert alert-info">{{ t "There is no bookmark at the moment." }}</p>
{{ else }}
<div class="items">
{{ range .entries }}
<article class="item touch-item item-status-{{ .Status }}" data-id="{{ .ID }}">
<div class="item-header">
<span class="item-title">
{{ if ne .Feed.Icon.IconID 0 }}
<img src="{{ route "icon" "iconID" .Feed.Icon.IconID }}" width="16" height="16">
{{ end }}
<a href="{{ route "starredEntry" "entryID" .ID }}">{{ .Title }}</a>
</span>
<span class="category"><a href="{{ route "categoryEntries" "categoryID" .Feed.Category.ID }}">{{ .Feed.Category.Title }}</a></span>
</div>
{{ template "item_meta" dict "user" $.user "entry" . "hasSaveEntry" $.hasSaveEntry }}
</article>
{{ end }}
</div>
{{ template "pagination" .pagination }}
{{ end }}
{{ end }}
`,
"categories": `{{ define "title"}}{{ t "Categories" }} ({{ .total }}){{ end }}
@@ -179,7 +210,7 @@ var templateViewsMap = map[string]string{
</span>
<span class="category"><a href="{{ route "categoryEntries" "categoryID" .Feed.Category.ID }}">{{ .Feed.Category.Title }}</a></span>
</div>
{{ template "item_meta" dict "user" $.user "entry" . }}
{{ template "item_meta" dict "user" $.user "entry" . "hasSaveEntry" $.hasSaveEntry }}
</article>
{{ end }}
</div>
@@ -483,15 +514,17 @@ var templateViewsMap = map[string]string{
data-value="{{ if .Starred }}star{{ else }}unstar{{ end }}"
>{{ if .entry.Starred }}{{ t "Unstar" }}{{ else }}{{ t "Star" }}{{ end }}</a>
</li>
<li>
<a href="#"
title="{{ t "Save this article" }}"
data-save-entry="true"
data-save-url="{{ route "saveEntry" "entryID" .entry.ID }}"
data-label-loading="{{ t "Saving..." }}"
data-label-done="{{ t "Done!" }}"
>{{ t "Save" }}</a>
</li>
{{ if .hasSaveEntry }}
<li>
<a href="#"
title="{{ t "Save this article" }}"
data-save-entry="true"
data-save-url="{{ route "saveEntry" "entryID" .entry.ID }}"
data-label-loading="{{ t "Saving..." }}"
data-label-done="{{ t "Done!" }}"
>{{ t "Save" }}</a>
</li>
{{ end }}
<li>
<a href="#"
title="{{ t "Fetch original content" }}"
@@ -618,7 +651,7 @@ var templateViewsMap = map[string]string{
</span>
<span class="category"><a href="{{ route "categoryEntries" "categoryID" .Feed.Category.ID }}">{{ .Feed.Category.Title }}</a></span>
</div>
{{ template "item_meta" dict "user" $.user "entry" . }}
{{ template "item_meta" dict "user" $.user "entry" . "hasSaveEntry" $.hasSaveEntry }}
</article>
{{ end }}
</div>
@@ -705,7 +738,7 @@ var templateViewsMap = map[string]string{
{{ end }}
`,
"history": `{{ define "title"}}{{ t "History" }} ({{ .total }}){{ end }}
"history_entries": `{{ define "title"}}{{ t "History" }} ({{ .total }}){{ end }}
{{ define "content"}}
<section class="page-header">
@@ -734,7 +767,7 @@ var templateViewsMap = map[string]string{
</span>
<span class="category"><a href="{{ route "categoryEntries" "categoryID" .Feed.Category.ID }}">{{ .Feed.Category.Title }}</a></span>
</div>
{{ template "item_meta" dict "user" $.user "entry" . }}
{{ template "item_meta" dict "user" $.user "entry" . "hasSaveEntry" $.hasSaveEntry }}
</article>
{{ end }}
</div>
@@ -853,6 +886,25 @@ var templateViewsMap = map[string]string{
<input type="password" name="instapaper_password" id="form-instapaper-password" value="{{ .form.InstapaperPassword }}">
</div>
<h3>Pocket</h3>
<div class="form-section">
<label>
<input type="checkbox" name="pocket_enabled" value="1" {{ if .form.PocketEnabled }}checked{{ end }}> {{ t "Save articles to Pocket" }}
</label>
{{ if not .hasPocketConsumerKeyConfigured }}
<label for="form-pocket-consumer-key">{{ t "Pocket Consumer Key" }}</label>
<input type="text" name="pocket_consumer_key" id="form-pocket-consumer-key" value="{{ .form.PocketConsumerKey }}">
{{ end }}
<label for="form-pocket-access-token">{{ t "Pocket Access Token" }}</label>
<input type="password" name="pocket_access_token" id="form-pocket-access-token" value="{{ .form.PocketAccessToken }}">
{{ if not .form.PocketAccessToken }}
<p><a href="{{ route "pocketAuthorize" }}">{{ t "Connect your Pocket account" }}</a></p>
{{ end }}
</div>
<h3>Wallabag</h3>
<div class="form-section">
<label>
@@ -874,7 +926,7 @@ var templateViewsMap = map[string]string{
<label for="form-wallabag-password">{{ t "Wallabag Password" }}</label>
<input type="password" name="wallabag_password" id="form-wallabag-password" value="{{ .form.WallabagPassword }}">
</div>
<h3>Nunux Keeper</h3>
<div class="form-section">
<label>
@@ -1090,38 +1142,7 @@ var templateViewsMap = map[string]string{
{{ end }}
`,
"starred": `{{ define "title"}}{{ t "Favorites" }} ({{ .total }}){{ end }}
{{ define "content"}}
<section class="page-header">
<h1>{{ t "Favorites" }} ({{ .total }})</h1>
</section>
{{ if not .entries }}
<p class="alert alert-info">{{ t "There is no bookmark at the moment." }}</p>
{{ else }}
<div class="items">
{{ range .entries }}
<article class="item touch-item item-status-{{ .Status }}" data-id="{{ .ID }}">
<div class="item-header">
<span class="item-title">
{{ if ne .Feed.Icon.IconID 0 }}
<img src="{{ route "icon" "iconID" .Feed.Icon.IconID }}" width="16" height="16">
{{ end }}
<a href="{{ route "starredEntry" "entryID" .ID }}">{{ .Title }}</a>
</span>
<span class="category"><a href="{{ route "categoryEntries" "categoryID" .Feed.Category.ID }}">{{ .Feed.Category.Title }}</a></span>
</div>
{{ template "item_meta" dict "user" $.user "entry" . }}
</article>
{{ end }}
</div>
{{ template "pagination" .pagination }}
{{ end }}
{{ end }}
`,
"unread": `{{ define "title"}}{{ t "Unread Items" }} {{ if gt .countUnread 0 }}({{ .countUnread }}){{ end }} {{ end }}
"unread_entries": `{{ define "title"}}{{ t "Unread Items" }} {{ if gt .countUnread 0 }}({{ .countUnread }}){{ end }} {{ end }}
{{ define "content"}}
<section class="page-header">
@@ -1153,7 +1174,7 @@ var templateViewsMap = map[string]string{
</span>
<span class="category"><a href="{{ route "categoryEntries" "categoryID" .Feed.Category.ID }}">{{ .Feed.Category.Title }}</a></span>
</div>
{{ template "item_meta" dict "user" $.user "entry" . }}
{{ template "item_meta" dict "user" $.user "entry" . "hasSaveEntry" $.hasSaveEntry }}
</article>
{{ end }}
</div>
@@ -1230,24 +1251,24 @@ var templateViewsMap = map[string]string{
var templateViewsMapChecksums = map[string]string{
"about": "ad2fb778fc73c39b733b3f81b13e5c7d689b041fadd24ee2d4577f545aa788ad",
"add_subscription": "053c920b0d7e109ea19dce6a448e304ce720db8633588ea04db16677f7209a7b",
"bookmark_entries": "8e5fea7559218a34289c2f0e54955fc0ef3b9e629205927841cbcc2276aefb2a",
"categories": "ca1280cd157bb527d4fc907da67b05a8347378f6dce965b9389d4bcdf3600a11",
"category_entries": "686132d71c52a665329670756ac09959d915f7bc3227970149c623059988b035",
"category_entries": "6ad52c8d0c28e21ea48be76228ea8432adde1dc190010753a48928477d52e065",
"choose_subscription": "a325f9c976ca2b2dc148e25c8fef0cf6ccab0e04e86e604e7812bb18dc4cdde1",
"create_category": "2b82af5d2dcd67898dc5daa57a6461e6ff8121a6089b2a2a1be909f35e4a2275",
"create_user": "233764778c915754141a20429ec8db9bf80ef2d7704867a2d7232c1e9df233ae",
"edit_category": "cee720faadcec58289b707ad30af623d2ee66c1ce23a732965463250d7ff41c5",
"edit_feed": "d2c1c8486d7faf4ee58151ccf3e3c690e53bd6872050d291c5db8452a83c3d53",
"edit_user": "321e0a60cf3bf7441bff970f4920e4c5b7c1883f80ab1d1674f8137954b25033",
"entry": "656a33552c3662c5d38a6178c811316ff55b38460b1f57d4087d51d631efe896",
"feed_entries": "420da786e827a77fecc8794207d158af3a30e489ca2b2019f48d5228919af4a7",
"entry": "bd611521ebb46714fce434fe7fa5d4e53e50da4c3ed02450ad3557f614f16e14",
"feed_entries": "4dffdb55cfad29df20612efe7ed2dbed03d919c4556898543ab6450f610d3c99",
"feeds": "2a5abe37968ea34a0576dbef52341645cb1fc9562e351382fbf721491da6f4fa",
"history": "967bc95236269ab3a77455910aca1939f43f93171fe1af77eb3b1b4eac579e55",
"history_entries": "451f0b202f47c9db5344d3e73862f5b7afbd4323fbdba21b6087866c40f045d3",
"import": "73b5112e20bfd232bf73334544186ea419505936bc237d481517a8622901878f",
"integrations": "979193f39c2a3b43cec192aa119713cc9cbe2d5fdaedf8d2b3573c752823446c",
"integrations": "4f93dab2630e6b3bf8bf51fc844e48fc763b8e7d6bb98c3c1692ea43f0e93930",
"login": "7d83c3067c02f1f6aafdd8816c7f97a4eb5a5a4bdaaaa4cc1e2fbb9c17ea65e8",
"sessions": "3fa79031dd883847eba92fbafe5f535fa3a4e1614bb610f20588b6f8fc8b3624",
"settings": "ea2505b9d0a6d6bb594dba87a92079de19baa6d494f0651693a7685489fb7de9",
"starred": "d006f5bcfca7abc7c68b6bb38fe25838acb81b65f960fbf06b97b259ba03b936",
"unread": "ad71a7bdd46c1d650efecbeeb37b7606a046027c15284c0712912b77397f90d4",
"unread_entries": "ca3ef1547d7d170b005a2f48fabd4c0a15550884db5e481659c13ffe6a47d19d",
"users": "c6d91b0b29984b4cb3073bec6a2933cfb72981ec60f54b6c7aa05194f0e860bd",
}
+21 -10
View File
@@ -5,21 +5,32 @@
package ui
import (
"github.com/miniflux/miniflux/http/handler"
"net/http"
"github.com/miniflux/miniflux/http/context"
"github.com/miniflux/miniflux/http/response/html"
"github.com/miniflux/miniflux/ui/session"
"github.com/miniflux/miniflux/ui/view"
"github.com/miniflux/miniflux/version"
)
// AboutPage shows the about page.
func (c *Controller) AboutPage(ctx *handler.Context, request *handler.Request, response *handler.Response) {
args, err := c.getCommonTemplateArgs(ctx)
// About shows the about page.
func (c *Controller) About(w http.ResponseWriter, r *http.Request) {
ctx := context.New(r)
user, err := c.store.UserByID(ctx.UserID())
if err != nil {
response.HTML().ServerError(err)
html.ServerError(w, err)
return
}
response.HTML().Render("about", args.Merge(tplParams{
"version": version.Version,
"build_date": version.BuildDate,
"menu": "settings",
}))
sess := session.New(c.store, ctx)
view := view.New(c.tpl, ctx, sess)
view.Set("version", version.Version)
view.Set("build_date", version.BuildDate)
view.Set("menu", "settings")
view.Set("user", user)
view.Set("countUnread", c.store.CountUnreadEntries(user.ID))
html.OK(w, view.Render("about"))
}
+62
View File
@@ -0,0 +1,62 @@
// Copyright 2018 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 ui
import (
"net/http"
"github.com/miniflux/miniflux/http/context"
"github.com/miniflux/miniflux/http/request"
"github.com/miniflux/miniflux/http/response/html"
"github.com/miniflux/miniflux/http/route"
"github.com/miniflux/miniflux/model"
"github.com/miniflux/miniflux/ui/session"
"github.com/miniflux/miniflux/ui/view"
)
// ShowStarredPage renders the page with all starred entries.
func (c *Controller) ShowStarredPage(w http.ResponseWriter, r *http.Request) {
ctx := context.New(r)
user, err := c.store.UserByID(ctx.UserID())
if err != nil {
html.ServerError(w, err)
return
}
offset := request.QueryIntParam(r, "offset", 0)
builder := c.store.NewEntryQueryBuilder(user.ID)
builder.WithoutStatus(model.EntryStatusRemoved)
builder.WithStarred()
builder.WithOrder(model.DefaultSortingOrder)
builder.WithDirection(user.EntryDirection)
builder.WithOffset(offset)
builder.WithLimit(nbItemsPerPage)
entries, err := builder.GetEntries()
if err != nil {
html.ServerError(w, err)
return
}
count, err := builder.CountEntries()
if err != nil {
html.ServerError(w, err)
return
}
sess := session.New(c.store, ctx)
view := view.New(c.tpl, ctx, sess)
view.Set("total", count)
view.Set("entries", entries)
view.Set("pagination", c.getPagination(route.Path(c.router, "starred"), count, offset))
view.Set("menu", "starred")
view.Set("user", user)
view.Set("countUnread", c.store.CountUnreadEntries(user.ID))
view.Set("hasSaveEntry", c.store.HasSaveEntry(user.ID))
html.OK(w, view.Render("bookmark_entries"))
}
-257
View File
@@ -1,257 +0,0 @@
// 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 ui
import (
"errors"
"github.com/miniflux/miniflux/http/handler"
"github.com/miniflux/miniflux/logger"
"github.com/miniflux/miniflux/model"
"github.com/miniflux/miniflux/ui/form"
)
// ShowCategories shows the page with all categories.
func (c *Controller) ShowCategories(ctx *handler.Context, request *handler.Request, response *handler.Response) {
args, err := c.getCommonTemplateArgs(ctx)
if err != nil {
response.HTML().ServerError(err)
return
}
user := ctx.LoggedUser()
categories, err := c.store.CategoriesWithFeedCount(user.ID)
if err != nil {
response.HTML().ServerError(err)
return
}
response.HTML().Render("categories", args.Merge(tplParams{
"categories": categories,
"total": len(categories),
"menu": "categories",
}))
}
// ShowCategoryEntries shows all entries for the given category.
func (c *Controller) ShowCategoryEntries(ctx *handler.Context, request *handler.Request, response *handler.Response) {
user := ctx.LoggedUser()
offset := request.QueryIntegerParam("offset", 0)
args, err := c.getCommonTemplateArgs(ctx)
if err != nil {
response.HTML().ServerError(err)
return
}
category, err := c.getCategoryFromURL(ctx, request, response)
if err != nil {
return
}
builder := c.store.NewEntryQueryBuilder(user.ID)
builder.WithCategoryID(category.ID)
builder.WithOrder(model.DefaultSortingOrder)
builder.WithDirection(user.EntryDirection)
builder.WithoutStatus(model.EntryStatusRemoved)
builder.WithOffset(offset)
builder.WithLimit(nbItemsPerPage)
entries, err := builder.GetEntries()
if err != nil {
response.HTML().ServerError(err)
return
}
count, err := builder.CountEntries()
if err != nil {
response.HTML().ServerError(err)
return
}
response.HTML().Render("category_entries", args.Merge(tplParams{
"category": category,
"entries": entries,
"total": count,
"pagination": c.getPagination(ctx.Route("categoryEntries", "categoryID", category.ID), count, offset),
"menu": "categories",
}))
}
// CreateCategory shows the form to create a new category.
func (c *Controller) CreateCategory(ctx *handler.Context, request *handler.Request, response *handler.Response) {
args, err := c.getCommonTemplateArgs(ctx)
if err != nil {
response.HTML().ServerError(err)
return
}
response.HTML().Render("create_category", args.Merge(tplParams{
"menu": "categories",
}))
}
// SaveCategory validate and save the new category into the database.
func (c *Controller) SaveCategory(ctx *handler.Context, request *handler.Request, response *handler.Response) {
user := ctx.LoggedUser()
args, err := c.getCommonTemplateArgs(ctx)
if err != nil {
response.HTML().ServerError(err)
return
}
categoryForm := form.NewCategoryForm(request.Request())
if err := categoryForm.Validate(); err != nil {
response.HTML().Render("create_category", args.Merge(tplParams{
"errorMessage": err.Error(),
}))
return
}
duplicateCategory, err := c.store.CategoryByTitle(user.ID, categoryForm.Title)
if err != nil {
response.HTML().ServerError(err)
return
}
if duplicateCategory != nil {
response.HTML().Render("create_category", args.Merge(tplParams{
"errorMessage": "This category already exists.",
}))
return
}
category := model.Category{Title: categoryForm.Title, UserID: user.ID}
err = c.store.CreateCategory(&category)
if err != nil {
logger.Info("[Controller:CreateCategory] %v", err)
response.HTML().Render("create_category", args.Merge(tplParams{
"errorMessage": "Unable to create this category.",
}))
return
}
response.Redirect(ctx.Route("categories"))
}
// EditCategory shows the form to modify a category.
func (c *Controller) EditCategory(ctx *handler.Context, request *handler.Request, response *handler.Response) {
user := ctx.LoggedUser()
category, err := c.getCategoryFromURL(ctx, request, response)
if err != nil {
logger.Error("[Controller:EditCategory] %v", err)
return
}
args, err := c.getCategoryFormTemplateArgs(ctx, user, category, nil)
if err != nil {
response.HTML().ServerError(err)
return
}
response.HTML().Render("edit_category", args)
}
// UpdateCategory validate and update a category.
func (c *Controller) UpdateCategory(ctx *handler.Context, request *handler.Request, response *handler.Response) {
user := ctx.LoggedUser()
category, err := c.getCategoryFromURL(ctx, request, response)
if err != nil {
logger.Error("[Controller:UpdateCategory] %v", err)
return
}
categoryForm := form.NewCategoryForm(request.Request())
args, err := c.getCategoryFormTemplateArgs(ctx, user, category, categoryForm)
if err != nil {
response.HTML().ServerError(err)
return
}
if err := categoryForm.Validate(); err != nil {
response.HTML().Render("edit_category", args.Merge(tplParams{
"errorMessage": err.Error(),
}))
return
}
if c.store.AnotherCategoryExists(user.ID, category.ID, categoryForm.Title) {
response.HTML().Render("edit_category", args.Merge(tplParams{
"errorMessage": "This category already exists.",
}))
return
}
err = c.store.UpdateCategory(categoryForm.Merge(category))
if err != nil {
logger.Error("[Controller:UpdateCategory] %v", err)
response.HTML().Render("edit_category", args.Merge(tplParams{
"errorMessage": "Unable to update this category.",
}))
return
}
response.Redirect(ctx.Route("categories"))
}
// RemoveCategory delete a category from the database.
func (c *Controller) RemoveCategory(ctx *handler.Context, request *handler.Request, response *handler.Response) {
user := ctx.LoggedUser()
category, err := c.getCategoryFromURL(ctx, request, response)
if err != nil {
return
}
if err := c.store.RemoveCategory(user.ID, category.ID); err != nil {
response.HTML().ServerError(err)
return
}
response.Redirect(ctx.Route("categories"))
}
func (c *Controller) getCategoryFromURL(ctx *handler.Context, request *handler.Request, response *handler.Response) (*model.Category, error) {
categoryID, err := request.IntegerParam("categoryID")
if err != nil {
response.HTML().BadRequest(err)
return nil, err
}
user := ctx.LoggedUser()
category, err := c.store.Category(user.ID, categoryID)
if err != nil {
response.HTML().ServerError(err)
return nil, err
}
if category == nil {
response.HTML().NotFound()
return nil, errors.New("Category not found")
}
return category, nil
}
func (c *Controller) getCategoryFormTemplateArgs(ctx *handler.Context, user *model.User, category *model.Category, categoryForm *form.CategoryForm) (tplParams, error) {
args, err := c.getCommonTemplateArgs(ctx)
if err != nil {
return nil, err
}
if categoryForm == nil {
args["form"] = form.CategoryForm{
Title: category.Title,
}
} else {
args["form"] = categoryForm
}
args["category"] = category
args["menu"] = "categories"
return args, nil
}
+33
View File
@@ -0,0 +1,33 @@
// Copyright 2018 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 ui
import (
"net/http"
"github.com/miniflux/miniflux/http/context"
"github.com/miniflux/miniflux/http/response/html"
"github.com/miniflux/miniflux/ui/session"
"github.com/miniflux/miniflux/ui/view"
)
// CreateCategory shows the form to create a new category.
func (c *Controller) CreateCategory(w http.ResponseWriter, r *http.Request) {
ctx := context.New(r)
user, err := c.store.UserByID(ctx.UserID())
if err != nil {
html.ServerError(w, err)
return
}
sess := session.New(c.store, ctx)
view := view.New(c.tpl, ctx, sess)
view.Set("menu", "categories")
view.Set("user", user)
view.Set("countUnread", c.store.CountUnreadEntries(user.ID))
html.OK(w, view.Render("create_category"))
}
+58
View File
@@ -0,0 +1,58 @@
// Copyright 2018 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 ui
import (
"net/http"
"github.com/miniflux/miniflux/http/context"
"github.com/miniflux/miniflux/http/request"
"github.com/miniflux/miniflux/http/response/html"
"github.com/miniflux/miniflux/ui/form"
"github.com/miniflux/miniflux/ui/session"
"github.com/miniflux/miniflux/ui/view"
)
// EditCategory shows the form to modify a category.
func (c *Controller) EditCategory(w http.ResponseWriter, r *http.Request) {
ctx := context.New(r)
sess := session.New(c.store, ctx)
view := view.New(c.tpl, ctx, sess)
user, err := c.store.UserByID(ctx.UserID())
if err != nil {
html.ServerError(w, err)
return
}
categoryID, err := request.IntParam(r, "categoryID")
if err != nil {
html.BadRequest(w, err)
return
}
category, err := c.store.Category(ctx.UserID(), categoryID)
if err != nil {
html.ServerError(w, err)
return
}
if category == nil {
html.NotFound(w)
return
}
categoryForm := form.CategoryForm{
Title: category.Title,
}
view.Set("form", categoryForm)
view.Set("category", category)
view.Set("menu", "categories")
view.Set("user", user)
view.Set("countUnread", c.store.CountUnreadEntries(user.ID))
html.OK(w, view.Render("edit_category"))
}
+79
View File
@@ -0,0 +1,79 @@
// Copyright 2018 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 ui
import (
"net/http"
"github.com/miniflux/miniflux/http/context"
"github.com/miniflux/miniflux/http/request"
"github.com/miniflux/miniflux/http/response/html"
"github.com/miniflux/miniflux/http/route"
"github.com/miniflux/miniflux/model"
"github.com/miniflux/miniflux/ui/session"
"github.com/miniflux/miniflux/ui/view"
)
// CategoryEntries shows all entries for the given category.
func (c *Controller) CategoryEntries(w http.ResponseWriter, r *http.Request) {
ctx := context.New(r)
user, err := c.store.UserByID(ctx.UserID())
if err != nil {
html.ServerError(w, err)
return
}
categoryID, err := request.IntParam(r, "categoryID")
if err != nil {
html.BadRequest(w, err)
return
}
category, err := c.store.Category(ctx.UserID(), categoryID)
if err != nil {
html.ServerError(w, err)
return
}
if category == nil {
html.NotFound(w)
return
}
offset := request.QueryIntParam(r, "offset", 0)
builder := c.store.NewEntryQueryBuilder(user.ID)
builder.WithCategoryID(category.ID)
builder.WithOrder(model.DefaultSortingOrder)
builder.WithDirection(user.EntryDirection)
builder.WithoutStatus(model.EntryStatusRemoved)
builder.WithOffset(offset)
builder.WithLimit(nbItemsPerPage)
entries, err := builder.GetEntries()
if err != nil {
html.ServerError(w, err)
return
}
count, err := builder.CountEntries()
if err != nil {
html.ServerError(w, err)
return
}
sess := session.New(c.store, ctx)
view := view.New(c.tpl, ctx, sess)
view.Set("category", category)
view.Set("total", count)
view.Set("entries", entries)
view.Set("pagination", c.getPagination(route.Path(c.router, "categoryEntries", "categoryID", category.ID), count, offset))
view.Set("menu", "categories")
view.Set("user", user)
view.Set("countUnread", c.store.CountUnreadEntries(user.ID))
view.Set("hasSaveEntry", c.store.HasSaveEntry(user.ID))
html.OK(w, view.Render("category_entries"))
}
+41
View File
@@ -0,0 +1,41 @@
// Copyright 2018 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 ui
import (
"net/http"
"github.com/miniflux/miniflux/http/context"
"github.com/miniflux/miniflux/http/response/html"
"github.com/miniflux/miniflux/ui/session"
"github.com/miniflux/miniflux/ui/view"
)
// CategoryList shows the page with all categories.
func (c *Controller) CategoryList(w http.ResponseWriter, r *http.Request) {
ctx := context.New(r)
user, err := c.store.UserByID(ctx.UserID())
if err != nil {
html.ServerError(w, err)
return
}
categories, err := c.store.CategoriesWithFeedCount(user.ID)
if err != nil {
html.ServerError(w, err)
return
}
sess := session.New(c.store, ctx)
view := view.New(c.tpl, ctx, sess)
view.Set("categories", categories)
view.Set("total", len(categories))
view.Set("menu", "categories")
view.Set("user", user)
view.Set("countUnread", c.store.CountUnreadEntries(user.ID))
html.OK(w, view.Render("categories"))
}
+50
View File
@@ -0,0 +1,50 @@
// Copyright 2018 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 ui
import (
"net/http"
"github.com/miniflux/miniflux/http/context"
"github.com/miniflux/miniflux/http/request"
"github.com/miniflux/miniflux/http/response"
"github.com/miniflux/miniflux/http/response/html"
"github.com/miniflux/miniflux/http/route"
)
// RemoveCategory deletes a category from the database.
func (c *Controller) RemoveCategory(w http.ResponseWriter, r *http.Request) {
ctx := context.New(r)
user, err := c.store.UserByID(ctx.UserID())
if err != nil {
html.ServerError(w, err)
return
}
categoryID, err := request.IntParam(r, "categoryID")
if err != nil {
html.BadRequest(w, err)
return
}
category, err := c.store.Category(ctx.UserID(), categoryID)
if err != nil {
html.ServerError(w, err)
return
}
if category == nil {
html.NotFound(w)
return
}
if err := c.store.RemoveCategory(user.ID, category.ID); err != nil {
html.ServerError(w, err)
return
}
response.Redirect(w, r, route.Path(c.router, "categories"))
}

Some files were not shown because too many files have changed in this diff Show More