Compare commits
39 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 39c4452142 | |||
| 2f4cd59ad9 | |||
| 5cacae6cf2 | |||
| 7a1653a2e9 | |||
| 31da4db14f | |||
| b166ceaea7 | |||
| f49b42f70f | |||
| 1eba1730d1 | |||
| 04adf5fdf5 | |||
| ddd3af4b85 | |||
| 6b360d08c1 | |||
| 322b265d7a | |||
| 920dda79b7 | |||
| 1ce522b98a | |||
| 657e96e133 | |||
| 0429bbb19d | |||
| 45dde0cf4a | |||
| 4cdb2f820b | |||
| dcbb5047b1 | |||
| 15202b8675 | |||
| 02ba735ba9 | |||
| 20f874399d | |||
| e2d02bac5a | |||
| f76093690c | |||
| 7640a8cbab | |||
| 3d59cdba10 | |||
| d3855fef3f | |||
| 336d44b00e | |||
| 46d67acf22 | |||
| 702256bcc0 | |||
| 538d08c16c | |||
| 449020c1e8 | |||
| c811849771 | |||
| 6ea4da3bce | |||
| 34cdffda88 | |||
| b1da081ae6 | |||
| 482785c5e6 | |||
| fec391a336 | |||
| ec08f45bf5 |
+1
-4
@@ -1,4 +1 @@
|
||||
miniflux-linux-amd64
|
||||
miniflux-linux-arm*
|
||||
miniflux-darwin-amd64
|
||||
miniflux-test
|
||||
miniflux-*
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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"
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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 client’s 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 client’s 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())
|
||||
}
|
||||
|
||||
/*
|
||||
|
||||
@@ -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}
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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
@@ -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
|
||||
@@ -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}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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"))
|
||||
}
|
||||
@@ -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}
|
||||
}
|
||||
@@ -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}
|
||||
}
|
||||
@@ -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}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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...)}
|
||||
}
|
||||
@@ -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}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"))
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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,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)
|
||||
}
|
||||
|
||||
@@ -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,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)
|
||||
}
|
||||
|
||||
@@ -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,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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -32,5 +32,6 @@ func AvailableLanguages() map[string]string {
|
||||
"de_DE": "Deutsch",
|
||||
"pl_PL": "Polski",
|
||||
"zh_CN": "简体中文",
|
||||
"nl_NL": "Nederlands",
|
||||
}
|
||||
}
|
||||
|
||||
+244
-15
@@ -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",
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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}
|
||||
}
|
||||
@@ -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}
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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
@@ -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})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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"`
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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 == "" {
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
alter table enclosures alter column size set data type bigint;
|
||||
@@ -0,0 +1 @@
|
||||
alter table entries add column comments_url text default '';
|
||||
+5
-1
@@ -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
@@ -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
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ import (
|
||||
"github.com/miniflux/miniflux/sql"
|
||||
)
|
||||
|
||||
const schemaVersion = 14
|
||||
const schemaVersion = 16
|
||||
|
||||
// Migrate run database migrations.
|
||||
func (s *Storage) Migrate() {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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
@@ -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
@@ -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"))
|
||||
}
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user