Compare commits

...

39 Commits

Author SHA1 Message Date
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
Frédéric Guillot 1ce522b98a Update ChangeLog 2018-04-20 21:56:38 -07:00
Frédéric Guillot 657e96e133 Improve graceful shutdown 2018-04-17 21:50:52 -07:00
Frédéric Guillot 0429bbb19d Simplify Heroku deployment 2018-04-15 21:07:59 -07:00
Frédéric Guillot 45dde0cf4a Display memory usage and some metrics in logs 2018-04-14 14:23:05 -07:00
Frédéric Guillot 4cdb2f820b Increase read/write timeout for HTTP server 2018-04-14 13:52:53 -07:00
Frédéric Guillot dcbb5047b1 Add support for Dublin Core date in RDF feeds 2018-04-10 18:13:05 -07:00
Frédéric Guillot 15202b8675 Do not return an error if the user session is not found 2018-04-09 21:52:24 -07:00
Frédéric Guillot 02ba735ba9 Handle some non-english date formats 2018-04-09 21:27:15 -07:00
Frédéric Guillot 20f874399d Add missing French translation 2018-04-09 20:39:56 -07:00
Frédéric Guillot e2d02bac5a Rename RSS parser getters 2018-04-09 20:38:12 -07:00
Frédéric Guillot f76093690c Get the right comments URL when having multiple namespaces 2018-04-09 20:30:55 -07:00
Frédéric Guillot 7640a8cbab Ignore caching headers for feeds that send "Expires: 0" 2018-04-09 20:18:54 -07:00
stratmaster 3d59cdba10 Add missing translation string 2018-04-09 18:24:24 -07:00
stratmaster d3855fef3f Update German translation 2018-04-09 10:33:40 -07:00
Frédéric Guillot 336d44b00e Update ChangeLog 2018-04-07 15:47:18 -07:00
Frédéric Guillot 46d67acf22 Avoid unread counter to be off by one 2018-04-07 14:20:42 -07:00
Frédéric Guillot 702256bcc0 Add unit test for comments url and French translation 2018-04-07 13:56:11 -07:00
Ben Brooks 538d08c16c Add CommentsURL to entry 2018-04-07 13:50:45 -07:00
Frédéric Guillot 449020c1e8 Update .gitignore 2018-03-18 20:48:58 -07:00
MoritzFago c811849771 Add FreeBSD build target 2018-03-18 20:44:13 -07:00
Frédéric Guillot 6ea4da3bce Handle RSS author elements with inner HTML 2018-03-18 11:57:46 -07:00
Frédéric Guillot 34cdffda88 Fix typo in translations 2018-03-17 13:39:08 -07:00
Daan Sprenkels b1da081ae6 Add dutch translations 2018-03-17 13:33:02 -07:00
Frédéric Guillot 482785c5e6 Convert enclosure size field to bigint 2018-03-14 20:09:06 -07:00
Frédéric Guillot fec391a336 Switch Travis to Go 1.10 2018-03-14 18:51:41 -07:00
Frédéric Guillot ec08f45bf5 Fix broken OPML import with Go 1.10 2018-03-14 18:50:06 -07:00
194 changed files with 6784 additions and 3949 deletions
+1 -4
View File
@@ -1,4 +1 @@
miniflux-linux-amd64
miniflux-linux-arm*
miniflux-darwin-amd64
miniflux-test
miniflux-*
+1 -1
View File
@@ -6,7 +6,7 @@ addons:
postgresql: "9.4"
language: go
go:
- 1.9
- "1.10"
before_install:
- npm install -g jshint
- go get -u github.com/golang/lint/golint
+45
View File
@@ -1,3 +1,48 @@
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)
----------------------------
* Improve graceful shutdown
* Simplify Heroku deployment
* Display memory usage and some metrics in logs
* Increase read/write timeout for HTTP server
* Add support for Dublin Core date in RDF feeds
* Do not return an error if the user session is not found
* Handle some non-english date formats
* Add missing French translation
* Rename RSS parser getters
* Get the right comments URL when having multiple namespaces
* Ignore caching headers for feeds that send "Expires: 0"
* Update translations
Version 2.0.5 (Apr 7, 2018)
---------------------------
* Avoid unread counter to be off by one when reading an entry
* Add Comments URL to entries
* Add FreeBSD build target
* Handle RSS author elements with inner HTML
* Fix typo in translations
* Add Dutch translation
* Convert enclosure size field to bigint
* Switch CI to Go v1.10
* Fix broken OPML import when compiling with Go 1.10
Version 2.0.4 (Mar 5, 2018)
---------------------------
Generated
+4 -4
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 = "7939463a4e1a1c5392d026d8d28bf7732459abd7"
[[projects]]
name = "github.com/tdewolff/minify"
@@ -158,6 +158,6 @@
[solve-meta]
analyzer-name = "dep"
analyzer-version = 1
inputs-digest = "338222e5111416c46b2b8bde149443abc542b386dd02aff2a0dd6e13334bcf28"
inputs-digest = "0cb64ea4a8054f26b80103c60380d800d74bdd2faec4ef53453874922e7de748"
solver-name = "gps-cdcl"
solver-version = 1
+5 -1
View File
@@ -20,6 +20,10 @@
# name = "github.com/x/y"
# version = "2.4.0"
[metadata.heroku]
root-package = "github.com/miniflux/miniflux"
go-version = "go1.10"
ensure = "false"
[[constraint]]
name = "github.com/PuerkitoBio/goquery"
@@ -27,7 +31,7 @@
[[constraint]]
name = "github.com/gorilla/mux"
version = "1.6.0"
version = "1.6.1"
[[constraint]]
branch = "master"
+6 -2
View File
@@ -4,7 +4,7 @@ BUILD_DATE=`date +%FT%T%z`
PKG_LIST := $(shell go list ./... | grep -v /vendor/)
DB_URL := postgres://postgres:postgres@localhost/miniflux_test?sslmode=disable
.PHONY: linux linux-arm darwin build run clean test lint integration-test clean-integration-test
.PHONY: linux linux-arm darwin freebsd build run clean test lint integration-test clean-integration-test
linux:
@ go generate
@@ -21,7 +21,11 @@ darwin:
@ go generate
@ GOOS=darwin GOARCH=amd64 go build -ldflags="-X 'github.com/miniflux/miniflux/version.Version=$(VERSION)' -X 'github.com/miniflux/miniflux/version.BuildDate=$(BUILD_DATE)'" -o $(APP)-darwin-amd64 main.go
build: linux linux-arm darwin
freebsd:
@ go generate
@ GOOS=freebsd GOARCH=amd64 go build -ldflags="-X 'githug.com/miniflux/miniflux/version.Version=$(VERSION)' -X 'github.com/miniflux/miniflux/version.BuildDate=$(BUILD_DATE)'" -o $(APP)-freebsd-amd64 main.go
build: linux linux-arm darwin freebsd
run:
@ go generate
+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)
}
+54 -45
View File
@@ -6,182 +6,191 @@ 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) {
func (c *Controller) CreateUser(w http.ResponseWriter, r *http.Request) {
ctx := context.New(r)
if !ctx.IsAdminUser() {
response.JSON().Forbidden()
json.Forbidden(w)
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.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)
}
+11 -2
View File
@@ -8,10 +8,9 @@ import (
"flag"
"fmt"
"github.com/miniflux/miniflux/logger"
"github.com/miniflux/miniflux/config"
"github.com/miniflux/miniflux/daemon"
"github.com/miniflux/miniflux/logger"
"github.com/miniflux/miniflux/storage"
"github.com/miniflux/miniflux/version"
)
@@ -68,5 +67,15 @@ func Parse() {
return
}
// Run migrations and start the deamon.
if cfg.RunMigrations() {
store.Migrate()
}
// Create admin user and start the deamon.
if cfg.CreateAdmin() {
createAdmin(store)
}
daemon.Run(cfg, store)
}
+14
View File
@@ -110,6 +110,10 @@ func (c *Config) DatabaseMaxConnections() int {
// ListenAddr returns the listen address for the HTTP server.
func (c *Config) ListenAddr() string {
if port := os.Getenv("PORT"); port != "" {
return ":" + port
}
return c.get("LISTEN_ADDR", defaultListenAddr)
}
@@ -183,6 +187,16 @@ func (c *Config) HasHSTS() bool {
return c.get("DISABLE_HSTS", "") == ""
}
// RunMigrations returns true if the environment variable RUN_MIGRATIONS is not empty.
func (c *Config) RunMigrations() bool {
return c.get("RUN_MIGRATIONS", "") != ""
}
// CreateAdmin returns true if the environment variable CREATE_ADMIN is not empty.
func (c *Config) CreateAdmin() bool {
return c.get("CREATE_ADMIN", "") != ""
}
// NewConfig returns a new Config.
func NewConfig() *Config {
return &Config{IsHTTPS: os.Getenv("HTTPS") != ""}
+14 -1
View File
@@ -8,6 +8,7 @@ import (
"context"
"os"
"os/signal"
"runtime"
"syscall"
"time"
@@ -27,6 +28,16 @@ func Run(cfg *config.Config, store *storage.Storage) {
signal.Notify(stop, os.Interrupt)
signal.Notify(stop, syscall.SIGTERM)
go func() {
for {
var m runtime.MemStats
runtime.ReadMemStats(&m)
logger.Debug("Alloc=%vK, TotalAlloc=%vK, Sys=%vK, NumGC=%v, GoRoutines=%d, NumCPU=%d",
m.Alloc/1024, m.TotalAlloc/1024, m.Sys/1024, m.NumGC, runtime.NumGoroutine(), runtime.NumCPU())
time.Sleep(30 * time.Second)
}
}()
translator := locale.Load()
feedHandler := feed.NewFeedHandler(store, translator)
pool := scheduler.NewWorkerPool(feedHandler, cfg.WorkerPoolSize())
@@ -43,7 +54,9 @@ func Run(cfg *config.Config, store *storage.Storage) {
<-stop
logger.Info("Shutting down the server...")
ctx, _ := context.WithTimeout(context.Background(), 5*time.Second)
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
server.Shutdown(ctx)
store.Close()
logger.Info("Server gracefully stopped")
+104 -127
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,
))
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,103 @@ 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("/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("/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
}
+2 -2
View File
@@ -25,8 +25,8 @@ func newServer(cfg *config.Config, store *storage.Storage, pool *scheduler.Worke
certDomain := cfg.CertDomain()
certCache := cfg.CertCache()
server := &http.Server{
ReadTimeout: 5 * time.Second,
WriteTimeout: 10 * time.Second,
ReadTimeout: 30 * time.Second,
WriteTimeout: 30 * time.Second,
IdleTimeout: 60 * time.Second,
Addr: cfg.ListenAddr(),
Handler: routes(cfg, store, feedHandler, pool, translator),
+78 -63
View File
@@ -5,11 +5,14 @@
package fever
import (
"net/http"
"strconv"
"strings"
"time"
"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/integration"
"github.com/miniflux/miniflux/logger"
"github.com/miniflux/miniflux/model"
@@ -129,28 +132,28 @@ type Controller struct {
}
// 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 +177,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 +201,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 +228,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 +260,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 +282,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 +302,7 @@ func (c *Controller) handleFavicons(ctx *handler.Context, request *handler.Reque
}
result.SetCommonValues()
response.JSON().Standard(result)
json.OK(w, result)
}
/*
@@ -330,9 +336,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 +349,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 +374,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,7 +382,7 @@ 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
}
@@ -404,7 +411,7 @@ func (c *Controller) handleItems(ctx *handler.Context, request *handler.Request,
}
result.SetCommonValues()
response.JSON().Standard(result)
json.OK(w, result)
}
/*
@@ -414,7 +421,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 +430,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 +442,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 +453,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 +463,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 +474,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 +482,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 +498,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,20 +506,23 @@ 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
}
@@ -518,7 +531,7 @@ func (c *Controller) handleWriteItems(ctx *handler.Context, request *handler.Req
}()
}
response.JSON().Standard(newBaseResponse())
json.OK(w, newBaseResponse())
}
/*
@@ -527,11 +540,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 +554,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 +562,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 +581,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 +595,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 +603,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())
}
/*
+42 -22
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"),
@@ -129,17 +156,25 @@ func (c *Client) executeRequest(request *http.Request) (*Response, error) {
ContentLength: resp.ContentLength,
}
logger.Debug("[HttpClient:%s] OriginalURL=%s, StatusCode=%d, ContentLength=%d, ContentType=%s, ETag=%s, LastModified=%s, EffectiveURL=%s",
logger.Debug("[HttpClient:%s] URL=%s, EffectiveURL=%s, Code=%d, Length=%d, Type=%s, ETag=%s, LastMod=%s, Expires=%s",
request.Method,
c.url,
response.EffectiveURL,
response.StatusCode,
resp.ContentLength,
response.ContentType,
response.ETag,
response.LastModified,
response.EffectiveURL,
resp.Header.Get("Expires"),
)
// Ignore caching headers for feeds that do not want any cache.
if resp.Header.Get("Expires") == "0" {
logger.Debug("[HttpClient] Ignore caching headers for %q", response.EffectiveURL)
response.ETag = ""
response.LastModified = ""
}
return response, err
}
@@ -189,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"
+108
View File
@@ -0,0 +1,108 @@
// 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)
}
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}
}
+90
View File
@@ -0,0 +1,90 @@
// 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/http"
"strconv"
"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
}
+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))
}
+8 -3
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,13 +19,18 @@ 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 response.HasServerFailure() {
return fmt.Errorf("instapaper: unable to send url, status=%d", response.StatusCode)
}
+4 -4
View File
@@ -25,14 +25,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 +46,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 +57,7 @@ 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)
}
}
}
+9 -3
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,8 +43,10 @@ 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 response.HasServerFailure() {
return fmt.Errorf("nunux-keeper: unable to send entry, status=%d", response.StatusCode)
}
+7 -3
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,8 +34,8 @@ 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 response.HasServerFailure() {
return fmt.Errorf("pinboard: unable to send bookmark, status=%d", response.StatusCode)
}
+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)
}
+28
View File
@@ -7,6 +7,8 @@
package main
import (
"bytes"
"io/ioutil"
"math/rand"
"strconv"
"strings"
@@ -653,6 +655,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)
+1
View File
@@ -32,5 +32,6 @@ func AvailableLanguages() map[string]string {
"de_DE": "Deutsch",
"pl_PL": "Polski",
"zh_CN": "简体中文",
"nl_NL": "Nederlands",
}
}
+244 -15
View File
@@ -1,5 +1,5 @@
// Code generated by go generate; DO NOT EDIT.
// 2018-02-27 21:15:00.586846241 -0800 PST m=+0.029084447
// 2018-04-29 16:59:49.591693595 -0700 PDT m=+0.022587229
package locale
@@ -103,7 +103,7 @@ var translations = map[string]string{
"The username, theme, language and timezone fields are mandatory.": "Die Felder für Benutzername, Thema, Sprache und Zeitzone sind obligatorisch.",
"The title is mandatory.": "Der Titel ist obligatorisch.",
"About": "Über",
"version": "Version",
"Version": "Version",
"Version:": "Version :",
"Build Date:": "Datum der Kompilierung:",
"Author:": "Autor:",
@@ -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",
@@ -223,7 +223,9 @@ var translations = map[string]string{
"Invalid SSL certificate (original error: %q)": "Ungültiges SSL-Zertifikat (ursprünglicher Fehler: %q)",
"This website is temporarily unreachable (original error: %q)": "Diese Webseite ist vorübergehend nicht erreichbar (ursprünglicher Fehler: %q)",
"This website is permanently unreachable (original error: %q)": "Diese Webseite ist dauerhaft nicht erreichbar (ursprünglicher Fehler: %q)",
"Website unreachable, the request timed out after %d seconds": "Webseite nicht erreichbar, die Anfrage endete nach %d Sekunden"
"Website unreachable, the request timed out after %d seconds": "Webseite nicht erreichbar, die Anfrage endete nach %d Sekunden",
"Comments": "Kommentare",
"View Comments": "Kommentare anzeigen"
}
`,
"en_US": `{
@@ -335,7 +337,7 @@ var translations = map[string]string{
"The username, theme, language and timezone fields are mandatory.": "Le nom d'utilisateur, le thème, la langue et le fuseau horaire sont obligatoire.",
"The title is mandatory.": "Le titre est obligatoire.",
"About": "A propos",
"version": "Version",
"Version": "Version",
"Version:": "Version :",
"Build Date:": "Date de la compilation :",
"Author:": "Auteur :",
@@ -393,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",
@@ -455,7 +457,233 @@ var translations = map[string]string{
"Invalid SSL certificate (original error: %q)": "Certificat SSL invalide (erreur originale : %q)",
"This website is temporarily unreachable (original error: %q)": "Ce site web est temporairement injoignable (erreur originale : %q)",
"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"
"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",
"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."
}
`,
"nl_NL": `{
"plural.feed.error_count": [
"%d error",
"%d errors"
],
"plural.categories.feed_count": [
"Er is %d feed.",
"Er zijn %d feeds."
],
"Username": "Gebruikersnaam",
"Password": "Wachtwoord",
"Unread": "Ongelezen",
"History": "Geschiedenis",
"Feeds": "Feeds",
"Categories": "Categorieën",
"Settings": "Instellingen",
"Logout": "Uitloggen",
"Next": "Volgende",
"Previous": "Vorige",
"New Subscription": "Nieuwe feed",
"Import": "Importeren",
"Export": "Exporteren",
"There is no category. You must have at least one category.": "Er zijn geen categorieën. Je moet op zijn minst één caterogie hebben.",
"URL": "URL",
"Category": "Categorie",
"Find a subscription": "Feed zoeken",
"Loading...": "Laden...",
"Create a category": "Categorie toevoegen",
"There is no category.": "Er zijn geen categorieën.",
"Edit": "Bewerken",
"Remove": "Verwijderen",
"No feed.": "Geen feeds.",
"There is no article in this category.": "Deze categorie bevat geen feeds.",
"Original": "Origineel",
"Mark this page as read": "Markeer deze pagina als gelezen",
"not yet": "in de toekomst",
"just now": "minder dan een minuut geleden",
"1 minute ago": "een minuut geleden",
"%d minutes ago": "%d minuten geleden",
"1 hour ago": "een uur geleden",
"%d hours ago": "%d uur geleden",
"yesterday": "gisteren",
"%d days ago": "%d dagen geleden",
"%d weeks ago": "%d weken geleden",
"%d months ago": "%d maanden geleden",
"%d years ago": "%d jaar geleden",
"Date": "Datum",
"IP Address": "IP-adres",
"User Agent": "User-agent",
"Actions": "Acties",
"Current session": "Huidige sessie",
"Sessions": "Sessies",
"Users": "Gebruikers",
"Add user": "Gebruiker toevoegen",
"Choose a Subscription": "Feed kiezen",
"Subscribe": "Abboneren",
"New Category": "Nieuwe categorie",
"Title": "Naam",
"Save": "Opslaan",
"or": "of",
"cancel": "annuleren",
"New User": "Nieuwe gebruiker",
"Confirmation": "Bevestig wachtwoord",
"Administrator": "Administrator",
"Edit Category: %s": "Bewerken van categorie: %s",
"Update": "Updaten",
"Edit Feed: %s": "Bewerken van feed: %s",
"There is no category!": "Er zijn geen categorieën!",
"Edit user: %s": "Gebruiker aanpassen: %s",
"There is no article for this feed.": "Er zijn geen artikelen in deze feed.",
"Add subscription": "Feed toevoegen",
"You don't have any subscription.": "Je hebt nog geen feeds geabboneerd staan.",
"Last check:": "Laatste update:",
"Refresh": "Vernieuwen",
"There is no history at the moment.": "Geschiedenis is op dit moment leeg.",
"OPML file": "OPML-bestand",
"Sign In": "Inloggen",
"Theme": "Skin",
"Timezone": "Tijdzone",
"Language": "Taal",
"There is no unread article.": "Er zijn geen ongelezen artikelen.",
"You are the only user.": "Je bent de enige gebruiker.",
"Last Login": "Laatste login",
"Yes": "Ja",
"No": "Nee",
"This feed already exists (%s)": "Deze feed bestaat al (%s)",
"Unable to fetch feed (statusCode=%d)": "Kon feed niet update (code=%d)",
"Unable to open this link: %v": "Kon link niet volgen: %v",
"Unable to analyze this page: %v": "Kon pagina niet analyseren: %v",
"Unable to find any subscription.": "Kon geen feeds vinden.",
"The URL and the category are mandatory.": "The URL en de categorie zijn verplicht.",
"All fields are mandatory.": "Alle velden moeten ingevuld zijn.",
"Passwords are not the same.": "Wachtwoorden zijn niet hetzelfde.",
"You must use at least 6 characters.": "Je moet minstens 6 tekens gebruiken.",
"The username is mandatory.": "Gebruikersnaam is verplicht",
"The username, theme, language and timezone fields are mandatory.": "Gebruikersnaam, skin, taal en tijdzone zijn verplicht.",
"The title is mandatory.": "Naam van categorie is verplicht.",
"About": "Over Miniflux",
"Version": "Versie",
"Version:": "Versie:",
"Build Date:": "Datum build:",
"Author:": "Auteur:",
"Authors": "Auteurs",
"License:": "Licentie:",
"Attachments": "Bijlages",
"Download": "Download",
"Invalid username or password.": "Onjuiste gebruikersnaam of wachtwoord.",
"Never": "Nooit",
"Unable to execute request: %v": "Kon request niet uitvoeren: %v",
"Last Parsing Error": "Laatste parse error",
"There is a problem with this feed": "Er is een probleem met deze feed",
"Unable to parse OPML file: %q": "Kon OPML niet parsen: %q",
"Unable to parse RSS feed: %q": "Kon RSS-feed niet parsen: %q",
"Unable to parse Atom feed: %q": "Kon Atom-feed niet parsen: %q",
"Unable to parse JSON feed: %q": "Kon JSON-feed niet parsen: %q",
"Unable to parse RDF feed: %q": "Kon RDF-feed niet parsen: %q",
"Unable to normalize encoding: %q": "Kon encoding niet normaliseren: %q",
"Unable to create this category.": "Kon categorie niet aanmaken.",
"yes": "ja",
"no": "nee",
"Are you sure?": "Weet je het zeker?",
"Work in progress...": "Bezig...",
"This user already exists.": "Deze gebruiker bestaat al.",
"This category already exists.": "Deze categorie bestaat al.",
"Unable to update this category.": "Kon categorie niet updaten.",
"Integrations": "Integraties",
"Bookmarklet": "Bookmarklet",
"Drag and drop this link to your bookmarks.": "Sleep deze link naar je bookmarks.",
"This special link allows you to subscribe to a website directly by using a bookmark in your web browser.": "Gebruik deze link als bookmark in je browser om je direct te abboneren op een website.",
"Add to Miniflux": "Toevoegen aan Miniflux",
"Refresh all feeds in background": "Vernieuw alle feeds in de achtergrond",
"Sign in with Google": "Inloggen via Google",
"Unlink my Google account": "Ontkoppel mijn Google-account",
"Link my Google account": "Koppel mijn Google-account",
"Category not found for this user": "Categorie niet gevonden voor deze gebruiker",
"Invalid theme.": "Ongeldige skin.",
"Entry Sorting": "Volgorde van items",
"Older entries first": "Oudere items eerst",
"Recent entries first": "Recente items eerst",
"Saving...": "Opslaag...",
"Done!": "Klaar!",
"Save this article": "Artikel opslaan",
"Mark bookmark as unread": "Markeer bookmark als gelezen",
"Pinboard Tags": "Pinboard tags",
"Pinboard API Token": "Pinboard API token",
"Save articles to Pinboard": "Artikelen opslaan naar Pinboard",
"Save articles to Instapaper": "Artikelen opstaan naar Instapaper",
"Instapaper Username": "Instapaper gebruikersnaam",
"Instapaper Password": "Instapaper wachtwoord",
"Activate Fever API": "Activeer Fever API",
"Fever Username": "Fever gebruikersnaam",
"Fever Password": "Fever wachtwoord",
"Fetch original content": "Download originele content",
"Scraper Rules": "Scraper regels",
"Rewrite Rules": "Rewrite regels",
"Preferences saved!": "Instellingen opgeslagen!",
"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",
"Wallabag Client Secret": "Wallabag Client-Secret",
"Wallabag Username": "Wallabag gebruikersnaam",
"Wallabag Password": "Wallabag wachtwoord",
"Save articles to Nunux Keeper": "Opslaan naar Nunux Keeper",
"Nunux Keeper API Endpoint": "Nunux Keeper URL",
"Nunux Keeper API key": "Nunux Keeper API-sleutel",
"Keyboard Shortcut: %s": "Sneltoets: %s",
"Favorites": "Favorieten",
"Star": "Ster toevoegen",
"Unstar": "Ster weghalen",
"Starred": "Favorieten",
"There is no bookmark at the moment.": "Er zijn op dit moment geen favorieten.",
"Last checked:": "Laatste update:",
"ETag header:": "ETAG-header:",
"LastModified header:": "LastModified-header:",
"None": "Geen",
"Keyboard Shortcuts": "Sneltoetsen",
"Sections Navigation": "Naviguatie tussen menu's",
"Go to unread": "Ga naar ongelezen",
"Go to bookmarks": "Ga naar favorieten",
"Go to history": "Ga naar geschiedenis",
"Go to feeds": "Ga naar feeds",
"Go to categories": "Ga naar categorieën",
"Go to settings": "Ga naar instellingen",
"Show keyboard shortcuts": "Laat sneltoetsen zien",
"Items Navigation": "Navigatie tussen items",
"Go to previous item": "Vorige item",
"Go to next item": "Volgende item",
"Pages Navigation": "Naviguatie tussen pagina's",
"Go to previous page": "Vorige pagina",
"Go to next page": "Volgende pagina",
"Open selected item": "Open geselecteerde link",
"Open original link": "Open originele link",
"Toggle read/unread": "Markeer gelezen/ongelezen",
"Mark current page as read": "Markeer deze pagina als gelezen",
"Download original content": "Download originele content",
"Toggle bookmark": "Ster toevoegen/weghalen",
"Close modal dialog": "Sluit dialoogscherm",
"Save article": "Artikel opslaan",
"There is already someone associated with this provider!": "Er is al iemand geregistreerd met deze provider!",
"There is already someone else with the same Fever username!": "Er is al iemand met dezelfde Fever gebruikersnaam!",
"Mark all as read": "Markeer alle items als gelezen",
"This feed is empty": "Deze feed is leeg",
"Flush history": "Verwijder geschiedenis",
"Site URL": "Website URL",
"Feed URL": "Feed URL",
"Logged as %s": "Ingelogd als %s",
"Unread Items": "Ongelezen items",
"Change entry status": "Verander status van item",
"Read": "Gelezen",
"Fever API endpoint:": "Fever URL:",
"Miniflux API": "Miniflux API",
"API Endpoint": "API-URL",
"Your account password": "Wachtwoord van jouw account",
"This web page is empty": "Deze webpagina is leeg",
"Invalid SSL certificate (original error: %q)": "Ongeldig SSL-certificaat (originele error: %q)",
"This website is temporarily unreachable (original error: %q)": "Deze website is tijdelijk onbereikbaar (originele error: %q)",
"This website is permanently unreachable (original error: %q)": "Deze website is permanent onbereikbaar (originele error: %q)",
"Website unreachable, the request timed out after %d seconds": "Website onbereikbaar, de request gaf een timeout na %d seconden"
}
`,
"pl_PL": `{
@@ -559,7 +787,7 @@ var translations = map[string]string{
"The username, theme, language and timezone fields are mandatory.": "Pola nazwy użytkownika, tematu, języka i strefy czasowej są obowiązkowe.",
"The title is mandatory.": "Tytuł jest obowiązkowy.",
"About": "O stronie",
"version": "Wersja",
"Version": "Wersja",
"Version:": "Wersja :",
"Build Date:": "Data opracowania:",
"Author:": "Autor:",
@@ -617,7 +845,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",
@@ -781,7 +1009,7 @@ var translations = map[string]string{
"The username, theme, language and timezone fields are mandatory.": "必须填写用户名,主题,语言和时区.",
"The title is mandatory.": "必须填写标题.",
"About": "关于",
"version": "版本",
"Version": "版本",
"Version:": "版本:",
"Build Date:": "构建日期:",
"Author:": "作者:",
@@ -839,7 +1067,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",
@@ -907,9 +1135,10 @@ var translations = map[string]string{
}
var translationsChecksums = map[string]string{
"de_DE": "da3e70c096b35c205d89dddd400bbf34927bb495d4ee0f4eb3c3dc04e02b99c1",
"de_DE": "791d72c96137ab03b729017bdfa27c8eed2f65912e372fcb5b2796d5099d5498",
"en_US": "6fe95384260941e8a5a3c695a655a932e0a8a6a572c1e45cb2b1ae8baa01b897",
"fr_FR": "e842d6503b4d50ba5e3cd862b3d92c64f031356cf87f9989d2ac9a1ba0246ac8",
"pl_PL": "0d8a76425cf634b96cfc425127d8e83db7662e2d4dbc30674098e3fb6cea7c8d",
"zh_CN": "c19cb45a49af7957748fa006b51421edaa9774ef1ab0e91eb2c0552635016b62",
"fr_FR": "5a954b28ac31af6fc525cb000d86c884950dac7414b695bd38a4c0aebdfe35b5",
"nl_NL": "1a73f1dd1c4c0d2c2adc8695cdd050c2dad81c14876caed3892b44adc2491265",
"pl_PL": "da709c14ff71f3b516eec66cb2758d89c5feab1472c94b2b518f425162a9f806",
"zh_CN": "d80594c1b67d15e9f4673d3d62fe4949e8606a5fdfb741d8a8921f21dceb8cf2",
}
+5 -3
View File
@@ -97,7 +97,7 @@
"The username, theme, language and timezone fields are mandatory.": "Die Felder für Benutzername, Thema, Sprache und Zeitzone sind obligatorisch.",
"The title is mandatory.": "Der Titel ist obligatorisch.",
"About": "Über",
"version": "Version",
"Version": "Version",
"Version:": "Version :",
"Build Date:": "Datum der Kompilierung:",
"Author:": "Autor:",
@@ -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",
@@ -217,5 +217,7 @@
"Invalid SSL certificate (original error: %q)": "Ungültiges SSL-Zertifikat (ursprünglicher Fehler: %q)",
"This website is temporarily unreachable (original error: %q)": "Diese Webseite ist vorübergehend nicht erreichbar (ursprünglicher Fehler: %q)",
"This website is permanently unreachable (original error: %q)": "Diese Webseite ist dauerhaft nicht erreichbar (ursprünglicher Fehler: %q)",
"Website unreachable, the request timed out after %d seconds": "Webseite nicht erreichbar, die Anfrage endete nach %d Sekunden"
"Website unreachable, the request timed out after %d seconds": "Webseite nicht erreichbar, die Anfrage endete nach %d Sekunden",
"Comments": "Kommentare",
"View Comments": "Kommentare anzeigen"
}
+8 -3
View File
@@ -97,7 +97,7 @@
"The username, theme, language and timezone fields are mandatory.": "Le nom d'utilisateur, le thème, la langue et le fuseau horaire sont obligatoire.",
"The title is mandatory.": "Le titre est obligatoire.",
"About": "A propos",
"version": "Version",
"Version": "Version",
"Version:": "Version :",
"Build Date:": "Date de la compilation :",
"Author:": "Auteur :",
@@ -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",
@@ -217,5 +217,10 @@
"Invalid SSL certificate (original error: %q)": "Certificat SSL invalide (erreur originale : %q)",
"This website is temporarily unreachable (original error: %q)": "Ce site web est temporairement injoignable (erreur originale : %q)",
"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"
"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",
"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."
}
+220
View File
@@ -0,0 +1,220 @@
{
"plural.feed.error_count": [
"%d error",
"%d errors"
],
"plural.categories.feed_count": [
"Er is %d feed.",
"Er zijn %d feeds."
],
"Username": "Gebruikersnaam",
"Password": "Wachtwoord",
"Unread": "Ongelezen",
"History": "Geschiedenis",
"Feeds": "Feeds",
"Categories": "Categorieën",
"Settings": "Instellingen",
"Logout": "Uitloggen",
"Next": "Volgende",
"Previous": "Vorige",
"New Subscription": "Nieuwe feed",
"Import": "Importeren",
"Export": "Exporteren",
"There is no category. You must have at least one category.": "Er zijn geen categorieën. Je moet op zijn minst één caterogie hebben.",
"URL": "URL",
"Category": "Categorie",
"Find a subscription": "Feed zoeken",
"Loading...": "Laden...",
"Create a category": "Categorie toevoegen",
"There is no category.": "Er zijn geen categorieën.",
"Edit": "Bewerken",
"Remove": "Verwijderen",
"No feed.": "Geen feeds.",
"There is no article in this category.": "Deze categorie bevat geen feeds.",
"Original": "Origineel",
"Mark this page as read": "Markeer deze pagina als gelezen",
"not yet": "in de toekomst",
"just now": "minder dan een minuut geleden",
"1 minute ago": "een minuut geleden",
"%d minutes ago": "%d minuten geleden",
"1 hour ago": "een uur geleden",
"%d hours ago": "%d uur geleden",
"yesterday": "gisteren",
"%d days ago": "%d dagen geleden",
"%d weeks ago": "%d weken geleden",
"%d months ago": "%d maanden geleden",
"%d years ago": "%d jaar geleden",
"Date": "Datum",
"IP Address": "IP-adres",
"User Agent": "User-agent",
"Actions": "Acties",
"Current session": "Huidige sessie",
"Sessions": "Sessies",
"Users": "Gebruikers",
"Add user": "Gebruiker toevoegen",
"Choose a Subscription": "Feed kiezen",
"Subscribe": "Abboneren",
"New Category": "Nieuwe categorie",
"Title": "Naam",
"Save": "Opslaan",
"or": "of",
"cancel": "annuleren",
"New User": "Nieuwe gebruiker",
"Confirmation": "Bevestig wachtwoord",
"Administrator": "Administrator",
"Edit Category: %s": "Bewerken van categorie: %s",
"Update": "Updaten",
"Edit Feed: %s": "Bewerken van feed: %s",
"There is no category!": "Er zijn geen categorieën!",
"Edit user: %s": "Gebruiker aanpassen: %s",
"There is no article for this feed.": "Er zijn geen artikelen in deze feed.",
"Add subscription": "Feed toevoegen",
"You don't have any subscription.": "Je hebt nog geen feeds geabboneerd staan.",
"Last check:": "Laatste update:",
"Refresh": "Vernieuwen",
"There is no history at the moment.": "Geschiedenis is op dit moment leeg.",
"OPML file": "OPML-bestand",
"Sign In": "Inloggen",
"Theme": "Skin",
"Timezone": "Tijdzone",
"Language": "Taal",
"There is no unread article.": "Er zijn geen ongelezen artikelen.",
"You are the only user.": "Je bent de enige gebruiker.",
"Last Login": "Laatste login",
"Yes": "Ja",
"No": "Nee",
"This feed already exists (%s)": "Deze feed bestaat al (%s)",
"Unable to fetch feed (statusCode=%d)": "Kon feed niet update (code=%d)",
"Unable to open this link: %v": "Kon link niet volgen: %v",
"Unable to analyze this page: %v": "Kon pagina niet analyseren: %v",
"Unable to find any subscription.": "Kon geen feeds vinden.",
"The URL and the category are mandatory.": "The URL en de categorie zijn verplicht.",
"All fields are mandatory.": "Alle velden moeten ingevuld zijn.",
"Passwords are not the same.": "Wachtwoorden zijn niet hetzelfde.",
"You must use at least 6 characters.": "Je moet minstens 6 tekens gebruiken.",
"The username is mandatory.": "Gebruikersnaam is verplicht",
"The username, theme, language and timezone fields are mandatory.": "Gebruikersnaam, skin, taal en tijdzone zijn verplicht.",
"The title is mandatory.": "Naam van categorie is verplicht.",
"About": "Over Miniflux",
"Version": "Versie",
"Version:": "Versie:",
"Build Date:": "Datum build:",
"Author:": "Auteur:",
"Authors": "Auteurs",
"License:": "Licentie:",
"Attachments": "Bijlages",
"Download": "Download",
"Invalid username or password.": "Onjuiste gebruikersnaam of wachtwoord.",
"Never": "Nooit",
"Unable to execute request: %v": "Kon request niet uitvoeren: %v",
"Last Parsing Error": "Laatste parse error",
"There is a problem with this feed": "Er is een probleem met deze feed",
"Unable to parse OPML file: %q": "Kon OPML niet parsen: %q",
"Unable to parse RSS feed: %q": "Kon RSS-feed niet parsen: %q",
"Unable to parse Atom feed: %q": "Kon Atom-feed niet parsen: %q",
"Unable to parse JSON feed: %q": "Kon JSON-feed niet parsen: %q",
"Unable to parse RDF feed: %q": "Kon RDF-feed niet parsen: %q",
"Unable to normalize encoding: %q": "Kon encoding niet normaliseren: %q",
"Unable to create this category.": "Kon categorie niet aanmaken.",
"yes": "ja",
"no": "nee",
"Are you sure?": "Weet je het zeker?",
"Work in progress...": "Bezig...",
"This user already exists.": "Deze gebruiker bestaat al.",
"This category already exists.": "Deze categorie bestaat al.",
"Unable to update this category.": "Kon categorie niet updaten.",
"Integrations": "Integraties",
"Bookmarklet": "Bookmarklet",
"Drag and drop this link to your bookmarks.": "Sleep deze link naar je bookmarks.",
"This special link allows you to subscribe to a website directly by using a bookmark in your web browser.": "Gebruik deze link als bookmark in je browser om je direct te abboneren op een website.",
"Add to Miniflux": "Toevoegen aan Miniflux",
"Refresh all feeds in background": "Vernieuw alle feeds in de achtergrond",
"Sign in with Google": "Inloggen via Google",
"Unlink my Google account": "Ontkoppel mijn Google-account",
"Link my Google account": "Koppel mijn Google-account",
"Category not found for this user": "Categorie niet gevonden voor deze gebruiker",
"Invalid theme.": "Ongeldige skin.",
"Entry Sorting": "Volgorde van items",
"Older entries first": "Oudere items eerst",
"Recent entries first": "Recente items eerst",
"Saving...": "Opslaag...",
"Done!": "Klaar!",
"Save this article": "Artikel opslaan",
"Mark bookmark as unread": "Markeer bookmark als gelezen",
"Pinboard Tags": "Pinboard tags",
"Pinboard API Token": "Pinboard API token",
"Save articles to Pinboard": "Artikelen opslaan naar Pinboard",
"Save articles to Instapaper": "Artikelen opstaan naar Instapaper",
"Instapaper Username": "Instapaper gebruikersnaam",
"Instapaper Password": "Instapaper wachtwoord",
"Activate Fever API": "Activeer Fever API",
"Fever Username": "Fever gebruikersnaam",
"Fever Password": "Fever wachtwoord",
"Fetch original content": "Download originele content",
"Scraper Rules": "Scraper regels",
"Rewrite Rules": "Rewrite regels",
"Preferences saved!": "Instellingen opgeslagen!",
"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",
"Wallabag Client Secret": "Wallabag Client-Secret",
"Wallabag Username": "Wallabag gebruikersnaam",
"Wallabag Password": "Wallabag wachtwoord",
"Save articles to Nunux Keeper": "Opslaan naar Nunux Keeper",
"Nunux Keeper API Endpoint": "Nunux Keeper URL",
"Nunux Keeper API key": "Nunux Keeper API-sleutel",
"Keyboard Shortcut: %s": "Sneltoets: %s",
"Favorites": "Favorieten",
"Star": "Ster toevoegen",
"Unstar": "Ster weghalen",
"Starred": "Favorieten",
"There is no bookmark at the moment.": "Er zijn op dit moment geen favorieten.",
"Last checked:": "Laatste update:",
"ETag header:": "ETAG-header:",
"LastModified header:": "LastModified-header:",
"None": "Geen",
"Keyboard Shortcuts": "Sneltoetsen",
"Sections Navigation": "Naviguatie tussen menu's",
"Go to unread": "Ga naar ongelezen",
"Go to bookmarks": "Ga naar favorieten",
"Go to history": "Ga naar geschiedenis",
"Go to feeds": "Ga naar feeds",
"Go to categories": "Ga naar categorieën",
"Go to settings": "Ga naar instellingen",
"Show keyboard shortcuts": "Laat sneltoetsen zien",
"Items Navigation": "Navigatie tussen items",
"Go to previous item": "Vorige item",
"Go to next item": "Volgende item",
"Pages Navigation": "Naviguatie tussen pagina's",
"Go to previous page": "Vorige pagina",
"Go to next page": "Volgende pagina",
"Open selected item": "Open geselecteerde link",
"Open original link": "Open originele link",
"Toggle read/unread": "Markeer gelezen/ongelezen",
"Mark current page as read": "Markeer deze pagina als gelezen",
"Download original content": "Download originele content",
"Toggle bookmark": "Ster toevoegen/weghalen",
"Close modal dialog": "Sluit dialoogscherm",
"Save article": "Artikel opslaan",
"There is already someone associated with this provider!": "Er is al iemand geregistreerd met deze provider!",
"There is already someone else with the same Fever username!": "Er is al iemand met dezelfde Fever gebruikersnaam!",
"Mark all as read": "Markeer alle items als gelezen",
"This feed is empty": "Deze feed is leeg",
"Flush history": "Verwijder geschiedenis",
"Site URL": "Website URL",
"Feed URL": "Feed URL",
"Logged as %s": "Ingelogd als %s",
"Unread Items": "Ongelezen items",
"Change entry status": "Verander status van item",
"Read": "Gelezen",
"Fever API endpoint:": "Fever URL:",
"Miniflux API": "Miniflux API",
"API Endpoint": "API-URL",
"Your account password": "Wachtwoord van jouw account",
"This web page is empty": "Deze webpagina is leeg",
"Invalid SSL certificate (original error: %q)": "Ongeldig SSL-certificaat (originele error: %q)",
"This website is temporarily unreachable (original error: %q)": "Deze website is tijdelijk onbereikbaar (originele error: %q)",
"This website is permanently unreachable (original error: %q)": "Deze website is permanent onbereikbaar (originele error: %q)",
"Website unreachable, the request timed out after %d seconds": "Website onbereikbaar, de request gaf een timeout na %d seconden"
}
+2 -2
View File
@@ -99,7 +99,7 @@
"The username, theme, language and timezone fields are mandatory.": "Pola nazwy użytkownika, tematu, języka i strefy czasowej są obowiązkowe.",
"The title is mandatory.": "Tytuł jest obowiązkowy.",
"About": "O stronie",
"version": "Wersja",
"Version": "Wersja",
"Version:": "Wersja :",
"Build Date:": "Data opracowania:",
"Author:": "Autor:",
@@ -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",
+2 -2
View File
@@ -97,7 +97,7 @@
"The username, theme, language and timezone fields are mandatory.": "必须填写用户名,主题,语言和时区.",
"The title is mandatory.": "必须填写标题.",
"About": "关于",
"version": "版本",
"Version": "版本",
"Version:": "版本:",
"Build Date:": "构建日期:",
"Author:": "作者:",
@@ -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",
+75
View File
@@ -0,0 +1,75 @@
// 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)
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.
@@ -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)
})
}
+21
View File
@@ -0,0 +1,21 @@
// 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/logger"
"github.com/tomasen/realip"
)
// 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", realip.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}
}
+1 -1
View File
@@ -11,7 +11,7 @@ type Enclosure struct {
EntryID int64 `json:"entry_id"`
URL string `json:"url"`
MimeType string `json:"mime_type"`
Size int `json:"size"`
Size int64 `json:"size"`
}
// EnclosureList represents a list of attachments.
+15 -14
View File
@@ -20,20 +20,21 @@ const (
// Entry represents a feed item in the system.
type Entry struct {
ID int64 `json:"id"`
UserID int64 `json:"user_id"`
FeedID int64 `json:"feed_id"`
Status string `json:"status"`
Hash string `json:"hash"`
Title string `json:"title"`
URL string `json:"url"`
Date time.Time `json:"published_at"`
Content string `json:"content"`
Author string `json:"author"`
Starred bool `json:"starred"`
Enclosures EnclosureList `json:"enclosures,omitempty"`
Feed *Feed `json:"feed,omitempty"`
Category *Category `json:"category,omitempty"`
ID int64 `json:"id"`
UserID int64 `json:"user_id"`
FeedID int64 `json:"feed_id"`
Status string `json:"status"`
Hash string `json:"hash"`
Title string `json:"title"`
URL string `json:"url"`
CommentsURL string `json:"comments_url"`
Date time.Time `json:"published_at"`
Content string `json:"content"`
Author string `json:"author"`
Starred bool `json:"starred"`
Enclosures EnclosureList `json:"enclosures,omitempty"`
Feed *Feed `json:"feed,omitempty"`
Category *Category `json:"category,omitempty"`
}
// Entries represents a list of entries.
+1 -1
View File
@@ -187,7 +187,7 @@ func getEnclosures(a *atomEntry) model.EnclosureList {
for _, link := range a.Links {
if strings.ToLower(link.Rel) == "enclosure" {
length, _ := strconv.Atoi(link.Length)
length, _ := strconv.ParseInt(link.Length, 10, 0)
enclosures = append(enclosures, &model.Enclosure{URL: link.URL, MimeType: link.Type, Size: length})
}
}
+32
View File
@@ -71,6 +71,8 @@ var dateFormats = []string{
"Mon, 2 Jan 2006 15:04:05 -0700",
"Mon, 2 Jan 2006 15:04:05",
"Mon, 2 Jan 2006 15:04",
"Mon, 02 Jan 2006, 15:04",
"Mon, 2 Jan 2006, 15:04",
"Mon,2 Jan 2006",
"Mon, 2 Jan 2006",
"Mon, 2 Jan 15:04:05 MST",
@@ -192,6 +194,7 @@ 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) {
ds = replaceNonEnglishWords(ds)
d := strings.TrimSpace(ds)
if d == "" {
return t, errors.New("date parser: empty value")
@@ -211,3 +214,32 @@ func Parse(ds string) (t time.Time, err error) {
err = fmt.Errorf(`date parser: failed to parse date "%s"`, ds)
return
}
// Replace German and French dates to English.
func replaceNonEnglishWords(ds string) string {
r := strings.NewReplacer(
"Mo,", "Mon,",
"Di,", "Tue,",
"Mi,", "Wed,",
"Do,", "Thu,",
"Fr,", "Fri,",
"Sa,", "Sat,",
"So,", "Sun,",
"Mär ", "Mar ",
"Mai ", "May ",
"Okt ", "Oct ",
"Dez ", "Dec ",
"lun,", "Mon,",
"mar,", "Tue,",
"mer,", "Wed,",
"jeu,", "Thu,",
"ven,", "Fri,",
"sam,", "Sat,",
"dim,", "Sun,",
"avr ", "Apr ",
"mai ", "May ",
"jui ", "Jun ",
)
return r.Replace(ds)
}
+5 -1
View File
@@ -47,11 +47,15 @@ func TestParseWeirdDateFormat(t *testing.T) {
"Friday, December 22, 2017 - 3:09pm",
"Friday, December 8, 2017 - 3:07pm",
"Thu, 25 Feb 2016 00:00:00 Europe/Brussels",
"Mon, 09 Apr 2018, 16:04",
"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",
}
for _, date := range dates {
if _, err := Parse(date); err != nil {
t.Fatalf(`Unable to parse date: "%s"`, date)
t.Fatalf(`Unable to parse date: %q`, date)
}
}
}
+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)
}
+1 -1
View File
@@ -47,7 +47,7 @@ type jsonAttachment struct {
URL string `json:"url"`
MimeType string `json:"mime_type"`
Title string `json:"title"`
Size int `json:"size_in_bytes"`
Size int64 `json:"size_in_bytes"`
Duration int `json:"duration_in_seconds"`
}
+4 -4
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
@@ -41,7 +40,7 @@ func (h *Handler) Export(userID int64) (string, error) {
}
// Import parses and create feeds from an OPML import.
func (h *Handler) Import(userID int64, data io.Reader) (err error) {
func (h *Handler) Import(userID int64, data io.Reader) error {
subscriptions, err := Parse(data)
if err != nil {
return err
@@ -50,6 +49,7 @@ func (h *Handler) Import(userID int64, data io.Reader) (err error) {
for _, subscription := range subscriptions {
if !h.store.FeedURLExists(userID, subscription.FeedURL) {
var category *model.Category
var err error
if subscription.CategoryName == "" {
category, err = h.store.FirstCategory(userID)
@@ -73,7 +73,7 @@ func (h *Handler) Import(userID int64, data io.Reader) (err 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)
}
}
}
+55
View File
@@ -322,6 +322,61 @@ func TestParseItemWithoutLink(t *testing.T) {
}
}
func TestParseItemWithDublicCoreDate(t *testing.T) {
data := `<?xml version="1.0" encoding="utf-8"?>
<rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" xmlns="http://purl.org/rss/1.0/" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:slash="http://purl.org/rss/1.0/modules/slash/">
<channel>
<title>Example</title>
<link>http://example.org</link>
</channel>
<item>
<title>Title</title>
<description>Test</description>
<link>http://example.org/test.html</link>
<dc:creator>Tester</dc:creator>
<dc:date>2018-04-10T05:00:00+00:00</dc:date>
</item>
</rdf:RDF>`
feed, err := Parse(bytes.NewBufferString(data))
if err != nil {
t.Error(err)
}
expectedDate := time.Date(2018, time.April, 10, 5, 0, 0, 0, time.UTC)
if !feed.Entries[0].Date.Equal(expectedDate) {
t.Errorf("Incorrect entry date, got: %v, want: %v", feed.Entries[0].Date, expectedDate)
}
}
func TestParseItemWithoutDate(t *testing.T) {
data := `<?xml version="1.0" encoding="utf-8"?>
<rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" xmlns="http://purl.org/rss/1.0/">
<channel>
<title>Example</title>
<link>http://example.org</link>
</channel>
<item>
<title>Title</title>
<description>Test</description>
<link>http://example.org/test.html</link>
</item>
</rdf:RDF>`
feed, err := Parse(bytes.NewBufferString(data))
if err != nil {
t.Error(err)
}
expectedDate := time.Now().In(time.Local)
diff := expectedDate.Sub(feed.Entries[0].Date)
if diff > time.Second {
t.Errorf("Incorrect entry date, got: %v", diff)
}
}
func TestParseInvalidXml(t *testing.T) {
data := `garbage`
_, err := Parse(bytes.NewBufferString(data))
+18 -1
View File
@@ -10,7 +10,9 @@ import (
"time"
"github.com/miniflux/miniflux/crypto"
"github.com/miniflux/miniflux/logger"
"github.com/miniflux/miniflux/model"
"github.com/miniflux/miniflux/reader/date"
"github.com/miniflux/miniflux/reader/sanitizer"
"github.com/miniflux/miniflux/url"
)
@@ -54,6 +56,7 @@ type rdfItem struct {
Link string `xml:"link"`
Description string `xml:"description"`
Creator string `xml:"creator"`
Date string `xml:"date"`
}
func (r *rdfItem) Transform() *model.Entry {
@@ -63,10 +66,24 @@ func (r *rdfItem) Transform() *model.Entry {
entry.URL = r.Link
entry.Content = r.Description
entry.Hash = getHash(r)
entry.Date = time.Now()
entry.Date = getDate(r)
return entry
}
func getDate(r *rdfItem) time.Time {
if r.Date != "" {
result, err := date.Parse(r.Date)
if err != nil {
logger.Error("rdf: %v", err)
return time.Now()
}
return result
}
return time.Now()
}
func getHash(r *rdfItem) string {
value := r.Link
if value == "" {
+51
View File
@@ -230,6 +230,31 @@ func TestParseFeedURLWithAtomLink(t *testing.T) {
}
}
func TestParseEntryWithAuthorAndInnerHTML(t *testing.T) {
data := `<?xml version="1.0" encoding="utf-8"?>
<rss xmlns:atom="http://www.w3.org/2005/Atom" version="2.0">
<channel>
<title>Example</title>
<link>https://example.org/</link>
<atom:link href="https://example.org/rss" type="application/rss+xml" rel="self"></atom:link>
<item>
<title>Test</title>
<link>https://example.org/item</link>
<author>by <a itemprop="url" class="author" rel="author" href="/author/foobar">Foo Bar</a></author>
</item>
</channel>
</rss>`
feed, err := Parse(bytes.NewBufferString(data))
if err != nil {
t.Error(err)
}
if feed.Entries[0].Author != "by Foo Bar" {
t.Errorf("Incorrect entry author, got: %s", feed.Entries[0].Author)
}
}
func TestParseEntryWithAtomAuthor(t *testing.T) {
data := `<?xml version="1.0" encoding="utf-8"?>
<rss xmlns:atom="http://www.w3.org/2005/Atom" version="2.0">
@@ -556,6 +581,32 @@ func TestParseEntryWithRelativeURL(t *testing.T) {
}
}
func TestParseEntryWithCommentsURL(t *testing.T) {
data := `<?xml version="1.0" encoding="utf-8"?>
<rss version="2.0" xmlns:slash="http://purl.org/rss/1.0/modules/slash/">
<channel>
<link>https://example.org/</link>
<item>
<title>Item 1</title>
<link>https://example.org/item1</link>
<comments>
https://example.org/comments
</comments>
<slash:comments>42</slash:comments>
</item>
</channel>
</rss>`
feed, err := Parse(bytes.NewBufferString(data))
if err != nil {
t.Error(err)
}
if feed.Entries[0].CommentsURL != "https://example.org/comments" {
t.Errorf("Incorrect entry comments URL, got: %q", feed.Entries[0].CommentsURL)
}
}
func TestParseInvalidXml(t *testing.T) {
data := `garbage`
_, err := Parse(bytes.NewBufferString(data))
+56 -37
View File
@@ -15,6 +15,7 @@ import (
"github.com/miniflux/miniflux/logger"
"github.com/miniflux/miniflux/model"
"github.com/miniflux/miniflux/reader/date"
"github.com/miniflux/miniflux/reader/sanitizer"
"github.com/miniflux/miniflux/url"
)
@@ -37,25 +38,16 @@ type rssLink struct {
Rel string `xml:"rel,attr"`
}
type rssItem struct {
GUID string `xml:"guid"`
Title string `xml:"title"`
Links []rssLink `xml:"link"`
OriginalLink string `xml:"http://rssnamespace.org/feedburner/ext/1.0 origLink"`
Description string `xml:"description"`
Content string `xml:"http://purl.org/rss/1.0/modules/content/ encoded"`
PubDate string `xml:"pubDate"`
Date string `xml:"http://purl.org/dc/elements/1.1/ date"`
Authors []rssAuthor `xml:"author"`
Creator string `xml:"http://purl.org/dc/elements/1.1/ creator"`
Enclosures []rssEnclosure `xml:"enclosure"`
OrigEnclosureLink string `xml:"http://rssnamespace.org/feedburner/ext/1.0 origEnclosureLink"`
type rssCommentLink struct {
XMLName xml.Name
Data string `xml:",chardata"`
}
type rssAuthor struct {
XMLName xml.Name
Data string `xml:",chardata"`
Name string `xml:"name"`
Inner string `xml:",innerxml"`
}
type rssEnclosure struct {
@@ -64,7 +56,23 @@ type rssEnclosure struct {
Length string `xml:"length,attr"`
}
func (r *rssFeed) GetSiteURL() string {
type rssItem struct {
GUID string `xml:"guid"`
Title string `xml:"title"`
Links []rssLink `xml:"link"`
OriginalLink string `xml:"http://rssnamespace.org/feedburner/ext/1.0 origLink"`
CommentLinks []rssCommentLink `xml:"comments"`
Description string `xml:"description"`
EncodedContent string `xml:"http://purl.org/rss/1.0/modules/content/ encoded"`
PubDate string `xml:"pubDate"`
Date string `xml:"http://purl.org/dc/elements/1.1/ date"`
Authors []rssAuthor `xml:"author"`
Creator string `xml:"http://purl.org/dc/elements/1.1/ creator"`
EnclosureLinks []rssEnclosure `xml:"enclosure"`
OrigEnclosureLink string `xml:"http://rssnamespace.org/feedburner/ext/1.0 origEnclosureLink"`
}
func (r *rssFeed) SiteURL() string {
for _, element := range r.Links {
if element.XMLName.Space == "" {
return strings.TrimSpace(element.Data)
@@ -74,7 +82,7 @@ func (r *rssFeed) GetSiteURL() string {
return ""
}
func (r *rssFeed) GetFeedURL() string {
func (r *rssFeed) FeedURL() string {
for _, element := range r.Links {
if element.XMLName.Space == "http://www.w3.org/2005/Atom" {
return strings.TrimSpace(element.Href)
@@ -86,8 +94,8 @@ func (r *rssFeed) GetFeedURL() string {
func (r *rssFeed) Transform() *model.Feed {
feed := new(model.Feed)
feed.SiteURL = r.GetSiteURL()
feed.FeedURL = r.GetFeedURL()
feed.SiteURL = r.SiteURL()
feed.FeedURL = r.FeedURL()
feed.Title = strings.TrimSpace(r.Title)
if feed.Title == "" {
@@ -100,7 +108,7 @@ func (r *rssFeed) Transform() *model.Feed {
if entry.Author == "" && r.ItunesAuthor != "" {
entry.Author = r.ItunesAuthor
}
entry.Author = strings.TrimSpace(entry.Author)
entry.Author = strings.TrimSpace(sanitizer.StripTags(entry.Author))
if entry.URL == "" {
entry.URL = feed.SiteURL
@@ -121,7 +129,7 @@ func (r *rssFeed) Transform() *model.Feed {
return feed
}
func (r *rssItem) GetDate() time.Time {
func (r *rssItem) PublishedDate() time.Time {
value := r.PubDate
if r.Date != "" {
value = r.Date
@@ -140,22 +148,22 @@ func (r *rssItem) GetDate() time.Time {
return time.Now()
}
func (r *rssItem) GetAuthor() string {
func (r *rssItem) Author() string {
for _, element := range r.Authors {
if element.Name != "" {
return element.Name
}
if element.Data != "" {
return element.Data
if element.Inner != "" {
return element.Inner
}
}
return r.Creator
}
func (r *rssItem) GetHash() string {
for _, value := range []string{r.GUID, r.GetURL()} {
func (r *rssItem) Hash() string {
for _, value := range []string{r.GUID, r.URL()} {
if value != "" {
return crypto.Hash(value)
}
@@ -164,15 +172,15 @@ func (r *rssItem) GetHash() string {
return ""
}
func (r *rssItem) GetContent() string {
if r.Content != "" {
return r.Content
func (r *rssItem) Content() string {
if r.EncodedContent != "" {
return r.EncodedContent
}
return r.Description
}
func (r *rssItem) GetURL() string {
func (r *rssItem) URL() string {
if r.OriginalLink != "" {
return r.OriginalLink
}
@@ -190,11 +198,11 @@ func (r *rssItem) GetURL() string {
return ""
}
func (r *rssItem) GetEnclosures() model.EnclosureList {
func (r *rssItem) Enclosures() model.EnclosureList {
enclosures := make(model.EnclosureList, 0)
for _, enclosure := range r.Enclosures {
length, _ := strconv.Atoi(enclosure.Length)
for _, enclosure := range r.EnclosureLinks {
length, _ := strconv.ParseInt(enclosure.Length, 10, 0)
enclosureURL := enclosure.URL
if r.OrigEnclosureLink != "" {
@@ -214,15 +222,26 @@ func (r *rssItem) GetEnclosures() model.EnclosureList {
return enclosures
}
func (r *rssItem) CommentsURL() string {
for _, commentLink := range r.CommentLinks {
if commentLink.XMLName.Space == "" {
return strings.TrimSpace(commentLink.Data)
}
}
return ""
}
func (r *rssItem) Transform() *model.Entry {
entry := new(model.Entry)
entry.URL = r.GetURL()
entry.Date = r.GetDate()
entry.Author = r.GetAuthor()
entry.Hash = r.GetHash()
entry.Content = r.GetContent()
entry.URL = r.URL()
entry.CommentsURL = r.CommentsURL()
entry.Date = r.PublishedDate()
entry.Author = r.Author()
entry.Hash = r.Hash()
entry.Content = r.Content()
entry.Title = strings.TrimSpace(r.Title)
entry.Enclosures = r.GetEnclosures()
entry.Enclosures = r.Enclosures()
return entry
}
+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
+1
View File
@@ -0,0 +1 @@
alter table enclosures alter column size set data type bigint;
+1
View File
@@ -0,0 +1 @@
alter table entries add column comments_url text default '';
+5 -1
View File
@@ -1,5 +1,5 @@
// Code generated by go generate; DO NOT EDIT.
// 2018-02-24 17:47:34.98646993 +0000 GMT
// 2018-04-06 23:00:49.983090069 +0100 BST m=+0.002610702
package sql
@@ -129,6 +129,8 @@ create index feeds_user_category_idx on feeds(user_id, category_id);
"schema_version_14": `alter table integrations add column nunux_keeper_enabled bool default 'f';
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_2": `create extension if not exists hstore;
alter table users add column extra hstore;
create index users_extra_idx on users using gin(extra);
@@ -174,6 +176,8 @@ var SqlMapChecksums = map[string]string{
"schema_version_12": "a95abab6cdf64811fc744abd37457e2928939d999c5ef00d2bdd9398e16f32fb",
"schema_version_13": "9073fae1e796936f4a43a8120ebdb4218442fe7d346ace6387556a357c2d7edf",
"schema_version_14": "4622e42c4a5a88b6fe1e61f3d367b295968f7260ab5b96481760775ba9f9e1fe",
"schema_version_15": "13ff91462bdf4cda5a94a4c7a09f757761b0f2c32b4be713ba4786a4837750e4",
"schema_version_16": "9d006faca62fd7ab787f64aef0e0a5933d142466ec4cab0e096bb920d2797e34",
"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 {
+20 -4
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)
@@ -25,9 +39,9 @@ func (s *Storage) NewEntryQueryBuilder(userID int64) *EntryQueryBuilder {
func (s *Storage) createEntry(entry *model.Entry) error {
query := `
INSERT INTO entries
(title, hash, url, published_at, content, author, user_id, feed_id)
(title, hash, url, comments_url, published_at, content, author, user_id, feed_id)
VALUES
($1, $2, $3, $4, $5, $6, $7, $8)
($1, $2, $3, $4, $5, $6, $7, $8, $9)
RETURNING id
`
err := s.db.QueryRow(
@@ -35,6 +49,7 @@ func (s *Storage) createEntry(entry *model.Entry) error {
entry.Title,
entry.Hash,
entry.URL,
entry.CommentsURL,
entry.Date,
entry.Content,
entry.Author,
@@ -82,14 +97,15 @@ func (s *Storage) UpdateEntryContent(entry *model.Entry) error {
func (s *Storage) updateEntry(entry *model.Entry) error {
query := `
UPDATE entries SET
title=$1, url=$2, content=$3, author=$4
WHERE user_id=$5 AND feed_id=$6 AND hash=$7
title=$1, url=$2, comments_url=$3, content=$4, author=$5
WHERE user_id=$6 AND feed_id=$7 AND hash=$8
RETURNING id
`
err := s.db.QueryRow(
query,
entry.Title,
entry.URL,
entry.CommentsURL,
entry.Content,
entry.Author,
entry.UserID,
+2 -1
View File
@@ -158,7 +158,7 @@ func (e *EntryQueryBuilder) GetEntries() (model.Entries, error) {
query := `
SELECT
e.id, e.user_id, e.feed_id, e.hash, e.published_at at time zone u.timezone, e.title,
e.url, e.author, e.content, e.status, e.starred,
e.url, e.comments_url, e.author, e.content, e.status, e.starred,
f.title as feed_title, f.feed_url, f.site_url, f.checked_at,
f.category_id, c.title as category_title, f.scraper_rules, f.rewrite_rules, f.crawler,
fi.icon_id,
@@ -199,6 +199,7 @@ func (e *EntryQueryBuilder) GetEntries() (model.Entries, error) {
&entry.Date,
&entry.Title,
&entry.URL,
&entry.CommentsURL,
&entry.Author,
&entry.Content,
&entry.Status,
+15
View File
@@ -176,3 +176,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')
`
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 = 14
const schemaVersion = 16
// 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
+5 -9
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.
@@ -82,7 +78,7 @@ func (s *Storage) UserSessionByToken(token string) (*model.UserSession, error) {
)
if err == sql.ErrNoRows {
return nil, fmt.Errorf("user session not found: %s", token)
return nil, nil
} else if err != nil {
return nil, fmt.Errorf("unable to fetch user session: %v", err)
}
+18 -11
View File
@@ -1,5 +1,5 @@
// Code generated by go generate; DO NOT EDIT.
// 2018-02-24 17:47:34.998457627 +0000 GMT
// 2018-04-29 17:36:50.459886967 -0700 PDT m=+0.024552529
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"
@@ -215,7 +222,7 @@ var templateCommonMap = map[string]string{
var templateCommonMapChecksums = map[string]string{
"entry_pagination": "f1465fa70f585ae8043b200ec9de5bf437ffbb0c19fb7aefc015c3555614ee27",
"item_meta": "4796b74adca0567f3dbf8bdf6ac8cda59f455ea34cb6d4a92c83660fa72a883d",
"item_meta": "6cff8ae243f19dac936e523867d2975f70aa749b2a461ae63f6ebbca94cf7419",
"layout": "c7565e2cf904612e236bc1d7167c6c124ffe5d27348608eb5c2336606f266896",
"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 -9
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"
+16 -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" }}"
@@ -36,6 +38,11 @@
data-label-done="{{ t "Done!" }}"
>{{ t "Fetch original content" }}</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>
<div class="entry-meta">
+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>
@@ -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>
+60 -53
View File
@@ -1,5 +1,5 @@
// Code generated by go generate; DO NOT EDIT.
// 2018-03-01 23:04:58.998374277 -0800 PST m=+0.022158179
// 2018-04-29 17:36:50.450844913 -0700 PDT m=+0.015510475
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" }}"
@@ -501,6 +534,11 @@ var templateViewsMap = map[string]string{
data-label-done="{{ t "Done!" }}"
>{{ t "Fetch original content" }}</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>
<div class="entry-meta">
@@ -613,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>
@@ -700,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">
@@ -729,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>
@@ -1085,38 +1123,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">
@@ -1148,7 +1155,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>
@@ -1225,24 +1232,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": "27ea028515e79beb546f0b2792a3918c455fd877eea4c41d1a061f8e7b54a430",
"feed_entries": "420da786e827a77fecc8794207d158af3a30e489ca2b2019f48d5228919af4a7",
"entry": "bd611521ebb46714fce434fe7fa5d4e53e50da4c3ed02450ad3557f614f16e14",
"feed_entries": "4dffdb55cfad29df20612efe7ed2dbed03d919c4556898543ab6450f610d3c99",
"feeds": "2a5abe37968ea34a0576dbef52341645cb1fc9562e351382fbf721491da6f4fa",
"history": "967bc95236269ab3a77455910aca1939f43f93171fe1af77eb3b1b4eac579e55",
"history_entries": "451f0b202f47c9db5344d3e73862f5b7afbd4323fbdba21b6087866c40f045d3",
"import": "73b5112e20bfd232bf73334544186ea419505936bc237d481517a8622901878f",
"integrations": "979193f39c2a3b43cec192aa119713cc9cbe2d5fdaedf8d2b3573c752823446c",
"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"))
}

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