Compare commits

...

80 Commits

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

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

New behavior: like `img` elements, if an `iframe` is matched by a scraper rule,
the parent element's inner HTML (i.e. the `iframe` is returned).
2018-04-27 17:57:22 -07:00
aniran 920dda79b7 Add soundcloud and bandcamp iframe sources 2018-04-27 17:55:58 -07:00
Frédéric Guillot 1ce522b98a Update ChangeLog 2018-04-20 21:56:38 -07:00
Frédéric Guillot 657e96e133 Improve graceful shutdown 2018-04-17 21:50:52 -07:00
Frédéric Guillot 0429bbb19d Simplify Heroku deployment 2018-04-15 21:07:59 -07:00
Frédéric Guillot 45dde0cf4a Display memory usage and some metrics in logs 2018-04-14 14:23:05 -07:00
Frédéric Guillot 4cdb2f820b Increase read/write timeout for HTTP server 2018-04-14 13:52:53 -07:00
Frédéric Guillot dcbb5047b1 Add support for Dublin Core date in RDF feeds 2018-04-10 18:13:05 -07:00
Frédéric Guillot 15202b8675 Do not return an error if the user session is not found 2018-04-09 21:52:24 -07:00
Frédéric Guillot 02ba735ba9 Handle some non-english date formats 2018-04-09 21:27:15 -07:00
Frédéric Guillot 20f874399d Add missing French translation 2018-04-09 20:39:56 -07:00
Frédéric Guillot e2d02bac5a Rename RSS parser getters 2018-04-09 20:38:12 -07:00
Frédéric Guillot f76093690c Get the right comments URL when having multiple namespaces 2018-04-09 20:30:55 -07:00
Frédéric Guillot 7640a8cbab Ignore caching headers for feeds that send "Expires: 0" 2018-04-09 20:18:54 -07:00
stratmaster 3d59cdba10 Add missing translation string 2018-04-09 18:24:24 -07:00
stratmaster d3855fef3f Update German translation 2018-04-09 10:33:40 -07:00
Frédéric Guillot 336d44b00e Update ChangeLog 2018-04-07 15:47:18 -07:00
Frédéric Guillot 46d67acf22 Avoid unread counter to be off by one 2018-04-07 14:20:42 -07:00
Frédéric Guillot 702256bcc0 Add unit test for comments url and French translation 2018-04-07 13:56:11 -07:00
Ben Brooks 538d08c16c Add CommentsURL to entry 2018-04-07 13:50:45 -07:00
Frédéric Guillot 449020c1e8 Update .gitignore 2018-03-18 20:48:58 -07:00
MoritzFago c811849771 Add FreeBSD build target 2018-03-18 20:44:13 -07:00
Frédéric Guillot 6ea4da3bce Handle RSS author elements with inner HTML 2018-03-18 11:57:46 -07:00
Frédéric Guillot 34cdffda88 Fix typo in translations 2018-03-17 13:39:08 -07:00
Daan Sprenkels b1da081ae6 Add dutch translations 2018-03-17 13:33:02 -07:00
Frédéric Guillot 482785c5e6 Convert enclosure size field to bigint 2018-03-14 20:09:06 -07:00
Frédéric Guillot fec391a336 Switch Travis to Go 1.10 2018-03-14 18:51:41 -07:00
Frédéric Guillot ec08f45bf5 Fix broken OPML import with Go 1.10 2018-03-14 18:50:06 -07:00
Frédéric Guillot dd68181a83 Update ChangeLog 2018-03-05 21:42:52 -08:00
Frédéric Guillot a8be61cdbb Filter the list of timezones 2018-03-04 17:38:08 -08:00
Frédéric Guillot 609c57332e Show last login and session creation date in current timezone 2018-03-04 17:04:31 -08:00
Frédéric Guillot 5185bf0c7e Fix typo in edit user template 2018-03-01 23:06:28 -08:00
Frédéric Guillot cbd273da2b Improve for/range loop to avoid linter error 2018-03-01 21:43:25 -08:00
Frédéric Guillot 0c7039de0e Entries date should contains user timezone (API) 2018-03-01 21:43:04 -08:00
Frédéric Guillot f110384f11 Improve parser error messages 2018-02-27 21:19:59 -08:00
Frédéric Guillot 953d0a2dc0 Support localized feed errors generated by background workers 2018-02-27 21:08:32 -08:00
ReVanTis, Zhao 9694861cb6 Add Simplified Chinese Localization 2018-02-27 19:58:51 -08:00
Nicolas Carlier 34ce114231 Add Nunux Keeper integration 2018-02-25 11:49:08 -08:00
Frédéric Guillot 3030145b30 Remove parentheses around feed error messages 2018-02-23 19:58:08 -08:00
Frédéric Guillot a9f0fdaf22 Print info message if DATABASE_URL is not set 2018-02-23 18:26:34 -08:00
Frédéric Guillot 73a0a25b6c Update ChangeLog 2018-02-19 20:15:57 -08:00
Sebastian Korotkiewicz 2786b8f163 Update Polish translation 2018-02-19 15:15:03 -08:00
Frédéric Guillot 9292d5d604 Handle Atom feeds with HTML title 2018-02-17 12:21:58 -08:00
Frédéric Guillot 3ba280e10c There are more than 2 plurals in Polish 2018-02-17 11:43:37 -08:00
Sebastian Korotkiewicz 4eceeaaca4 Add Polish translation 2018-02-17 11:29:14 -08:00
Frédéric Guillot 205aef595b Change color of <q> tags for black theme 2018-02-10 22:35:02 -08:00
Frédéric Guillot a006a93a04 Add indexes 2018-02-10 22:30:43 -08:00
stratmaster 30e80c675e Update German translation 2018-02-09 18:20:06 -08:00
Frédéric Guillot dda9114692 Improve error handling for HTTP client 2018-02-08 18:16:54 -08:00
stratmaster 16c2dc4a8c Add missing German string 2018-02-08 12:41:14 -08:00
Frédéric Guillot 7b0bfd9308 Strip invalid XML characters to avoid parsing errors 2018-02-07 20:57:56 -08:00
Frédéric Guillot c6fd9eb9b1 Remove period for feed errors 2018-02-07 19:10:36 -08:00
Frédéric Guillot 0fb87eba3f Improve error handling when the response is empty 2018-02-07 18:47:47 -08:00
stratmaster 1e70ca1a19 Add missing French translation 2018-02-07 18:05:58 -08:00
238 changed files with 9014 additions and 4392 deletions
+1 -4
View File
@@ -1,4 +1 @@
miniflux-linux-amd64
miniflux-linux-arm*
miniflux-darwin-amd64
miniflux-test
miniflux-*
+1 -1
View File
@@ -6,7 +6,7 @@ addons:
postgresql: "9.4"
language: go
go:
- 1.9
- "1.10"
before_install:
- npm install -g jshint
- go get -u github.com/golang/lint/golint
+83
View File
@@ -1,3 +1,86 @@
Version 2.0.8 (June 4, 2018)
----------------------------
* Add Pocket integration
* Rewrite RealIP() to avoid returning an empty string
* Convert IP field from text to inet type
* Improve error handling in integration clients
* Make unread counter clickable
* Archive read entries automatically after 60 days
* Hide horizontal overflow when swiping articles on touch devices
* Add API endpoint to get logged user
* Fever API: Return response with an empty list if there is no item
* Handle feeds with dates formatted as Unix timestamp
Version 2.0.7 (May 7, 2018)
---------------------------
* Add API endpoint to import OPML file
* Make sure to close request body in HTTP client
* Do not show save link if no integration is configured
* Make sure integrations are configured before to make any HTTP requests
* Avoid people to unlink their OAuth2 account without having a local password
* Do not use shared variable to translate templates (avoid concurrency issue)
* Use vanilla HTTP handlers (refactoring)
* Move HTTP client to its own package (refactoring)
* Add middleware to read X-Forwarded-Proto header (refactoring)
* Use Gorilla middleware (refactoring)
* Scrape parent element for iframe
* Add SoundCloud and Bandcamp iframe sources
Version 2.0.6 (Apr 20, 2018)
----------------------------
* 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)
---------------------------
* Add Simplified Chinese translation
* Add Nunux Keeper integration
* Filter the list of timezones
* Add timezone to entries dates for REST and Fever API
* Show last login and session creation date in current timezone
* Fix typo in edit user template
* Improve parser error messages
* Remove parentheses around feed error messages
* Support localized feed errors generated by background workers
* Print info message if DATABASE_URL is not set
Version 2.0.3 (Feb 19, 2018)
----------------------------
* Add Polish translation
* Change color of <q> tags for black theme
* Add database indexes (don't forget to run database migrations)
* Handle Atom feeds with HTML title
* Strip invalid XML characters to avoid parsing errors
* Improve error handling for HTTP client
Version 2.0.2 (Feb 5, 2018)
---------------------------
Generated
+4 -10
View File
@@ -28,8 +28,8 @@
[[projects]]
name = "github.com/gorilla/mux"
packages = ["."]
revision = "7f08801859139f86dfafd1c296e2cba9a80d292e"
version = "v1.6.0"
revision = "53c1911da2b537f792e7cafcb446b05ffe33b996"
version = "v1.6.1"
[[projects]]
branch = "master"
@@ -45,7 +45,7 @@
branch = "master"
name = "github.com/miniflux/miniflux-go"
packages = ["."]
revision = "887ba3b062946784f0e64edb1734f435beb204f9"
revision = "8863d558cbf1f20beeb640328829205971f1a632"
[[projects]]
name = "github.com/tdewolff/minify"
@@ -69,12 +69,6 @@
revision = "639f6272aec6b52094db77b9ec488214b0b4b1a1"
version = "v2.3.2"
[[projects]]
branch = "master"
name = "github.com/tomasen/realip"
packages = ["."]
revision = "b5850897b7b539a1c9f22cdaa3b547d1bd453db8"
[[projects]]
branch = "master"
name = "golang.org/x/crypto"
@@ -158,6 +152,6 @@
[solve-meta]
analyzer-name = "dep"
analyzer-version = 1
inputs-digest = "338222e5111416c46b2b8bde149443abc542b386dd02aff2a0dd6e13334bcf28"
inputs-digest = "f030af166f90ac96d133b7ddf9f1f80785508462cac8cae4c65abbdb7c20474b"
solver-name = "gps-cdcl"
solver-version = 1
+5 -5
View File
@@ -20,6 +20,10 @@
# name = "github.com/x/y"
# version = "2.4.0"
[metadata.heroku]
root-package = "github.com/miniflux/miniflux"
go-version = "go1.10"
ensure = "false"
[[constraint]]
name = "github.com/PuerkitoBio/goquery"
@@ -27,7 +31,7 @@
[[constraint]]
name = "github.com/gorilla/mux"
version = "1.6.0"
version = "1.6.1"
[[constraint]]
branch = "master"
@@ -41,10 +45,6 @@
name = "github.com/tdewolff/minify"
version = "2.3.3"
[[constraint]]
branch = "master"
name = "github.com/tomasen/realip"
[[constraint]]
branch = "master"
name = "golang.org/x/crypto"
+6 -2
View File
@@ -4,7 +4,7 @@ BUILD_DATE=`date +%FT%T%z`
PKG_LIST := $(shell go list ./... | grep -v /vendor/)
DB_URL := postgres://postgres:postgres@localhost/miniflux_test?sslmode=disable
.PHONY: linux linux-arm darwin build run clean test lint integration-test clean-integration-test
.PHONY: linux linux-arm darwin freebsd build run clean test lint integration-test clean-integration-test
linux:
@ go generate
@@ -21,7 +21,11 @@ darwin:
@ go generate
@ GOOS=darwin GOARCH=amd64 go build -ldflags="-X 'github.com/miniflux/miniflux/version.Version=$(VERSION)' -X 'github.com/miniflux/miniflux/version.BuildDate=$(BUILD_DATE)'" -o $(APP)-darwin-amd64 main.go
build: linux linux-arm darwin
freebsd:
@ go generate
@ GOOS=freebsd GOARCH=amd64 go build -ldflags="-X 'githug.com/miniflux/miniflux/version.Version=$(VERSION)' -X 'github.com/miniflux/miniflux/version.BuildDate=$(BUILD_DATE)'" -o $(APP)-freebsd-amd64 main.go
build: linux linux-arm darwin freebsd
run:
@ go generate
+2 -1
View File
@@ -25,7 +25,8 @@ The Miniflux documentation is available here: <https://docs.miniflux.net/>
- [Opinionated?](https://docs.miniflux.net/en/latest/opinionated.html)
- [Features](https://docs.miniflux.net/en/latest/features.html)
- [Requirements](https://docs.miniflux.net/en/latest/requirements.html)
- [Installation](https://docs.miniflux.net/en/latest/installation.html)
- [Installation Instructions](https://docs.miniflux.net/en/latest/installation.html)
- [Installation Tutorials](https://docs.miniflux.net/en/latest/tutorials.html)
- [Upgrading to a new version](https://docs.miniflux.net/en/latest/upgrade.html)
- [Configuration](https://docs.miniflux.net/en/latest/configuration.html)
+33 -26
View File
@@ -6,98 +6,105 @@ package api
import (
"errors"
"net/http"
"github.com/miniflux/miniflux/http/handler"
"github.com/miniflux/miniflux/http/context"
"github.com/miniflux/miniflux/http/request"
"github.com/miniflux/miniflux/http/response/json"
)
// CreateCategory is the API handler to create a new category.
func (c *Controller) CreateCategory(ctx *handler.Context, request *handler.Request, response *handler.Response) {
userID := ctx.UserID()
category, err := decodeCategoryPayload(request.Body())
func (c *Controller) CreateCategory(w http.ResponseWriter, r *http.Request) {
category, err := decodeCategoryPayload(r.Body)
if err != nil {
response.JSON().BadRequest(err)
json.BadRequest(w, err)
return
}
ctx := context.New(r)
userID := ctx.UserID()
category.UserID = userID
if err := category.ValidateCategoryCreation(); err != nil {
response.JSON().BadRequest(err)
json.BadRequest(w, err)
return
}
if c, err := c.store.CategoryByTitle(userID, category.Title); err != nil || c != nil {
response.JSON().BadRequest(errors.New("This category already exists"))
json.BadRequest(w, errors.New("This category already exists"))
return
}
err = c.store.CreateCategory(category)
if err != nil {
response.JSON().ServerError(errors.New("Unable to create this category"))
json.ServerError(w, errors.New("Unable to create this category"))
return
}
response.JSON().Created(category)
json.Created(w, category)
}
// UpdateCategory is the API handler to update a category.
func (c *Controller) UpdateCategory(ctx *handler.Context, request *handler.Request, response *handler.Response) {
categoryID, err := request.IntegerParam("categoryID")
func (c *Controller) UpdateCategory(w http.ResponseWriter, r *http.Request) {
categoryID, err := request.IntParam(r, "categoryID")
if err != nil {
response.JSON().BadRequest(err)
json.BadRequest(w, err)
return
}
category, err := decodeCategoryPayload(request.Body())
category, err := decodeCategoryPayload(r.Body)
if err != nil {
response.JSON().BadRequest(err)
json.BadRequest(w, err)
return
}
ctx := context.New(r)
category.UserID = ctx.UserID()
category.ID = categoryID
if err := category.ValidateCategoryModification(); err != nil {
response.JSON().BadRequest(err)
json.BadRequest(w, err)
return
}
err = c.store.UpdateCategory(category)
if err != nil {
response.JSON().ServerError(errors.New("Unable to update this category"))
json.ServerError(w, errors.New("Unable to update this category"))
return
}
response.JSON().Created(category)
json.Created(w, category)
}
// GetCategories is the API handler to get a list of categories for a given user.
func (c *Controller) GetCategories(ctx *handler.Context, request *handler.Request, response *handler.Response) {
func (c *Controller) GetCategories(w http.ResponseWriter, r *http.Request) {
ctx := context.New(r)
categories, err := c.store.Categories(ctx.UserID())
if err != nil {
response.JSON().ServerError(errors.New("Unable to fetch categories"))
json.ServerError(w, errors.New("Unable to fetch categories"))
return
}
response.JSON().Standard(categories)
json.OK(w, categories)
}
// RemoveCategory is the API handler to remove a category.
func (c *Controller) RemoveCategory(ctx *handler.Context, request *handler.Request, response *handler.Response) {
func (c *Controller) RemoveCategory(w http.ResponseWriter, r *http.Request) {
ctx := context.New(r)
userID := ctx.UserID()
categoryID, err := request.IntegerParam("categoryID")
categoryID, err := request.IntParam(r, "categoryID")
if err != nil {
response.JSON().BadRequest(err)
json.BadRequest(w, err)
return
}
if !c.store.CategoryExists(userID, categoryID) {
response.JSON().NotFound(errors.New("Category not found"))
json.NotFound(w, errors.New("Category not found"))
return
}
if err := c.store.RemoveCategory(userID, categoryID); err != nil {
response.JSON().ServerError(errors.New("Unable to remove this category"))
json.ServerError(w, errors.New("Unable to remove this category"))
return
}
response.JSON().NoContent()
json.NoContent(w)
}
+65 -67
View File
@@ -6,107 +6,110 @@ package api
import (
"errors"
"net/http"
"github.com/miniflux/miniflux/http/handler"
"github.com/miniflux/miniflux/http/context"
"github.com/miniflux/miniflux/http/request"
"github.com/miniflux/miniflux/http/response/json"
"github.com/miniflux/miniflux/model"
)
// GetFeedEntry is the API handler to get a single feed entry.
func (c *Controller) GetFeedEntry(ctx *handler.Context, request *handler.Request, response *handler.Response) {
userID := ctx.UserID()
feedID, err := request.IntegerParam("feedID")
func (c *Controller) GetFeedEntry(w http.ResponseWriter, r *http.Request) {
feedID, err := request.IntParam(r, "feedID")
if err != nil {
response.JSON().BadRequest(err)
json.BadRequest(w, err)
return
}
entryID, err := request.IntegerParam("entryID")
entryID, err := request.IntParam(r, "entryID")
if err != nil {
response.JSON().BadRequest(err)
json.BadRequest(w, err)
return
}
ctx := context.New(r)
userID := ctx.UserID()
builder := c.store.NewEntryQueryBuilder(userID)
builder.WithFeedID(feedID)
builder.WithEntryID(entryID)
entry, err := builder.GetEntry()
if err != nil {
response.JSON().ServerError(errors.New("Unable to fetch this entry from the database"))
json.ServerError(w, errors.New("Unable to fetch this entry from the database"))
return
}
if entry == nil {
response.JSON().NotFound(errors.New("Entry not found"))
json.NotFound(w, errors.New("Entry not found"))
return
}
response.JSON().Standard(entry)
json.OK(w, entry)
}
// GetEntry is the API handler to get a single entry.
func (c *Controller) GetEntry(ctx *handler.Context, request *handler.Request, response *handler.Response) {
userID := ctx.UserID()
entryID, err := request.IntegerParam("entryID")
func (c *Controller) GetEntry(w http.ResponseWriter, r *http.Request) {
entryID, err := request.IntParam(r, "entryID")
if err != nil {
response.JSON().BadRequest(err)
json.BadRequest(w, err)
return
}
builder := c.store.NewEntryQueryBuilder(userID)
builder := c.store.NewEntryQueryBuilder(context.New(r).UserID())
builder.WithEntryID(entryID)
entry, err := builder.GetEntry()
if err != nil {
response.JSON().ServerError(errors.New("Unable to fetch this entry from the database"))
json.ServerError(w, errors.New("Unable to fetch this entry from the database"))
return
}
if entry == nil {
response.JSON().NotFound(errors.New("Entry not found"))
json.NotFound(w, errors.New("Entry not found"))
return
}
response.JSON().Standard(entry)
json.OK(w, entry)
}
// GetFeedEntries is the API handler to get all feed entries.
func (c *Controller) GetFeedEntries(ctx *handler.Context, request *handler.Request, response *handler.Response) {
userID := ctx.UserID()
feedID, err := request.IntegerParam("feedID")
func (c *Controller) GetFeedEntries(w http.ResponseWriter, r *http.Request) {
feedID, err := request.IntParam(r, "feedID")
if err != nil {
response.JSON().BadRequest(err)
json.BadRequest(w, err)
return
}
status := request.QueryStringParam("status", "")
status := request.QueryParam(r, "status", "")
if status != "" {
if err := model.ValidateEntryStatus(status); err != nil {
response.JSON().BadRequest(err)
json.BadRequest(w, err)
return
}
}
order := request.QueryStringParam("order", model.DefaultSortingOrder)
order := request.QueryParam(r, "order", model.DefaultSortingOrder)
if err := model.ValidateEntryOrder(order); err != nil {
response.JSON().BadRequest(err)
json.BadRequest(w, err)
return
}
direction := request.QueryStringParam("direction", model.DefaultSortingDirection)
direction := request.QueryParam(r, "direction", model.DefaultSortingDirection)
if err := model.ValidateDirection(direction); err != nil {
response.JSON().BadRequest(err)
json.BadRequest(w, err)
return
}
limit := request.QueryIntegerParam("limit", 100)
offset := request.QueryIntegerParam("offset", 0)
limit := request.QueryIntParam(r, "limit", 100)
offset := request.QueryIntParam(r, "offset", 0)
if err := model.ValidateRange(offset, limit); err != nil {
response.JSON().BadRequest(err)
json.BadRequest(w, err)
return
}
builder := c.store.NewEntryQueryBuilder(userID)
builder := c.store.NewEntryQueryBuilder(context.New(r).UserID())
builder.WithFeedID(feedID)
builder.WithStatus(status)
builder.WithOrder(order)
@@ -116,51 +119,49 @@ func (c *Controller) GetFeedEntries(ctx *handler.Context, request *handler.Reque
entries, err := builder.GetEntries()
if err != nil {
response.JSON().ServerError(errors.New("Unable to fetch the list of entries"))
json.ServerError(w, errors.New("Unable to fetch the list of entries"))
return
}
count, err := builder.CountEntries()
if err != nil {
response.JSON().ServerError(errors.New("Unable to count the number of entries"))
json.ServerError(w, errors.New("Unable to count the number of entries"))
return
}
response.JSON().Standard(&entriesResponse{Total: count, Entries: entries})
json.OK(w, &entriesResponse{Total: count, Entries: entries})
}
// GetEntries is the API handler to fetch entries.
func (c *Controller) GetEntries(ctx *handler.Context, request *handler.Request, response *handler.Response) {
userID := ctx.UserID()
status := request.QueryStringParam("status", "")
func (c *Controller) GetEntries(w http.ResponseWriter, r *http.Request) {
status := request.QueryParam(r, "status", "")
if status != "" {
if err := model.ValidateEntryStatus(status); err != nil {
response.JSON().BadRequest(err)
json.BadRequest(w, err)
return
}
}
order := request.QueryStringParam("order", model.DefaultSortingOrder)
order := request.QueryParam(r, "order", model.DefaultSortingOrder)
if err := model.ValidateEntryOrder(order); err != nil {
response.JSON().BadRequest(err)
json.BadRequest(w, err)
return
}
direction := request.QueryStringParam("direction", model.DefaultSortingDirection)
direction := request.QueryParam(r, "direction", model.DefaultSortingDirection)
if err := model.ValidateDirection(direction); err != nil {
response.JSON().BadRequest(err)
json.BadRequest(w, err)
return
}
limit := request.QueryIntegerParam("limit", 100)
offset := request.QueryIntegerParam("offset", 0)
limit := request.QueryIntParam(r, "limit", 100)
offset := request.QueryIntParam(r, "offset", 0)
if err := model.ValidateRange(offset, limit); err != nil {
response.JSON().BadRequest(err)
json.BadRequest(w, err)
return
}
builder := c.store.NewEntryQueryBuilder(userID)
builder := c.store.NewEntryQueryBuilder(context.New(r).UserID())
builder.WithStatus(status)
builder.WithOrder(order)
builder.WithDirection(direction)
@@ -169,55 +170,52 @@ func (c *Controller) GetEntries(ctx *handler.Context, request *handler.Request,
entries, err := builder.GetEntries()
if err != nil {
response.JSON().ServerError(errors.New("Unable to fetch the list of entries"))
json.ServerError(w, errors.New("Unable to fetch the list of entries"))
return
}
count, err := builder.CountEntries()
if err != nil {
response.JSON().ServerError(errors.New("Unable to count the number of entries"))
json.ServerError(w, errors.New("Unable to count the number of entries"))
return
}
response.JSON().Standard(&entriesResponse{Total: count, Entries: entries})
json.OK(w, &entriesResponse{Total: count, Entries: entries})
}
// SetEntryStatus is the API handler to change the status of entries.
func (c *Controller) SetEntryStatus(ctx *handler.Context, request *handler.Request, response *handler.Response) {
userID := ctx.UserID()
entryIDs, status, err := decodeEntryStatusPayload(request.Body())
func (c *Controller) SetEntryStatus(w http.ResponseWriter, r *http.Request) {
entryIDs, status, err := decodeEntryStatusPayload(r.Body)
if err != nil {
response.JSON().BadRequest(errors.New("Invalid JSON payload"))
json.BadRequest(w, errors.New("Invalid JSON payload"))
return
}
if err := model.ValidateEntryStatus(status); err != nil {
response.JSON().BadRequest(err)
json.BadRequest(w, err)
return
}
if err := c.store.SetEntriesStatus(userID, entryIDs, status); err != nil {
response.JSON().ServerError(errors.New("Unable to change entries status"))
if err := c.store.SetEntriesStatus(context.New(r).UserID(), entryIDs, status); err != nil {
json.ServerError(w, errors.New("Unable to change entries status"))
return
}
response.JSON().NoContent()
json.NoContent(w)
}
// ToggleBookmark is the API handler to toggle bookmark status.
func (c *Controller) ToggleBookmark(ctx *handler.Context, request *handler.Request, response *handler.Response) {
userID := ctx.UserID()
entryID, err := request.IntegerParam("entryID")
func (c *Controller) ToggleBookmark(w http.ResponseWriter, r *http.Request) {
entryID, err := request.IntParam(r, "entryID")
if err != nil {
response.JSON().BadRequest(err)
json.BadRequest(w, err)
return
}
if err := c.store.ToggleBookmark(userID, entryID); err != nil {
response.JSON().ServerError(errors.New("Unable to toggle bookmark value"))
if err := c.store.ToggleBookmark(context.New(r).UserID(), entryID); err != nil {
json.ServerError(w, errors.New("Unable to toggle bookmark value"))
return
}
response.JSON().NoContent()
json.NoContent(w)
}
+59 -63
View File
@@ -6,44 +6,47 @@ package api
import (
"errors"
"net/http"
"github.com/miniflux/miniflux/reader/opml"
"github.com/miniflux/miniflux/http/handler"
"github.com/miniflux/miniflux/http/context"
"github.com/miniflux/miniflux/http/request"
"github.com/miniflux/miniflux/http/response/json"
)
// CreateFeed is the API handler to create a new feed.
func (c *Controller) CreateFeed(ctx *handler.Context, request *handler.Request, response *handler.Response) {
userID := ctx.UserID()
feedURL, categoryID, err := decodeFeedCreationPayload(request.Body())
func (c *Controller) CreateFeed(w http.ResponseWriter, r *http.Request) {
feedURL, categoryID, err := decodeFeedCreationPayload(r.Body)
if err != nil {
response.JSON().BadRequest(err)
json.BadRequest(w, err)
return
}
if feedURL == "" {
response.JSON().BadRequest(errors.New("The feed_url is required"))
json.BadRequest(w, errors.New("The feed_url is required"))
return
}
if categoryID <= 0 {
response.JSON().BadRequest(errors.New("The category_id is required"))
json.BadRequest(w, errors.New("The category_id is required"))
return
}
ctx := context.New(r)
userID := ctx.UserID()
if c.store.FeedURLExists(userID, feedURL) {
response.JSON().BadRequest(errors.New("This feed_url already exists"))
json.BadRequest(w, errors.New("This feed_url already exists"))
return
}
if !c.store.CategoryExists(userID, categoryID) {
response.JSON().BadRequest(errors.New("This category_id doesn't exists or doesn't belongs to this user"))
json.BadRequest(w, errors.New("This category_id doesn't exists or doesn't belongs to this user"))
return
}
feed, err := c.feedHandler.CreateFeed(userID, categoryID, feedURL, false)
if err != nil {
response.JSON().ServerError(errors.New("Unable to create this feed"))
json.ServerError(w, errors.New("Unable to create this feed"))
return
}
@@ -51,142 +54,135 @@ func (c *Controller) CreateFeed(ctx *handler.Context, request *handler.Request,
FeedID int64 `json:"feed_id"`
}
response.JSON().Created(&result{FeedID: feed.ID})
json.Created(w, &result{FeedID: feed.ID})
}
// RefreshFeed is the API handler to refresh a feed.
func (c *Controller) RefreshFeed(ctx *handler.Context, request *handler.Request, response *handler.Response) {
userID := ctx.UserID()
feedID, err := request.IntegerParam("feedID")
func (c *Controller) RefreshFeed(w http.ResponseWriter, r *http.Request) {
feedID, err := request.IntParam(r, "feedID")
if err != nil {
response.JSON().BadRequest(err)
json.BadRequest(w, err)
return
}
ctx := context.New(r)
userID := ctx.UserID()
if !c.store.FeedExists(userID, feedID) {
response.JSON().NotFound(errors.New("Unable to find this feed"))
json.NotFound(w, errors.New("Unable to find this feed"))
return
}
err = c.feedHandler.RefreshFeed(userID, feedID)
if err != nil {
response.JSON().ServerError(errors.New("Unable to refresh this feed"))
json.ServerError(w, errors.New("Unable to refresh this feed"))
return
}
response.JSON().NoContent()
json.NoContent(w)
}
// UpdateFeed is the API handler that is used to update a feed.
func (c *Controller) UpdateFeed(ctx *handler.Context, request *handler.Request, response *handler.Response) {
userID := ctx.UserID()
feedID, err := request.IntegerParam("feedID")
func (c *Controller) UpdateFeed(w http.ResponseWriter, r *http.Request) {
feedID, err := request.IntParam(r, "feedID")
if err != nil {
response.JSON().BadRequest(err)
json.BadRequest(w, err)
return
}
newFeed, err := decodeFeedModificationPayload(request.Body())
newFeed, err := decodeFeedModificationPayload(r.Body)
if err != nil {
response.JSON().BadRequest(err)
json.BadRequest(w, err)
return
}
ctx := context.New(r)
userID := ctx.UserID()
if newFeed.Category != nil && newFeed.Category.ID != 0 && !c.store.CategoryExists(userID, newFeed.Category.ID) {
response.JSON().BadRequest(errors.New("This category_id doesn't exists or doesn't belongs to this user"))
json.BadRequest(w, errors.New("This category_id doesn't exists or doesn't belongs to this user"))
return
}
originalFeed, err := c.store.FeedByID(userID, feedID)
if err != nil {
response.JSON().NotFound(errors.New("Unable to find this feed"))
json.NotFound(w, errors.New("Unable to find this feed"))
return
}
if originalFeed == nil {
response.JSON().NotFound(errors.New("Feed not found"))
json.NotFound(w, errors.New("Feed not found"))
return
}
originalFeed.Merge(newFeed)
if err := c.store.UpdateFeed(originalFeed); err != nil {
response.JSON().ServerError(errors.New("Unable to update this feed"))
json.ServerError(w, errors.New("Unable to update this feed"))
return
}
originalFeed, err = c.store.FeedByID(userID, feedID)
if err != nil {
response.JSON().ServerError(errors.New("Unable to fetch this feed"))
json.ServerError(w, errors.New("Unable to fetch this feed"))
return
}
response.JSON().Created(originalFeed)
json.Created(w, originalFeed)
}
// GetFeeds is the API handler that get all feeds that belongs to the given user.
func (c *Controller) GetFeeds(ctx *handler.Context, request *handler.Request, response *handler.Response) {
feeds, err := c.store.Feeds(ctx.UserID())
func (c *Controller) GetFeeds(w http.ResponseWriter, r *http.Request) {
feeds, err := c.store.Feeds(context.New(r).UserID())
if err != nil {
response.JSON().ServerError(errors.New("Unable to fetch feeds from the database"))
json.ServerError(w, errors.New("Unable to fetch feeds from the database"))
return
}
response.JSON().Standard(feeds)
}
// Export is the API handler that incoves an OPML export.
func (c *Controller) Export(ctx *handler.Context, request *handler.Request, response *handler.Response) {
opmlHandler := opml.NewHandler(c.store)
opml, err := opmlHandler.Export(ctx.LoggedUser().ID)
if err != nil {
response.JSON().ServerError(errors.New("unable to export feeds to OPML"))
}
response.XML().Serve(opml)
json.OK(w, feeds)
}
// GetFeed is the API handler to get a feed.
func (c *Controller) GetFeed(ctx *handler.Context, request *handler.Request, response *handler.Response) {
userID := ctx.UserID()
feedID, err := request.IntegerParam("feedID")
func (c *Controller) GetFeed(w http.ResponseWriter, r *http.Request) {
feedID, err := request.IntParam(r, "feedID")
if err != nil {
response.JSON().BadRequest(err)
json.BadRequest(w, err)
return
}
feed, err := c.store.FeedByID(userID, feedID)
feed, err := c.store.FeedByID(context.New(r).UserID(), feedID)
if err != nil {
response.JSON().ServerError(errors.New("Unable to fetch this feed"))
json.ServerError(w, errors.New("Unable to fetch this feed"))
return
}
if feed == nil {
response.JSON().NotFound(errors.New("Feed not found"))
json.NotFound(w, errors.New("Feed not found"))
return
}
response.JSON().Standard(feed)
json.OK(w, feed)
}
// RemoveFeed is the API handler to remove a feed.
func (c *Controller) RemoveFeed(ctx *handler.Context, request *handler.Request, response *handler.Response) {
userID := ctx.UserID()
feedID, err := request.IntegerParam("feedID")
func (c *Controller) RemoveFeed(w http.ResponseWriter, r *http.Request) {
feedID, err := request.IntParam(r, "feedID")
if err != nil {
response.JSON().BadRequest(err)
json.BadRequest(w, err)
return
}
ctx := context.New(r)
userID := ctx.UserID()
if !c.store.FeedExists(userID, feedID) {
response.JSON().NotFound(errors.New("Feed not found"))
json.NotFound(w, errors.New("Feed not found"))
return
}
if err := c.store.RemoveFeed(userID, feedID); err != nil {
response.JSON().ServerError(errors.New("Unable to remove this feed"))
json.ServerError(w, errors.New("Unable to remove this feed"))
return
}
response.JSON().NoContent()
json.NoContent(w)
}
+12 -10
View File
@@ -6,36 +6,38 @@ package api
import (
"errors"
"net/http"
"github.com/miniflux/miniflux/http/handler"
"github.com/miniflux/miniflux/http/context"
"github.com/miniflux/miniflux/http/request"
"github.com/miniflux/miniflux/http/response/json"
)
// FeedIcon returns a feed icon.
func (c *Controller) FeedIcon(ctx *handler.Context, request *handler.Request, response *handler.Response) {
userID := ctx.UserID()
feedID, err := request.IntegerParam("feedID")
func (c *Controller) FeedIcon(w http.ResponseWriter, r *http.Request) {
feedID, err := request.IntParam(r, "feedID")
if err != nil {
response.JSON().BadRequest(err)
json.BadRequest(w, err)
return
}
if !c.store.HasIcon(feedID) {
response.JSON().NotFound(errors.New("This feed doesn't have any icon"))
json.NotFound(w, errors.New("This feed doesn't have any icon"))
return
}
icon, err := c.store.IconByFeedID(userID, feedID)
icon, err := c.store.IconByFeedID(context.New(r).UserID(), feedID)
if err != nil {
response.JSON().ServerError(errors.New("Unable to fetch feed icon"))
json.ServerError(w, errors.New("Unable to fetch feed icon"))
return
}
if icon == nil {
response.JSON().NotFound(errors.New("This feed doesn't have any icon"))
json.NotFound(w, errors.New("This feed doesn't have any icon"))
return
}
response.JSON().Standard(&feedIcon{
json.OK(w, &feedIcon{
ID: icon.ID,
MimeType: icon.MimeType,
Data: icon.DataURL(),
+39
View File
@@ -0,0 +1,39 @@
// Copyright 2018 Frédéric Guillot. All rights reserved.
// Use of this source code is governed by the Apache 2.0
// license that can be found in the LICENSE file.
package api
import (
"net/http"
"github.com/miniflux/miniflux/http/context"
"github.com/miniflux/miniflux/http/response/json"
"github.com/miniflux/miniflux/http/response/xml"
"github.com/miniflux/miniflux/reader/opml"
)
// Export is the API handler that export feeds to OPML.
func (c *Controller) Export(w http.ResponseWriter, r *http.Request) {
opmlHandler := opml.NewHandler(c.store)
opml, err := opmlHandler.Export(context.New(r).UserID())
if err != nil {
json.ServerError(w, err)
return
}
xml.OK(w, opml)
}
// Import is the API handler that import an OPML file.
func (c *Controller) Import(w http.ResponseWriter, r *http.Request) {
opmlHandler := opml.NewHandler(c.store)
err := opmlHandler.Import(context.New(r).UserID(), r.Body)
defer r.Body.Close()
if err != nil {
json.ServerError(w, err)
return
}
json.Created(w, map[string]string{"message": "Feeds imported successfully"})
}
+18 -12
View File
@@ -23,10 +23,11 @@ type entriesResponse struct {
Entries model.Entries `json:"entries"`
}
func decodeUserPayload(data io.Reader) (*model.User, error) {
func decodeUserPayload(r io.ReadCloser) (*model.User, error) {
var user model.User
decoder := json.NewDecoder(data)
decoder := json.NewDecoder(r)
defer r.Close()
if err := decoder.Decode(&user); err != nil {
return nil, fmt.Errorf("Unable to decode user JSON object: %v", err)
}
@@ -34,13 +35,14 @@ func decodeUserPayload(data io.Reader) (*model.User, error) {
return &user, nil
}
func decodeURLPayload(data io.Reader) (string, error) {
func decodeURLPayload(r io.ReadCloser) (string, error) {
type payload struct {
URL string `json:"url"`
}
var p payload
decoder := json.NewDecoder(data)
decoder := json.NewDecoder(r)
defer r.Close()
if err := decoder.Decode(&p); err != nil {
return "", fmt.Errorf("invalid JSON payload: %v", err)
}
@@ -48,14 +50,15 @@ func decodeURLPayload(data io.Reader) (string, error) {
return p.URL, nil
}
func decodeEntryStatusPayload(data io.Reader) ([]int64, string, error) {
func decodeEntryStatusPayload(r io.ReadCloser) ([]int64, string, error) {
type payload struct {
EntryIDs []int64 `json:"entry_ids"`
Status string `json:"status"`
}
var p payload
decoder := json.NewDecoder(data)
decoder := json.NewDecoder(r)
defer r.Close()
if err := decoder.Decode(&p); err != nil {
return nil, "", fmt.Errorf("invalid JSON payload: %v", err)
}
@@ -63,14 +66,15 @@ func decodeEntryStatusPayload(data io.Reader) ([]int64, string, error) {
return p.EntryIDs, p.Status, nil
}
func decodeFeedCreationPayload(data io.Reader) (string, int64, error) {
func decodeFeedCreationPayload(r io.ReadCloser) (string, int64, error) {
type payload struct {
FeedURL string `json:"feed_url"`
CategoryID int64 `json:"category_id"`
}
var p payload
decoder := json.NewDecoder(data)
decoder := json.NewDecoder(r)
defer r.Close()
if err := decoder.Decode(&p); err != nil {
return "", 0, fmt.Errorf("invalid JSON payload: %v", err)
}
@@ -78,10 +82,11 @@ func decodeFeedCreationPayload(data io.Reader) (string, int64, error) {
return p.FeedURL, p.CategoryID, nil
}
func decodeFeedModificationPayload(data io.Reader) (*model.Feed, error) {
func decodeFeedModificationPayload(r io.ReadCloser) (*model.Feed, error) {
var feed model.Feed
decoder := json.NewDecoder(data)
decoder := json.NewDecoder(r)
defer r.Close()
if err := decoder.Decode(&feed); err != nil {
return nil, fmt.Errorf("Unable to decode feed JSON object: %v", err)
}
@@ -89,10 +94,11 @@ func decodeFeedModificationPayload(data io.Reader) (*model.Feed, error) {
return &feed, nil
}
func decodeCategoryPayload(data io.Reader) (*model.Category, error) {
func decodeCategoryPayload(r io.ReadCloser) (*model.Category, error) {
var category model.Category
decoder := json.NewDecoder(data)
decoder := json.NewDecoder(r)
defer r.Close()
if err := decoder.Decode(&category); err != nil {
return nil, fmt.Errorf("Unable to decode category JSON object: %v", err)
}
+8 -7
View File
@@ -7,29 +7,30 @@ package api
import (
"errors"
"fmt"
"net/http"
"github.com/miniflux/miniflux/http/handler"
"github.com/miniflux/miniflux/http/response/json"
"github.com/miniflux/miniflux/reader/subscription"
)
// GetSubscriptions is the API handler to find subscriptions.
func (c *Controller) GetSubscriptions(ctx *handler.Context, request *handler.Request, response *handler.Response) {
websiteURL, err := decodeURLPayload(request.Body())
func (c *Controller) GetSubscriptions(w http.ResponseWriter, r *http.Request) {
websiteURL, err := decodeURLPayload(r.Body)
if err != nil {
response.JSON().BadRequest(err)
json.BadRequest(w, err)
return
}
subscriptions, err := subscription.FindSubscriptions(websiteURL)
if err != nil {
response.JSON().ServerError(errors.New("Unable to discover subscriptions"))
json.ServerError(w, errors.New("Unable to discover subscriptions"))
return
}
if subscriptions == nil {
response.JSON().NotFound(fmt.Errorf("No subscription found"))
json.NotFound(w, fmt.Errorf("No subscription found"))
return
}
response.JSON().Standard(subscriptions)
json.OK(w, subscriptions)
}
+70 -47
View File
@@ -6,180 +6,203 @@ package api
import (
"errors"
"net/http"
"github.com/miniflux/miniflux/http/handler"
"github.com/miniflux/miniflux/http/context"
"github.com/miniflux/miniflux/http/request"
"github.com/miniflux/miniflux/http/response/json"
)
// CreateUser is the API handler to create a new user.
func (c *Controller) CreateUser(ctx *handler.Context, request *handler.Request, response *handler.Response) {
if !ctx.IsAdminUser() {
response.JSON().Forbidden()
// CurrentUser is the API handler to retrieve the authenticated user.
func (c *Controller) CurrentUser(w http.ResponseWriter, r *http.Request) {
ctx := context.New(r)
user, err := c.store.UserByID(ctx.UserID())
if err != nil {
json.ServerError(w, err)
return
}
user, err := decodeUserPayload(request.Body())
json.OK(w, user)
}
// CreateUser is the API handler to create a new user.
func (c *Controller) CreateUser(w http.ResponseWriter, r *http.Request) {
ctx := context.New(r)
if !ctx.IsAdminUser() {
json.Forbidden(w)
return
}
user, err := decodeUserPayload(r.Body)
if err != nil {
response.JSON().BadRequest(err)
json.BadRequest(w, err)
return
}
if err := user.ValidateUserCreation(); err != nil {
response.JSON().BadRequest(err)
json.BadRequest(w, err)
return
}
if c.store.UserExists(user.Username) {
response.JSON().BadRequest(errors.New("This user already exists"))
json.BadRequest(w, errors.New("This user already exists"))
return
}
err = c.store.CreateUser(user)
if err != nil {
response.JSON().ServerError(errors.New("Unable to create this user"))
json.ServerError(w, errors.New("Unable to create this user"))
return
}
user.Password = ""
response.JSON().Created(user)
json.Created(w, user)
}
// UpdateUser is the API handler to update the given user.
func (c *Controller) UpdateUser(ctx *handler.Context, request *handler.Request, response *handler.Response) {
func (c *Controller) UpdateUser(w http.ResponseWriter, r *http.Request) {
ctx := context.New(r)
if !ctx.IsAdminUser() {
response.JSON().Forbidden()
json.Forbidden(w)
return
}
userID, err := request.IntegerParam("userID")
userID, err := request.IntParam(r, "userID")
if err != nil {
response.JSON().BadRequest(err)
json.BadRequest(w, err)
return
}
user, err := decodeUserPayload(request.Body())
user, err := decodeUserPayload(r.Body)
if err != nil {
response.JSON().BadRequest(err)
json.BadRequest(w, err)
return
}
if err := user.ValidateUserModification(); err != nil {
response.JSON().BadRequest(err)
json.BadRequest(w, err)
return
}
originalUser, err := c.store.UserByID(userID)
if err != nil {
response.JSON().BadRequest(errors.New("Unable to fetch this user from the database"))
json.BadRequest(w, errors.New("Unable to fetch this user from the database"))
return
}
if originalUser == nil {
response.JSON().NotFound(errors.New("User not found"))
json.NotFound(w, errors.New("User not found"))
return
}
originalUser.Merge(user)
if err = c.store.UpdateUser(originalUser); err != nil {
response.JSON().ServerError(errors.New("Unable to update this user"))
json.ServerError(w, errors.New("Unable to update this user"))
return
}
response.JSON().Created(originalUser)
json.Created(w, originalUser)
}
// Users is the API handler to get the list of users.
func (c *Controller) Users(ctx *handler.Context, request *handler.Request, response *handler.Response) {
func (c *Controller) Users(w http.ResponseWriter, r *http.Request) {
ctx := context.New(r)
if !ctx.IsAdminUser() {
response.JSON().Forbidden()
json.Forbidden(w)
return
}
users, err := c.store.Users()
if err != nil {
response.JSON().ServerError(errors.New("Unable to fetch the list of users"))
json.ServerError(w, errors.New("Unable to fetch the list of users"))
return
}
response.JSON().Standard(users)
users.UseTimezone(ctx.UserTimezone())
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
}
response.JSON().Standard(user)
user.UseTimezone(ctx.UserTimezone())
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)
}
+14 -4
View File
@@ -8,10 +8,9 @@ import (
"flag"
"fmt"
"github.com/miniflux/miniflux/logger"
"github.com/miniflux/miniflux/config"
"github.com/miniflux/miniflux/daemon"
"github.com/miniflux/miniflux/logger"
"github.com/miniflux/miniflux/storage"
"github.com/miniflux/miniflux/version"
)
@@ -28,6 +27,11 @@ func Parse() {
flag.Parse()
cfg := config.NewConfig()
if *flagDebugMode || cfg.HasDebugMode() {
logger.EnableDebug()
}
store := storage.NewStorage(
cfg.DatabaseURL(),
cfg.DatabaseMaxConnections(),
@@ -63,8 +67,14 @@ func Parse() {
return
}
if *flagDebugMode || cfg.HasDebugMode() {
logger.EnableDebug()
// 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)
+46 -16
View File
@@ -8,21 +8,23 @@ import (
"net/url"
"os"
"strconv"
"github.com/miniflux/miniflux/logger"
)
const (
defaultBaseURL = "http://localhost"
defaultDatabaseURL = "postgres://postgres:postgres@localhost/miniflux2?sslmode=disable"
defaultWorkerPoolSize = 5
defaultPollingFrequency = 60
defaultBatchSize = 10
defaultDatabaseMaxConns = 20
defaultListenAddr = "127.0.0.1:8080"
defaultCertFile = ""
defaultKeyFile = ""
defaultCertDomain = ""
defaultCertCache = "/tmp/cert_cache"
defaultSessionCleanupFrequency = 24
defaultBaseURL = "http://localhost"
defaultDatabaseURL = "postgres://postgres:postgres@localhost/miniflux2?sslmode=disable"
defaultWorkerPoolSize = 5
defaultPollingFrequency = 60
defaultBatchSize = 10
defaultDatabaseMaxConns = 20
defaultListenAddr = "127.0.0.1:8080"
defaultCertFile = ""
defaultKeyFile = ""
defaultCertDomain = ""
defaultCertCache = "/tmp/cert_cache"
defaultCleanupFrequency = 24
)
// Config manages configuration parameters.
@@ -89,7 +91,16 @@ func (c *Config) BasePath() string {
// DatabaseURL returns the database URL.
func (c *Config) DatabaseURL() string {
return c.get("DATABASE_URL", defaultDatabaseURL)
value, exists := os.LookupEnv("DATABASE_URL")
if !exists {
logger.Info("The environment variable DATABASE_URL is not configured (the default value is used instead)")
}
if value == "" {
value = defaultDatabaseURL
}
return value
}
// DatabaseMaxConnections returns the number of maximum database connections.
@@ -99,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)
}
@@ -122,9 +137,9 @@ func (c *Config) CertCache() string {
return c.get("CERT_CACHE", defaultCertCache)
}
// SessionCleanupFrequency returns the interval for session cleanup.
func (c *Config) SessionCleanupFrequency() int {
return c.getInt("SESSION_CLEANUP_FREQUENCY", defaultSessionCleanupFrequency)
// CleanupFrequency returns the interval for cleanup jobs.
func (c *Config) CleanupFrequency() int {
return c.getInt("CLEANUP_FREQUENCY", defaultCleanupFrequency)
}
// WorkerPoolSize returns the number of background worker.
@@ -172,6 +187,21 @@ 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", "") != ""
}
// PocketConsumerKey returns the Pocket Consumer Key if defined as environment variable.
func (c *Config) PocketConsumerKey(defaultValue string) string {
return c.get("POCKET_CONSUMER_KEY", defaultValue)
}
// NewConfig returns a new Config.
func NewConfig() *Config {
return &Config{IsHTTPS: os.Getenv("HTTPS") != ""}
+19 -4
View File
@@ -8,10 +8,12 @@ import (
"context"
"os"
"os/signal"
"runtime"
"syscall"
"time"
"github.com/miniflux/miniflux/config"
"github.com/miniflux/miniflux/locale"
"github.com/miniflux/miniflux/logger"
"github.com/miniflux/miniflux/reader/feed"
"github.com/miniflux/miniflux/scheduler"
@@ -26,9 +28,20 @@ func Run(cfg *config.Config, store *storage.Storage) {
signal.Notify(stop, os.Interrupt)
signal.Notify(stop, syscall.SIGTERM)
feedHandler := feed.NewFeedHandler(store)
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())
server := newServer(cfg, store, pool, feedHandler)
server := newServer(cfg, store, pool, feedHandler, translator)
scheduler.NewFeedScheduler(
store,
@@ -37,11 +50,13 @@ func Run(cfg *config.Config, store *storage.Storage) {
cfg.BatchSize(),
)
scheduler.NewSessionScheduler(store, cfg.SessionCleanupFrequency())
scheduler.NewCleanupScheduler(store, cfg.CleanupFrequency())
<-stop
logger.Info("Shutting down the server...")
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")
+109 -130
View File
@@ -10,11 +10,9 @@ import (
"github.com/miniflux/miniflux/api"
"github.com/miniflux/miniflux/config"
"github.com/miniflux/miniflux/fever"
"github.com/miniflux/miniflux/http/handler"
"github.com/miniflux/miniflux/http/middleware"
"github.com/miniflux/miniflux/locale"
"github.com/miniflux/miniflux/middleware"
"github.com/miniflux/miniflux/reader/feed"
"github.com/miniflux/miniflux/reader/opml"
"github.com/miniflux/miniflux/scheduler"
"github.com/miniflux/miniflux/storage"
"github.com/miniflux/miniflux/template"
@@ -23,141 +21,21 @@ import (
"github.com/gorilla/mux"
)
func routes(cfg *config.Config, store *storage.Storage, feedHandler *feed.Handler, pool *scheduler.WorkerPool) *mux.Router {
func routes(cfg *config.Config, store *storage.Storage, feedHandler *feed.Handler, pool *scheduler.WorkerPool, translator *locale.Translator) *mux.Router {
router := mux.NewRouter()
translator := locale.Load()
templateEngine := template.NewEngine(cfg, router, translator)
apiController := api.NewController(store, feedHandler)
feverController := fever.NewController(store)
uiController := ui.NewController(cfg, store, pool, feedHandler, opml.NewHandler(store))
apiHandler := handler.NewHandler(cfg, store, router, templateEngine, translator, middleware.NewChain(
middleware.NewBasicAuthMiddleware(store).Handler,
))
feverHandler := handler.NewHandler(cfg, store, router, templateEngine, translator, middleware.NewChain(
middleware.NewFeverMiddleware(store).Handler,
))
uiHandler := handler.NewHandler(cfg, store, router, templateEngine, translator, middleware.NewChain(
middleware.NewUserSessionMiddleware(store, router).Handler,
middleware.NewSessionMiddleware(cfg, store).Handler,
))
feverController := fever.NewController(cfg, store)
uiController := ui.NewController(cfg, store, pool, feedHandler, templateEngine, translator, router)
middleware := middleware.New(cfg, store, router)
if cfg.BasePath() != "" {
router = router.PathPrefix(cfg.BasePath()).Subrouter()
}
router.Handle("/fever/", feverHandler.Use(feverController.Handler)).Name("feverEndpoint")
router.Handle("/v1/users", apiHandler.Use(apiController.CreateUser)).Methods("POST")
router.Handle("/v1/users", apiHandler.Use(apiController.Users)).Methods("GET")
router.Handle("/v1/users/{userID:[0-9]+}", apiHandler.Use(apiController.UserByID)).Methods("GET")
router.Handle("/v1/users/{userID:[0-9]+}", apiHandler.Use(apiController.UpdateUser)).Methods("PUT")
router.Handle("/v1/users/{userID:[0-9]+}", apiHandler.Use(apiController.RemoveUser)).Methods("DELETE")
router.Handle("/v1/users/{username}", apiHandler.Use(apiController.UserByUsername)).Methods("GET")
router.Handle("/v1/categories", apiHandler.Use(apiController.CreateCategory)).Methods("POST")
router.Handle("/v1/categories", apiHandler.Use(apiController.GetCategories)).Methods("GET")
router.Handle("/v1/categories/{categoryID}", apiHandler.Use(apiController.UpdateCategory)).Methods("PUT")
router.Handle("/v1/categories/{categoryID}", apiHandler.Use(apiController.RemoveCategory)).Methods("DELETE")
router.Handle("/v1/discover", apiHandler.Use(apiController.GetSubscriptions)).Methods("POST")
router.Handle("/v1/feeds", apiHandler.Use(apiController.CreateFeed)).Methods("POST")
router.Handle("/v1/feeds", apiHandler.Use(apiController.GetFeeds)).Methods("Get")
router.Handle("/v1/feeds/{feedID}/refresh", apiHandler.Use(apiController.RefreshFeed)).Methods("PUT")
router.Handle("/v1/feeds/{feedID}", apiHandler.Use(apiController.GetFeed)).Methods("GET")
router.Handle("/v1/feeds/{feedID}", apiHandler.Use(apiController.UpdateFeed)).Methods("PUT")
router.Handle("/v1/feeds/{feedID}", apiHandler.Use(apiController.RemoveFeed)).Methods("DELETE")
router.Handle("/v1/feeds/{feedID}/icon", apiHandler.Use(apiController.FeedIcon)).Methods("GET")
router.Handle("/v1/export", apiHandler.Use(apiController.Export)).Methods("GET")
router.Handle("/v1/feeds/{feedID}/entries", apiHandler.Use(apiController.GetFeedEntries)).Methods("GET")
router.Handle("/v1/feeds/{feedID}/entries/{entryID}", apiHandler.Use(apiController.GetFeedEntry)).Methods("GET")
router.Handle("/v1/entries", apiHandler.Use(apiController.GetEntries)).Methods("GET")
router.Handle("/v1/entries", apiHandler.Use(apiController.SetEntryStatus)).Methods("PUT")
router.Handle("/v1/entries/{entryID}", apiHandler.Use(apiController.GetEntry)).Methods("GET")
router.Handle("/v1/entries/{entryID}/bookmark", apiHandler.Use(apiController.ToggleBookmark)).Methods("PUT")
router.Handle("/stylesheets/{name}.css", uiHandler.Use(uiController.Stylesheet)).Name("stylesheet").Methods("GET")
router.Handle("/js", uiHandler.Use(uiController.Javascript)).Name("javascript").Methods("GET")
router.Handle("/favicon.ico", uiHandler.Use(uiController.Favicon)).Name("favicon").Methods("GET")
router.Handle("/icon/{filename}", uiHandler.Use(uiController.AppIcon)).Name("appIcon").Methods("GET")
router.Handle("/manifest.json", uiHandler.Use(uiController.WebManifest)).Name("webManifest").Methods("GET")
router.Handle("/subscribe", uiHandler.Use(uiController.AddSubscription)).Name("addSubscription").Methods("GET")
router.Handle("/subscribe", uiHandler.Use(uiController.SubmitSubscription)).Name("submitSubscription").Methods("POST")
router.Handle("/subscriptions", uiHandler.Use(uiController.ChooseSubscription)).Name("chooseSubscription").Methods("POST")
router.Handle("/mark-all-as-read", uiHandler.Use(uiController.MarkAllAsRead)).Name("markAllAsRead").Methods("GET")
router.Handle("/unread", uiHandler.Use(uiController.ShowUnreadPage)).Name("unread").Methods("GET")
router.Handle("/history", uiHandler.Use(uiController.ShowHistoryPage)).Name("history").Methods("GET")
router.Handle("/starred", uiHandler.Use(uiController.ShowStarredPage)).Name("starred").Methods("GET")
router.Handle("/feed/{feedID}/refresh", uiHandler.Use(uiController.RefreshFeed)).Name("refreshFeed").Methods("GET")
router.Handle("/feed/{feedID}/edit", uiHandler.Use(uiController.EditFeed)).Name("editFeed").Methods("GET")
router.Handle("/feed/{feedID}/remove", uiHandler.Use(uiController.RemoveFeed)).Name("removeFeed").Methods("POST")
router.Handle("/feed/{feedID}/update", uiHandler.Use(uiController.UpdateFeed)).Name("updateFeed").Methods("POST")
router.Handle("/feed/{feedID}/entries", uiHandler.Use(uiController.ShowFeedEntries)).Name("feedEntries").Methods("GET")
router.Handle("/feeds", uiHandler.Use(uiController.ShowFeedsPage)).Name("feeds").Methods("GET")
router.Handle("/feeds/refresh", uiHandler.Use(uiController.RefreshAllFeeds)).Name("refreshAllFeeds").Methods("GET")
router.Handle("/unread/entry/{entryID}", uiHandler.Use(uiController.ShowUnreadEntry)).Name("unreadEntry").Methods("GET")
router.Handle("/history/entry/{entryID}", uiHandler.Use(uiController.ShowReadEntry)).Name("readEntry").Methods("GET")
router.Handle("/history/flush", uiHandler.Use(uiController.FlushHistory)).Name("flushHistory").Methods("GET")
router.Handle("/feed/{feedID}/entry/{entryID}", uiHandler.Use(uiController.ShowFeedEntry)).Name("feedEntry").Methods("GET")
router.Handle("/category/{categoryID}/entry/{entryID}", uiHandler.Use(uiController.ShowCategoryEntry)).Name("categoryEntry").Methods("GET")
router.Handle("/starred/entry/{entryID}", uiHandler.Use(uiController.ShowStarredEntry)).Name("starredEntry").Methods("GET")
router.Handle("/entry/status", uiHandler.Use(uiController.UpdateEntriesStatus)).Name("updateEntriesStatus").Methods("POST")
router.Handle("/entry/save/{entryID}", uiHandler.Use(uiController.SaveEntry)).Name("saveEntry").Methods("POST")
router.Handle("/entry/download/{entryID}", uiHandler.Use(uiController.FetchContent)).Name("fetchContent").Methods("POST")
router.Handle("/entry/bookmark/{entryID}", uiHandler.Use(uiController.ToggleBookmark)).Name("toggleBookmark").Methods("POST")
router.Handle("/categories", uiHandler.Use(uiController.ShowCategories)).Name("categories").Methods("GET")
router.Handle("/category/create", uiHandler.Use(uiController.CreateCategory)).Name("createCategory").Methods("GET")
router.Handle("/category/save", uiHandler.Use(uiController.SaveCategory)).Name("saveCategory").Methods("POST")
router.Handle("/category/{categoryID}/entries", uiHandler.Use(uiController.ShowCategoryEntries)).Name("categoryEntries").Methods("GET")
router.Handle("/category/{categoryID}/edit", uiHandler.Use(uiController.EditCategory)).Name("editCategory").Methods("GET")
router.Handle("/category/{categoryID}/update", uiHandler.Use(uiController.UpdateCategory)).Name("updateCategory").Methods("POST")
router.Handle("/category/{categoryID}/remove", uiHandler.Use(uiController.RemoveCategory)).Name("removeCategory").Methods("POST")
router.Handle("/feed/icon/{iconID}", uiHandler.Use(uiController.ShowIcon)).Name("icon").Methods("GET")
router.Handle("/proxy/{encodedURL}", uiHandler.Use(uiController.ImageProxy)).Name("proxy").Methods("GET")
router.Handle("/users", uiHandler.Use(uiController.ShowUsers)).Name("users").Methods("GET")
router.Handle("/user/create", uiHandler.Use(uiController.CreateUser)).Name("createUser").Methods("GET")
router.Handle("/user/save", uiHandler.Use(uiController.SaveUser)).Name("saveUser").Methods("POST")
router.Handle("/users/{userID}/edit", uiHandler.Use(uiController.EditUser)).Name("editUser").Methods("GET")
router.Handle("/users/{userID}/update", uiHandler.Use(uiController.UpdateUser)).Name("updateUser").Methods("POST")
router.Handle("/users/{userID}/remove", uiHandler.Use(uiController.RemoveUser)).Name("removeUser").Methods("POST")
router.Handle("/about", uiHandler.Use(uiController.AboutPage)).Name("about").Methods("GET")
router.Handle("/settings", uiHandler.Use(uiController.ShowSettings)).Name("settings").Methods("GET")
router.Handle("/settings", uiHandler.Use(uiController.UpdateSettings)).Name("updateSettings").Methods("POST")
router.Handle("/bookmarklet", uiHandler.Use(uiController.Bookmarklet)).Name("bookmarklet").Methods("GET")
router.Handle("/integrations", uiHandler.Use(uiController.ShowIntegrations)).Name("integrations").Methods("GET")
router.Handle("/integration", uiHandler.Use(uiController.UpdateIntegration)).Name("updateIntegration").Methods("POST")
router.Handle("/sessions", uiHandler.Use(uiController.ShowSessions)).Name("sessions").Methods("GET")
router.Handle("/sessions/{sessionID}/remove", uiHandler.Use(uiController.RemoveSession)).Name("removeSession").Methods("POST")
router.Handle("/export", uiHandler.Use(uiController.Export)).Name("export").Methods("GET")
router.Handle("/import", uiHandler.Use(uiController.Import)).Name("import").Methods("GET")
router.Handle("/upload", uiHandler.Use(uiController.UploadOPML)).Name("uploadOPML").Methods("POST")
router.Handle("/oauth2/{provider}/unlink", uiHandler.Use(uiController.OAuth2Unlink)).Name("oauth2Unlink").Methods("GET")
router.Handle("/oauth2/{provider}/redirect", uiHandler.Use(uiController.OAuth2Redirect)).Name("oauth2Redirect").Methods("GET")
router.Handle("/oauth2/{provider}/callback", uiHandler.Use(uiController.OAuth2Callback)).Name("oauth2Callback").Methods("GET")
router.Handle("/login", uiHandler.Use(uiController.CheckLogin)).Name("checkLogin").Methods("POST")
router.Handle("/logout", uiHandler.Use(uiController.Logout)).Name("logout").Methods("GET")
router.Handle("/", uiHandler.Use(uiController.ShowLoginPage)).Name("login").Methods("GET")
router.Use(middleware.HeaderConfig)
router.Use(middleware.Logging)
router.Use(middleware.CommonHeaders)
router.HandleFunc("/healthcheck", func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("OK"))
@@ -168,5 +46,106 @@ func routes(cfg *config.Config, store *storage.Storage, feedHandler *feed.Handle
w.Write([]byte("User-agent: *\nDisallow: /"))
})
feverRouter := router.PathPrefix("/fever").Subrouter()
feverRouter.Use(middleware.FeverAuth)
feverRouter.HandleFunc("/", feverController.Handler).Name("feverEndpoint")
apiRouter := router.PathPrefix("/v1").Subrouter()
apiRouter.Use(middleware.BasicAuth)
apiRouter.HandleFunc("/users", apiController.CreateUser).Methods("POST")
apiRouter.HandleFunc("/users", apiController.Users).Methods("GET")
apiRouter.HandleFunc("/users/{userID:[0-9]+}", apiController.UserByID).Methods("GET")
apiRouter.HandleFunc("/users/{userID:[0-9]+}", apiController.UpdateUser).Methods("PUT")
apiRouter.HandleFunc("/users/{userID:[0-9]+}", apiController.RemoveUser).Methods("DELETE")
apiRouter.HandleFunc("/users/{username}", apiController.UserByUsername).Methods("GET")
apiRouter.HandleFunc("/me", apiController.CurrentUser).Methods("GET")
apiRouter.HandleFunc("/categories", apiController.CreateCategory).Methods("POST")
apiRouter.HandleFunc("/categories", apiController.GetCategories).Methods("GET")
apiRouter.HandleFunc("/categories/{categoryID}", apiController.UpdateCategory).Methods("PUT")
apiRouter.HandleFunc("/categories/{categoryID}", apiController.RemoveCategory).Methods("DELETE")
apiRouter.HandleFunc("/discover", apiController.GetSubscriptions).Methods("POST")
apiRouter.HandleFunc("/feeds", apiController.CreateFeed).Methods("POST")
apiRouter.HandleFunc("/feeds", apiController.GetFeeds).Methods("GET")
apiRouter.HandleFunc("/feeds/{feedID}/refresh", apiController.RefreshFeed).Methods("PUT")
apiRouter.HandleFunc("/feeds/{feedID}", apiController.GetFeed).Methods("GET")
apiRouter.HandleFunc("/feeds/{feedID}", apiController.UpdateFeed).Methods("PUT")
apiRouter.HandleFunc("/feeds/{feedID}", apiController.RemoveFeed).Methods("DELETE")
apiRouter.HandleFunc("/feeds/{feedID}/icon", apiController.FeedIcon).Methods("GET")
apiRouter.HandleFunc("/export", apiController.Export).Methods("GET")
apiRouter.HandleFunc("/import", apiController.Import).Methods("POST")
apiRouter.HandleFunc("/feeds/{feedID}/entries", apiController.GetFeedEntries).Methods("GET")
apiRouter.HandleFunc("/feeds/{feedID}/entries/{entryID}", apiController.GetFeedEntry).Methods("GET")
apiRouter.HandleFunc("/entries", apiController.GetEntries).Methods("GET")
apiRouter.HandleFunc("/entries", apiController.SetEntryStatus).Methods("PUT")
apiRouter.HandleFunc("/entries/{entryID}", apiController.GetEntry).Methods("GET")
apiRouter.HandleFunc("/entries/{entryID}/bookmark", apiController.ToggleBookmark).Methods("PUT")
uiRouter := router.NewRoute().Subrouter()
uiRouter.Use(middleware.AppSession)
uiRouter.Use(middleware.UserSession)
uiRouter.HandleFunc("/stylesheets/{name}.css", uiController.Stylesheet).Name("stylesheet").Methods("GET")
uiRouter.HandleFunc("/js", uiController.Javascript).Name("javascript").Methods("GET")
uiRouter.HandleFunc("/favicon.ico", uiController.Favicon).Name("favicon").Methods("GET")
uiRouter.HandleFunc("/icon/{filename}", uiController.AppIcon).Name("appIcon").Methods("GET")
uiRouter.HandleFunc("/manifest.json", uiController.WebManifest).Name("webManifest").Methods("GET")
uiRouter.HandleFunc("/subscribe", uiController.AddSubscription).Name("addSubscription").Methods("GET")
uiRouter.HandleFunc("/subscribe", uiController.SubmitSubscription).Name("submitSubscription").Methods("POST")
uiRouter.HandleFunc("/subscriptions", uiController.ChooseSubscription).Name("chooseSubscription").Methods("POST")
uiRouter.HandleFunc("/mark-all-as-read", uiController.MarkAllAsRead).Name("markAllAsRead").Methods("GET")
uiRouter.HandleFunc("/unread", uiController.ShowUnreadPage).Name("unread").Methods("GET")
uiRouter.HandleFunc("/history", uiController.ShowHistoryPage).Name("history").Methods("GET")
uiRouter.HandleFunc("/starred", uiController.ShowStarredPage).Name("starred").Methods("GET")
uiRouter.HandleFunc("/feed/{feedID}/refresh", uiController.RefreshFeed).Name("refreshFeed").Methods("GET")
uiRouter.HandleFunc("/feed/{feedID}/edit", uiController.EditFeed).Name("editFeed").Methods("GET")
uiRouter.HandleFunc("/feed/{feedID}/remove", uiController.RemoveFeed).Name("removeFeed").Methods("POST")
uiRouter.HandleFunc("/feed/{feedID}/update", uiController.UpdateFeed).Name("updateFeed").Methods("POST")
uiRouter.HandleFunc("/feed/{feedID}/entries", uiController.ShowFeedEntries).Name("feedEntries").Methods("GET")
uiRouter.HandleFunc("/feeds", uiController.ShowFeedsPage).Name("feeds").Methods("GET")
uiRouter.HandleFunc("/feeds/refresh", uiController.RefreshAllFeeds).Name("refreshAllFeeds").Methods("GET")
uiRouter.HandleFunc("/unread/entry/{entryID}", uiController.ShowUnreadEntry).Name("unreadEntry").Methods("GET")
uiRouter.HandleFunc("/history/entry/{entryID}", uiController.ShowReadEntry).Name("readEntry").Methods("GET")
uiRouter.HandleFunc("/history/flush", uiController.FlushHistory).Name("flushHistory").Methods("GET")
uiRouter.HandleFunc("/feed/{feedID}/entry/{entryID}", uiController.ShowFeedEntry).Name("feedEntry").Methods("GET")
uiRouter.HandleFunc("/category/{categoryID}/entry/{entryID}", uiController.ShowCategoryEntry).Name("categoryEntry").Methods("GET")
uiRouter.HandleFunc("/starred/entry/{entryID}", uiController.ShowStarredEntry).Name("starredEntry").Methods("GET")
uiRouter.HandleFunc("/entry/status", uiController.UpdateEntriesStatus).Name("updateEntriesStatus").Methods("POST")
uiRouter.HandleFunc("/entry/save/{entryID}", uiController.SaveEntry).Name("saveEntry").Methods("POST")
uiRouter.HandleFunc("/entry/download/{entryID}", uiController.FetchContent).Name("fetchContent").Methods("POST")
uiRouter.HandleFunc("/entry/bookmark/{entryID}", uiController.ToggleBookmark).Name("toggleBookmark").Methods("POST")
uiRouter.HandleFunc("/categories", uiController.CategoryList).Name("categories").Methods("GET")
uiRouter.HandleFunc("/category/create", uiController.CreateCategory).Name("createCategory").Methods("GET")
uiRouter.HandleFunc("/category/save", uiController.SaveCategory).Name("saveCategory").Methods("POST")
uiRouter.HandleFunc("/category/{categoryID}/entries", uiController.CategoryEntries).Name("categoryEntries").Methods("GET")
uiRouter.HandleFunc("/category/{categoryID}/edit", uiController.EditCategory).Name("editCategory").Methods("GET")
uiRouter.HandleFunc("/category/{categoryID}/update", uiController.UpdateCategory).Name("updateCategory").Methods("POST")
uiRouter.HandleFunc("/category/{categoryID}/remove", uiController.RemoveCategory).Name("removeCategory").Methods("POST")
uiRouter.HandleFunc("/feed/icon/{iconID}", uiController.ShowIcon).Name("icon").Methods("GET")
uiRouter.HandleFunc("/proxy/{encodedURL}", uiController.ImageProxy).Name("proxy").Methods("GET")
uiRouter.HandleFunc("/users", uiController.ShowUsers).Name("users").Methods("GET")
uiRouter.HandleFunc("/user/create", uiController.CreateUser).Name("createUser").Methods("GET")
uiRouter.HandleFunc("/user/save", uiController.SaveUser).Name("saveUser").Methods("POST")
uiRouter.HandleFunc("/users/{userID}/edit", uiController.EditUser).Name("editUser").Methods("GET")
uiRouter.HandleFunc("/users/{userID}/update", uiController.UpdateUser).Name("updateUser").Methods("POST")
uiRouter.HandleFunc("/users/{userID}/remove", uiController.RemoveUser).Name("removeUser").Methods("POST")
uiRouter.HandleFunc("/about", uiController.About).Name("about").Methods("GET")
uiRouter.HandleFunc("/settings", uiController.ShowSettings).Name("settings").Methods("GET")
uiRouter.HandleFunc("/settings", uiController.UpdateSettings).Name("updateSettings").Methods("POST")
uiRouter.HandleFunc("/bookmarklet", uiController.Bookmarklet).Name("bookmarklet").Methods("GET")
uiRouter.HandleFunc("/integrations", uiController.ShowIntegrations).Name("integrations").Methods("GET")
uiRouter.HandleFunc("/integration", uiController.UpdateIntegration).Name("updateIntegration").Methods("POST")
uiRouter.HandleFunc("/integration/pocket/authorize", uiController.PocketAuthorize).Name("pocketAuthorize").Methods("GET")
uiRouter.HandleFunc("/integration/pocket/callback", uiController.PocketCallback).Name("pocketCallback").Methods("GET")
uiRouter.HandleFunc("/sessions", uiController.ShowSessions).Name("sessions").Methods("GET")
uiRouter.HandleFunc("/sessions/{sessionID}/remove", uiController.RemoveSession).Name("removeSession").Methods("POST")
uiRouter.HandleFunc("/export", uiController.Export).Name("export").Methods("GET")
uiRouter.HandleFunc("/import", uiController.Import).Name("import").Methods("GET")
uiRouter.HandleFunc("/upload", uiController.UploadOPML).Name("uploadOPML").Methods("POST")
uiRouter.HandleFunc("/oauth2/{provider}/unlink", uiController.OAuth2Unlink).Name("oauth2Unlink").Methods("GET")
uiRouter.HandleFunc("/oauth2/{provider}/redirect", uiController.OAuth2Redirect).Name("oauth2Redirect").Methods("GET")
uiRouter.HandleFunc("/oauth2/{provider}/callback", uiController.OAuth2Callback).Name("oauth2Callback").Methods("GET")
uiRouter.HandleFunc("/login", uiController.CheckLogin).Name("checkLogin").Methods("POST")
uiRouter.HandleFunc("/logout", uiController.Logout).Name("logout").Methods("GET")
uiRouter.HandleFunc("/", uiController.ShowLoginPage).Name("login").Methods("GET")
return router
}
+5 -4
View File
@@ -10,6 +10,7 @@ import (
"time"
"github.com/miniflux/miniflux/config"
"github.com/miniflux/miniflux/locale"
"github.com/miniflux/miniflux/logger"
"github.com/miniflux/miniflux/reader/feed"
"github.com/miniflux/miniflux/scheduler"
@@ -18,17 +19,17 @@ import (
"golang.org/x/crypto/acme/autocert"
)
func newServer(cfg *config.Config, store *storage.Storage, pool *scheduler.WorkerPool, feedHandler *feed.Handler) *http.Server {
func newServer(cfg *config.Config, store *storage.Storage, pool *scheduler.WorkerPool, feedHandler *feed.Handler, translator *locale.Translator) *http.Server {
certFile := cfg.CertFile()
keyFile := cfg.KeyFile()
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),
Handler: routes(cfg, store, feedHandler, pool, translator),
}
if certDomain != "" && certCache != "" {
+2 -2
View File
@@ -27,6 +27,6 @@ func (l LocalizedError) Localize(translation *locale.Language) string {
}
// NewLocalizedError returns a new LocalizedError.
func NewLocalizedError(message string, args ...interface{}) LocalizedError {
return LocalizedError{message: message, args: args}
func NewLocalizedError(message string, args ...interface{}) *LocalizedError {
return &LocalizedError{message: message, args: args}
}
+84 -66
View File
@@ -5,11 +5,15 @@
package fever
import (
"net/http"
"strconv"
"strings"
"time"
"github.com/miniflux/miniflux/http/handler"
"github.com/miniflux/miniflux/config"
"github.com/miniflux/miniflux/http/context"
"github.com/miniflux/miniflux/http/request"
"github.com/miniflux/miniflux/http/response/json"
"github.com/miniflux/miniflux/integration"
"github.com/miniflux/miniflux/logger"
"github.com/miniflux/miniflux/model"
@@ -125,32 +129,33 @@ type favicon struct {
// Controller implements the Fever API.
type Controller struct {
cfg *config.Config
store *storage.Storage
}
// Handler handles Fever API calls
func (c *Controller) Handler(ctx *handler.Context, request *handler.Request, response *handler.Response) {
func (c *Controller) Handler(w http.ResponseWriter, r *http.Request) {
switch {
case request.HasQueryParam("groups"):
c.handleGroups(ctx, request, response)
case request.HasQueryParam("feeds"):
c.handleFeeds(ctx, request, response)
case request.HasQueryParam("favicons"):
c.handleFavicons(ctx, request, response)
case request.HasQueryParam("unread_item_ids"):
c.handleUnreadItems(ctx, request, response)
case request.HasQueryParam("saved_item_ids"):
c.handleSavedItems(ctx, request, response)
case request.HasQueryParam("items"):
c.handleItems(ctx, request, response)
case request.FormValue("mark") == "item":
c.handleWriteItems(ctx, request, response)
case request.FormValue("mark") == "feed":
c.handleWriteFeeds(ctx, request, response)
case request.FormValue("mark") == "group":
c.handleWriteGroups(ctx, request, response)
case request.HasQueryParam(r, "groups"):
c.handleGroups(w, r)
case request.HasQueryParam(r, "feeds"):
c.handleFeeds(w, r)
case request.HasQueryParam(r, "favicons"):
c.handleFavicons(w, r)
case request.HasQueryParam(r, "unread_item_ids"):
c.handleUnreadItems(w, r)
case request.HasQueryParam(r, "saved_item_ids"):
c.handleSavedItems(w, r)
case request.HasQueryParam(r, "items"):
c.handleItems(w, r)
case r.FormValue("mark") == "item":
c.handleWriteItems(w, r)
case r.FormValue("mark") == "feed":
c.handleWriteFeeds(w, r)
case r.FormValue("mark") == "group":
c.handleWriteGroups(w, r)
default:
response.JSON().Standard(newBaseResponse())
json.OK(w, newBaseResponse())
}
}
@@ -174,19 +179,20 @@ The “Sparks” super group is not included in this response and is composed of
is_spark equal to 1.
*/
func (c *Controller) handleGroups(ctx *handler.Context, request *handler.Request, response *handler.Response) {
func (c *Controller) handleGroups(w http.ResponseWriter, r *http.Request) {
ctx := context.New(r)
userID := ctx.UserID()
logger.Debug("[Fever] Fetching groups for userID=%d", userID)
categories, err := c.store.Categories(userID)
if err != nil {
response.JSON().ServerError(err)
json.ServerError(w, err)
return
}
feeds, err := c.store.Feeds(userID)
if err != nil {
response.JSON().ServerError(err)
json.ServerError(w, err)
return
}
@@ -197,7 +203,7 @@ func (c *Controller) handleGroups(ctx *handler.Context, request *handler.Request
result.FeedsGroups = c.buildFeedGroups(feeds)
result.SetCommonValues()
response.JSON().Standard(result)
json.OK(w, result)
}
/*
@@ -224,13 +230,14 @@ should be limited to feeds with an is_spark equal to 0.
For the “Sparks” super group the items should be limited to feeds with an is_spark equal to 1.
*/
func (c *Controller) handleFeeds(ctx *handler.Context, request *handler.Request, response *handler.Response) {
func (c *Controller) handleFeeds(w http.ResponseWriter, r *http.Request) {
ctx := context.New(r)
userID := ctx.UserID()
logger.Debug("[Fever] Fetching feeds for userID=%d", userID)
feeds, err := c.store.Feeds(userID)
if err != nil {
response.JSON().ServerError(err)
json.ServerError(w, err)
return
}
@@ -255,7 +262,7 @@ func (c *Controller) handleFeeds(ctx *handler.Context, request *handler.Request,
result.FeedsGroups = c.buildFeedGroups(feeds)
result.SetCommonValues()
response.JSON().Standard(result)
json.OK(w, result)
}
/*
@@ -277,13 +284,14 @@ A PHP/HTML example:
echo '<img src="data:'.$favicon['data'].'">';
*/
func (c *Controller) handleFavicons(ctx *handler.Context, request *handler.Request, response *handler.Response) {
func (c *Controller) handleFavicons(w http.ResponseWriter, r *http.Request) {
ctx := context.New(r)
userID := ctx.UserID()
logger.Debug("[Fever] Fetching favicons for userID=%d", userID)
icons, err := c.store.Icons(userID)
if err != nil {
response.JSON().ServerError(err)
json.ServerError(w, err)
return
}
@@ -296,7 +304,7 @@ func (c *Controller) handleFavicons(ctx *handler.Context, request *handler.Reque
}
result.SetCommonValues()
response.JSON().Standard(result)
json.OK(w, result)
}
/*
@@ -330,9 +338,10 @@ Three optional arguments control determine the items included in the response.
(added in API version 2)
*/
func (c *Controller) handleItems(ctx *handler.Context, request *handler.Request, response *handler.Response) {
func (c *Controller) handleItems(w http.ResponseWriter, r *http.Request) {
var result itemsResponse
ctx := context.New(r)
userID := ctx.UserID()
logger.Debug("[Fever] Fetching items for userID=%d", userID)
@@ -342,17 +351,17 @@ func (c *Controller) handleItems(ctx *handler.Context, request *handler.Request,
builder.WithOrder("id")
builder.WithDirection(model.DefaultSortingDirection)
sinceID := request.QueryIntegerParam("since_id", 0)
sinceID := request.QueryIntParam(r, "since_id", 0)
if sinceID > 0 {
builder.WithGreaterThanEntryID(int64(sinceID))
}
maxID := request.QueryIntegerParam("max_id", 0)
maxID := request.QueryIntParam(r, "max_id", 0)
if maxID > 0 {
builder.WithOffset(maxID)
}
csvItemIDs := request.QueryStringParam("with_ids", "")
csvItemIDs := request.QueryParam(r, "with_ids", "")
if csvItemIDs != "" {
var itemIDs []int64
@@ -367,7 +376,7 @@ func (c *Controller) handleItems(ctx *handler.Context, request *handler.Request,
entries, err := builder.GetEntries()
if err != nil {
response.JSON().ServerError(err)
json.ServerError(w, err)
return
}
@@ -375,10 +384,11 @@ func (c *Controller) handleItems(ctx *handler.Context, request *handler.Request,
builder.WithoutStatus(model.EntryStatusRemoved)
result.Total, err = builder.CountEntries()
if err != nil {
response.JSON().ServerError(err)
json.ServerError(w, err)
return
}
result.Items = make([]item, 0)
for _, entry := range entries {
isRead := 0
if entry.Status == model.EntryStatusRead {
@@ -404,7 +414,7 @@ func (c *Controller) handleItems(ctx *handler.Context, request *handler.Request,
}
result.SetCommonValues()
response.JSON().Standard(result)
json.OK(w, result)
}
/*
@@ -414,7 +424,8 @@ with the remote Fever installation.
A request with the unread_item_ids argument will return one additional member:
unread_item_ids (string/comma-separated list of positive integers)
*/
func (c *Controller) handleUnreadItems(ctx *handler.Context, request *handler.Request, response *handler.Response) {
func (c *Controller) handleUnreadItems(w http.ResponseWriter, r *http.Request) {
ctx := context.New(r)
userID := ctx.UserID()
logger.Debug("[Fever] Fetching unread items for userID=%d", userID)
@@ -422,7 +433,7 @@ func (c *Controller) handleUnreadItems(ctx *handler.Context, request *handler.Re
builder.WithStatus(model.EntryStatusUnread)
entries, err := builder.GetEntries()
if err != nil {
response.JSON().ServerError(err)
json.ServerError(w, err)
return
}
@@ -434,7 +445,7 @@ func (c *Controller) handleUnreadItems(ctx *handler.Context, request *handler.Re
var result unreadResponse
result.ItemIDs = strings.Join(itemIDs, ",")
result.SetCommonValues()
response.JSON().Standard(result)
json.OK(w, result)
}
/*
@@ -445,7 +456,8 @@ with the remote Fever installation.
saved_item_ids (string/comma-separated list of positive integers)
*/
func (c *Controller) handleSavedItems(ctx *handler.Context, request *handler.Request, response *handler.Response) {
func (c *Controller) handleSavedItems(w http.ResponseWriter, r *http.Request) {
ctx := context.New(r)
userID := ctx.UserID()
logger.Debug("[Fever] Fetching saved items for userID=%d", userID)
@@ -454,7 +466,7 @@ func (c *Controller) handleSavedItems(ctx *handler.Context, request *handler.Req
entryIDs, err := builder.GetEntryIDs()
if err != nil {
response.JSON().ServerError(err)
json.ServerError(w, err)
return
}
@@ -465,7 +477,7 @@ func (c *Controller) handleSavedItems(ctx *handler.Context, request *handler.Req
result := &savedResponse{ItemIDs: strings.Join(itemsIDs, ",")}
result.SetCommonValues()
response.JSON().Standard(result)
json.OK(w, result)
}
/*
@@ -473,11 +485,12 @@ func (c *Controller) handleSavedItems(ctx *handler.Context, request *handler.Req
as=? where ? is replaced with read, saved or unsaved
id=? where ? is replaced with the id of the item to modify
*/
func (c *Controller) handleWriteItems(ctx *handler.Context, request *handler.Request, response *handler.Response) {
func (c *Controller) handleWriteItems(w http.ResponseWriter, r *http.Request) {
ctx := context.New(r)
userID := ctx.UserID()
logger.Debug("[Fever] Receiving mark=item call for userID=%d", userID)
entryID := request.FormIntegerValue("id")
entryID := request.FormIntValue(r, "id")
if entryID <= 0 {
return
}
@@ -488,7 +501,7 @@ func (c *Controller) handleWriteItems(ctx *handler.Context, request *handler.Req
entry, err := builder.GetEntry()
if err != nil {
response.JSON().ServerError(err)
json.ServerError(w, err)
return
}
@@ -496,29 +509,32 @@ func (c *Controller) handleWriteItems(ctx *handler.Context, request *handler.Req
return
}
switch request.FormValue("as") {
switch r.FormValue("as") {
case "read":
logger.Debug("[Fever] Mark entry #%d as read", entryID)
c.store.SetEntriesStatus(userID, []int64{entryID}, model.EntryStatusRead)
case "unread":
logger.Debug("[Fever] Mark entry #%d as unread", entryID)
c.store.SetEntriesStatus(userID, []int64{entryID}, model.EntryStatusUnread)
case "saved", "unsaved":
logger.Debug("[Fever] Mark entry #%d as saved/unsaved", entryID)
if err := c.store.ToggleBookmark(userID, entryID); err != nil {
response.JSON().ServerError(err)
json.ServerError(w, err)
return
}
settings, err := c.store.Integration(userID)
if err != nil {
response.JSON().ServerError(err)
json.ServerError(w, err)
return
}
go func() {
integration.SendEntry(entry, settings)
integration.SendEntry(c.cfg, entry, settings)
}()
}
response.JSON().Standard(newBaseResponse())
json.OK(w, newBaseResponse())
}
/*
@@ -527,11 +543,12 @@ func (c *Controller) handleWriteItems(ctx *handler.Context, request *handler.Req
id=? where ? is replaced with the id of the feed or group to modify
before=? where ? is replaced with the Unix timestamp of the the local clients most recent items API request
*/
func (c *Controller) handleWriteFeeds(ctx *handler.Context, request *handler.Request, response *handler.Response) {
func (c *Controller) handleWriteFeeds(w http.ResponseWriter, r *http.Request) {
ctx := context.New(r)
userID := ctx.UserID()
logger.Debug("[Fever] Receiving mark=feed call for userID=%d", userID)
feedID := request.FormIntegerValue("id")
feedID := request.FormIntValue(r, "id")
if feedID <= 0 {
return
}
@@ -540,7 +557,7 @@ func (c *Controller) handleWriteFeeds(ctx *handler.Context, request *handler.Req
builder.WithStatus(model.EntryStatusUnread)
builder.WithFeedID(feedID)
before := request.FormIntegerValue("before")
before := request.FormIntValue(r, "before")
if before > 0 {
t := time.Unix(before, 0)
builder.Before(&t)
@@ -548,17 +565,17 @@ func (c *Controller) handleWriteFeeds(ctx *handler.Context, request *handler.Req
entryIDs, err := builder.GetEntryIDs()
if err != nil {
response.JSON().ServerError(err)
json.ServerError(w, err)
return
}
err = c.store.SetEntriesStatus(userID, entryIDs, model.EntryStatusRead)
if err != nil {
response.JSON().ServerError(err)
json.ServerError(w, err)
return
}
response.JSON().Standard(newBaseResponse())
json.OK(w, newBaseResponse())
}
/*
@@ -567,11 +584,12 @@ func (c *Controller) handleWriteFeeds(ctx *handler.Context, request *handler.Req
id=? where ? is replaced with the id of the feed or group to modify
before=? where ? is replaced with the Unix timestamp of the the local clients most recent items API request
*/
func (c *Controller) handleWriteGroups(ctx *handler.Context, request *handler.Request, response *handler.Response) {
func (c *Controller) handleWriteGroups(w http.ResponseWriter, r *http.Request) {
ctx := context.New(r)
userID := ctx.UserID()
logger.Debug("[Fever] Receiving mark=group call for userID=%d", userID)
groupID := request.FormIntegerValue("id")
groupID := request.FormIntValue(r, "id")
if groupID < 0 {
return
}
@@ -580,7 +598,7 @@ func (c *Controller) handleWriteGroups(ctx *handler.Context, request *handler.Re
builder.WithStatus(model.EntryStatusUnread)
builder.WithCategoryID(groupID)
before := request.FormIntegerValue("before")
before := request.FormIntValue(r, "before")
if before > 0 {
t := time.Unix(before, 0)
builder.Before(&t)
@@ -588,17 +606,17 @@ func (c *Controller) handleWriteGroups(ctx *handler.Context, request *handler.Re
entryIDs, err := builder.GetEntryIDs()
if err != nil {
response.JSON().ServerError(err)
json.ServerError(w, err)
return
}
err = c.store.SetEntriesStatus(userID, entryIDs, model.EntryStatusRead)
if err != nil {
response.JSON().ServerError(err)
json.ServerError(w, err)
return
}
response.JSON().Standard(newBaseResponse())
json.OK(w, newBaseResponse())
}
/*
@@ -626,6 +644,6 @@ func (c *Controller) buildFeedGroups(feeds model.Feeds) []feedsGroups {
}
// NewController returns a new Fever API.
func NewController(store *storage.Storage) *Controller {
return &Controller{store: store}
func NewController(cfg *config.Config, store *storage.Storage) *Controller {
return &Controller{cfg, store}
}
+81 -25
View File
@@ -1,27 +1,43 @@
// 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"
"crypto/tls"
"crypto/x509"
"encoding/json"
"fmt"
"io"
"io/ioutil"
"net"
"net/http"
"net/url"
"strings"
"time"
"github.com/miniflux/miniflux/errors"
"github.com/miniflux/miniflux/logger"
"github.com/miniflux/miniflux/timer"
"github.com/miniflux/miniflux/version"
)
const requestTimeout = 300
const maxBodySize = 1024 * 1024 * 15
const (
// 20 seconds max.
requestTimeout = 20
// 15MB max.
maxBodySize = 1024 * 1024 * 15
)
var (
errInvalidCertificate = "Invalid SSL certificate (original error: %q)"
errTemporaryNetworkOperation = "This website is temporarily unreachable (original error: %q)"
errPermanentNetworkOperation = "This website is permanently unreachable (original error: %q)"
errRequestTimeout = "Website unreachable, the request timed out after %d seconds"
)
// Client is a HTTP Client :)
type Client struct {
@@ -34,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)
@@ -77,15 +113,41 @@ func (c *Client) executeRequest(request *http.Request) (*Response, error) {
client := c.buildClient()
resp, err := client.Do(request)
if err != nil {
if uerr, ok := err.(*url.Error); ok {
switch uerr.Err.(type) {
case x509.CertificateInvalidError, x509.HostnameError:
err = errors.NewLocalizedError(errInvalidCertificate, uerr.Err)
case *net.OpError:
if uerr.Err.(*net.OpError).Temporary() {
err = errors.NewLocalizedError(errTemporaryNetworkOperation, uerr.Err)
} else {
err = errors.NewLocalizedError(errPermanentNetworkOperation, uerr.Err)
}
case net.Error:
nerr := uerr.Err.(net.Error)
if nerr.Timeout() {
err = errors.NewLocalizedError(errRequestTimeout, requestTimeout)
} else if nerr.Temporary() {
err = errors.NewLocalizedError(errTemporaryNetworkOperation, nerr)
}
}
}
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"),
@@ -94,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
}
@@ -114,11 +184,12 @@ func (c *Client) buildRequest(method string, body io.Reader) (*http.Request, err
return nil, err
}
request.Header = c.buildHeaders()
if c.username != "" && c.password != "" {
request.SetBasicAuth(c.username, c.password)
}
request.Header = c.buildHeaders()
return request, nil
}
@@ -153,22 +224,7 @@ func (c *Client) buildHeaders() http.Header {
return headers
}
// NewClient returns a new HTTP client.
func NewClient(url string) *Client {
// New returns a new HTTP client.
func New(url string) *Client {
return &Client{url: url, Insecure: false}
}
// NewClientWithCredentials returns a new HTTP client that requires authentication.
func NewClientWithCredentials(url, username, password string) *Client {
return &Client{url: url, Insecure: false, username: username, password: password}
}
// NewClientWithAuthorization returns a new client with a custom authorization header.
func NewClientWithAuthorization(url, authorization string) *Client {
return &Client{url: url, Insecure: false, authorizationHeader: authorization}
}
// NewClientWithCacheHeaders returns a new HTTP client that send cache headers.
func NewClientWithCacheHeaders(url, etagHeader, lastModifiedHeader string) *Client {
return &Client{url: url, etagHeader: etagHeader, lastModifiedHeader: lastModifiedHeader, Insecure: false}
}
+1 -1
View File
@@ -2,7 +2,7 @@
// Use of this source code is governed by the Apache 2.0
// license that can be found in the LICENSE file.
package http
package client
import (
"io"
@@ -2,7 +2,7 @@
// Use of this source code is governed by the Apache 2.0
// license that can be found in the LICENSE file.
package http
package client
import "testing"
+113
View File
@@ -0,0 +1,113 @@
// Copyright 2018 Frédéric Guillot. All rights reserved.
// Use of this source code is governed by the Apache 2.0
// license that can be found in the LICENSE file.
package context
import (
"net/http"
"github.com/miniflux/miniflux/middleware"
)
// Context contains helper functions related to the current request.
type Context struct {
request *http.Request
}
// IsAdminUser checks if the logged user is administrator.
func (c *Context) IsAdminUser() bool {
return c.getContextBoolValue(middleware.IsAdminUserContextKey)
}
// IsAuthenticated returns a boolean if the user is authenticated.
func (c *Context) IsAuthenticated() bool {
return c.getContextBoolValue(middleware.IsAuthenticatedContextKey)
}
// UserID returns the UserID of the logged user.
func (c *Context) UserID() int64 {
return c.getContextIntValue(middleware.UserIDContextKey)
}
// UserTimezone returns the timezone used by the logged user.
func (c *Context) UserTimezone() string {
value := c.getContextStringValue(middleware.UserTimezoneContextKey)
if value == "" {
value = "UTC"
}
return value
}
// UserLanguage get the locale used by the current logged user.
func (c *Context) UserLanguage() string {
language := c.getContextStringValue(middleware.UserLanguageContextKey)
if language == "" {
language = "en_US"
}
return language
}
// CSRF returns the current CSRF token.
func (c *Context) CSRF() string {
return c.getContextStringValue(middleware.CSRFContextKey)
}
// SessionID returns the current session ID.
func (c *Context) SessionID() string {
return c.getContextStringValue(middleware.SessionIDContextKey)
}
// UserSessionToken returns the current user session token.
func (c *Context) UserSessionToken() string {
return c.getContextStringValue(middleware.UserSessionTokenContextKey)
}
// OAuth2State returns the current OAuth2 state.
func (c *Context) OAuth2State() string {
return c.getContextStringValue(middleware.OAuth2StateContextKey)
}
// FlashMessage returns the message message if any.
func (c *Context) FlashMessage() string {
return c.getContextStringValue(middleware.FlashMessageContextKey)
}
// FlashErrorMessage returns the message error message if any.
func (c *Context) FlashErrorMessage() string {
return c.getContextStringValue(middleware.FlashErrorMessageContextKey)
}
// PocketRequestToken returns the Pocket Request Token if any.
func (c *Context) PocketRequestToken() string {
return c.getContextStringValue(middleware.PocketRequestTokenContextKey)
}
func (c *Context) getContextStringValue(key *middleware.ContextKey) string {
if v := c.request.Context().Value(key); v != nil {
return v.(string)
}
return ""
}
func (c *Context) getContextBoolValue(key *middleware.ContextKey) bool {
if v := c.request.Context().Value(key); v != nil {
return v.(bool)
}
return false
}
func (c *Context) getContextIntValue(key *middleware.ContextKey) int64 {
if v := c.request.Context().Value(key); v != nil {
return v.(int64)
}
return 0
}
// New creates a new Context.
func New(r *http.Request) *Context {
return &Context{r}
}
-10
View File
@@ -1,10 +0,0 @@
// Copyright 2018 Frédéric Guillot. All rights reserved.
// Use of this source code is governed by the MIT license
// that can be found in the LICENSE file.
/*
Package http implements a set of utilities related to the HTTP protocol.
*/
package http
-163
View File
@@ -1,163 +0,0 @@
// Copyright 2017 Frédéric Guillot. All rights reserved.
// Use of this source code is governed by the Apache 2.0
// license that can be found in the LICENSE file.
package handler
import (
"net/http"
"github.com/miniflux/miniflux/crypto"
"github.com/miniflux/miniflux/http/middleware"
"github.com/miniflux/miniflux/http/route"
"github.com/miniflux/miniflux/locale"
"github.com/miniflux/miniflux/logger"
"github.com/miniflux/miniflux/model"
"github.com/miniflux/miniflux/storage"
"github.com/gorilla/mux"
)
// Context contains helper functions related to the current request.
type Context struct {
writer http.ResponseWriter
request *http.Request
store *storage.Storage
router *mux.Router
user *model.User
translator *locale.Translator
}
// IsAdminUser checks if the logged user is administrator.
func (c *Context) IsAdminUser() bool {
if v := c.request.Context().Value(middleware.IsAdminUserContextKey); v != nil {
return v.(bool)
}
return false
}
// UserTimezone returns the timezone used by the logged user.
func (c *Context) UserTimezone() string {
value := c.getContextStringValue(middleware.UserTimezoneContextKey)
if value == "" {
value = "UTC"
}
return value
}
// IsAuthenticated returns a boolean if the user is authenticated.
func (c *Context) IsAuthenticated() bool {
if v := c.request.Context().Value(middleware.IsAuthenticatedContextKey); v != nil {
return v.(bool)
}
return false
}
// UserID returns the UserID of the logged user.
func (c *Context) UserID() int64 {
if v := c.request.Context().Value(middleware.UserIDContextKey); v != nil {
return v.(int64)
}
return 0
}
// LoggedUser returns all properties related to the logged user.
func (c *Context) LoggedUser() *model.User {
if c.user == nil {
var err error
c.user, err = c.store.UserByID(c.UserID())
if err != nil {
logger.Fatal("[Context] %v", err)
}
if c.user == nil {
logger.Fatal("Unable to find user from context")
}
}
return c.user
}
// UserLanguage get the locale used by the current logged user.
func (c *Context) UserLanguage() string {
if c.IsAuthenticated() {
user := c.LoggedUser()
return user.Language
}
return c.getContextStringValue(middleware.UserLanguageContextKey)
}
// Translate translates a message in the current language.
func (c *Context) Translate(message string, args ...interface{}) string {
return c.translator.GetLanguage(c.UserLanguage()).Get(message, args...)
}
// CSRF returns the current CSRF token.
func (c *Context) CSRF() string {
return c.getContextStringValue(middleware.CSRFContextKey)
}
// SessionID returns the current session ID.
func (c *Context) SessionID() string {
return c.getContextStringValue(middleware.SessionIDContextKey)
}
// UserSessionToken returns the current user session token.
func (c *Context) UserSessionToken() string {
return c.getContextStringValue(middleware.UserSessionTokenContextKey)
}
// OAuth2State returns the current OAuth2 state.
func (c *Context) OAuth2State() string {
return c.getContextStringValue(middleware.OAuth2StateContextKey)
}
// GenerateOAuth2State generate a new OAuth2 state.
func (c *Context) GenerateOAuth2State() string {
state := crypto.GenerateRandomString(32)
c.store.UpdateSessionField(c.SessionID(), "oauth2_state", state)
return state
}
// SetFlashMessage defines a new flash message.
func (c *Context) SetFlashMessage(message string) {
c.store.UpdateSessionField(c.SessionID(), "flash_message", message)
}
// FlashMessage returns the flash message and remove it.
func (c *Context) FlashMessage() string {
message := c.getContextStringValue(middleware.FlashMessageContextKey)
c.store.UpdateSessionField(c.SessionID(), "flash_message", "")
return message
}
// SetFlashErrorMessage defines a new flash error message.
func (c *Context) SetFlashErrorMessage(message string) {
c.store.UpdateSessionField(c.SessionID(), "flash_error_message", message)
}
// FlashErrorMessage returns the error flash message and remove it.
func (c *Context) FlashErrorMessage() string {
message := c.getContextStringValue(middleware.FlashErrorMessageContextKey)
c.store.UpdateSessionField(c.SessionID(), "flash_error_message", "")
return message
}
func (c *Context) getContextStringValue(key *middleware.ContextKey) string {
if v := c.request.Context().Value(key); v != nil {
return v.(string)
}
return ""
}
// Route returns the path for the given arguments.
func (c *Context) Route(name string, args ...interface{}) string {
return route.Path(c.router, name, args...)
}
// NewContext creates a new Context.
func NewContext(r *http.Request, store *storage.Storage, router *mux.Router, translator *locale.Translator) *Context {
return &Context{request: r, store: store, router: router, translator: translator}
}
-71
View File
@@ -1,71 +0,0 @@
// Copyright 2017 Frédéric Guillot. All rights reserved.
// Use of this source code is governed by the Apache 2.0
// license that can be found in the LICENSE file.
package handler
import (
"net/http"
"time"
"github.com/miniflux/miniflux/config"
"github.com/miniflux/miniflux/http/middleware"
"github.com/miniflux/miniflux/locale"
"github.com/miniflux/miniflux/logger"
"github.com/miniflux/miniflux/storage"
"github.com/miniflux/miniflux/template"
"github.com/miniflux/miniflux/timer"
"github.com/gorilla/mux"
"github.com/tomasen/realip"
)
// ControllerFunc is an application HTTP handler.
type ControllerFunc func(ctx *Context, request *Request, response *Response)
// Handler manages HTTP handlers and middlewares.
type Handler struct {
cfg *config.Config
store *storage.Storage
translator *locale.Translator
template *template.Engine
router *mux.Router
middleware *middleware.Chain
}
// Use is a wrapper around an HTTP handler.
func (h *Handler) Use(f ControllerFunc) http.Handler {
return h.middleware.WrapFunc(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
defer timer.ExecutionTime(time.Now(), r.URL.Path)
logger.Debug("[HTTP] %s %s %s", realip.RealIP(r), r.Method, r.URL.Path)
if r.Header.Get("X-Forwarded-Proto") == "https" {
h.cfg.IsHTTPS = true
}
ctx := NewContext(r, h.store, h.router, h.translator)
request := NewRequest(r)
response := NewResponse(h.cfg, w, r, h.template)
language := ctx.UserLanguage()
if language != "" {
h.template.SetLanguage(language)
} else {
h.template.SetLanguage("en_US")
}
f(ctx, request, response)
}))
}
// NewHandler returns a new Handler.
func NewHandler(cfg *config.Config, store *storage.Storage, router *mux.Router, template *template.Engine, translator *locale.Translator, middleware *middleware.Chain) *Handler {
return &Handler{
cfg: cfg,
store: store,
translator: translator,
router: router,
template: template,
middleware: middleware,
}
}
-65
View File
@@ -1,65 +0,0 @@
// Copyright 2017 Frédéric Guillot. All rights reserved.
// Use of this source code is governed by the Apache 2.0
// license that can be found in the LICENSE file.
package handler
import (
"net/http"
"github.com/miniflux/miniflux/logger"
"github.com/miniflux/miniflux/template"
)
// HTMLResponse handles HTML responses.
type HTMLResponse struct {
writer http.ResponseWriter
request *http.Request
template *template.Engine
}
// Render execute a template and send to the client the generated HTML.
func (h *HTMLResponse) Render(template string, args map[string]interface{}) {
h.writer.Header().Set("Content-Type", "text/html; charset=utf-8")
h.template.Execute(h.writer, template, args)
}
// ServerError sends a 500 error to the browser.
func (h *HTMLResponse) ServerError(err error) {
h.writer.Header().Set("Content-Type", "text/html; charset=utf-8")
h.writer.WriteHeader(http.StatusInternalServerError)
if err != nil {
logger.Error("[Internal Server Error] %v", err)
h.writer.Write([]byte("Internal Server Error: " + err.Error()))
} else {
h.writer.Write([]byte("Internal Server Error"))
}
}
// BadRequest sends a 400 error to the browser.
func (h *HTMLResponse) BadRequest(err error) {
h.writer.Header().Set("Content-Type", "text/html; charset=utf-8")
h.writer.WriteHeader(http.StatusBadRequest)
if err != nil {
logger.Error("[Bad Request] %v", err)
h.writer.Write([]byte("Bad Request: " + err.Error()))
} else {
h.writer.Write([]byte("Bad Request"))
}
}
// NotFound sends a 404 error to the browser.
func (h *HTMLResponse) NotFound() {
h.writer.Header().Set("Content-Type", "text/html; charset=utf-8")
h.writer.WriteHeader(http.StatusNotFound)
h.writer.Write([]byte("Page Not Found"))
}
// Forbidden sends a 403 error to the browser.
func (h *HTMLResponse) Forbidden() {
h.writer.Header().Set("Content-Type", "text/html; charset=utf-8")
h.writer.WriteHeader(http.StatusForbidden)
h.writer.Write([]byte("Access Forbidden"))
}
-111
View File
@@ -1,111 +0,0 @@
// Copyright 2017 Frédéric Guillot. All rights reserved.
// Use of this source code is governed by the Apache 2.0
// license that can be found in the LICENSE file.
package handler
import (
"encoding/json"
"errors"
"net/http"
"github.com/miniflux/miniflux/logger"
)
// JSONResponse handles JSON responses.
type JSONResponse struct {
writer http.ResponseWriter
request *http.Request
}
// Standard sends a JSON response with the status code 200.
func (j *JSONResponse) Standard(v interface{}) {
j.commonHeaders()
j.writer.WriteHeader(http.StatusOK)
j.writer.Write(j.toJSON(v))
}
// Created sends a JSON response with the status code 201.
func (j *JSONResponse) Created(v interface{}) {
j.commonHeaders()
j.writer.WriteHeader(http.StatusCreated)
j.writer.Write(j.toJSON(v))
}
// NoContent sends a JSON response with the status code 204.
func (j *JSONResponse) NoContent() {
j.commonHeaders()
j.writer.WriteHeader(http.StatusNoContent)
}
// BadRequest sends a JSON response with the status code 400.
func (j *JSONResponse) BadRequest(err error) {
logger.Error("[Bad Request] %v", err)
j.commonHeaders()
j.writer.WriteHeader(http.StatusBadRequest)
if err != nil {
j.writer.Write(j.encodeError(err))
}
}
// NotFound sends a JSON response with the status code 404.
func (j *JSONResponse) NotFound(err error) {
logger.Error("[Not Found] %v", err)
j.commonHeaders()
j.writer.WriteHeader(http.StatusNotFound)
j.writer.Write(j.encodeError(err))
}
// ServerError sends a JSON response with the status code 500.
func (j *JSONResponse) ServerError(err error) {
logger.Error("[Internal Server Error] %v", err)
j.commonHeaders()
j.writer.WriteHeader(http.StatusInternalServerError)
if err != nil {
j.writer.Write(j.encodeError(err))
}
}
// Forbidden sends a JSON response with the status code 403.
func (j *JSONResponse) Forbidden() {
logger.Info("[API:Forbidden]")
j.commonHeaders()
j.writer.WriteHeader(http.StatusForbidden)
j.writer.Write(j.encodeError(errors.New("Access Forbidden")))
}
func (j *JSONResponse) commonHeaders() {
j.writer.Header().Set("Accept", "application/json")
j.writer.Header().Set("Content-Type", "application/json; charset=utf-8")
}
func (j *JSONResponse) encodeError(err error) []byte {
type errorMsg struct {
ErrorMessage string `json:"error_message"`
}
tmp := errorMsg{ErrorMessage: err.Error()}
data, err := json.Marshal(tmp)
if err != nil {
logger.Error("encoding error: %v", err)
}
return data
}
func (j *JSONResponse) toJSON(v interface{}) []byte {
b, err := json.Marshal(v)
if err != nil {
logger.Error("encoding error: %v", err)
return []byte("")
}
return b
}
// NewJSONResponse returns a new JSONResponse.
func NewJSONResponse(w http.ResponseWriter, r *http.Request) *JSONResponse {
return &JSONResponse{request: r, writer: w}
}
-124
View File
@@ -1,124 +0,0 @@
// Copyright 2017 Frédéric Guillot. All rights reserved.
// Use of this source code is governed by the Apache 2.0
// license that can be found in the LICENSE file.
package handler
import (
"fmt"
"io"
"mime/multipart"
"net/http"
"strconv"
"github.com/gorilla/mux"
"github.com/miniflux/miniflux/logger"
)
// Request is a thin wrapper around "http.Request".
type Request struct {
request *http.Request
}
// Request returns the raw Request struct.
func (r *Request) Request() *http.Request {
return r.request
}
// Body returns the request body.
func (r *Request) Body() io.ReadCloser {
return r.request.Body
}
// File returns uploaded file properties.
func (r *Request) File(name string) (multipart.File, *multipart.FileHeader, error) {
return r.request.FormFile(name)
}
// Cookie returns the cookie value.
func (r *Request) Cookie(name string) string {
cookie, err := r.request.Cookie(name)
if err == http.ErrNoCookie {
return ""
}
return cookie.Value
}
// FormValue returns a form value as integer.
func (r *Request) FormValue(param string) string {
return r.request.FormValue(param)
}
// FormIntegerValue returns a form value as integer.
func (r *Request) FormIntegerValue(param string) int64 {
value := r.request.FormValue(param)
integer, _ := strconv.Atoi(value)
return int64(integer)
}
// IntegerParam returns an URL parameter as integer.
func (r *Request) IntegerParam(param string) (int64, error) {
vars := mux.Vars(r.request)
value, err := strconv.Atoi(vars[param])
if err != nil {
logger.Error("[IntegerParam] %v", err)
return 0, fmt.Errorf("%s parameter is not an integer", param)
}
if value < 0 {
return 0, nil
}
return int64(value), nil
}
// StringParam returns an URL parameter as string.
func (r *Request) StringParam(param, defaultValue string) string {
vars := mux.Vars(r.request)
value := vars[param]
if value == "" {
value = defaultValue
}
return value
}
// QueryStringParam returns a querystring parameter as string.
func (r *Request) QueryStringParam(param, defaultValue string) string {
value := r.request.URL.Query().Get(param)
if value == "" {
value = defaultValue
}
return value
}
// QueryIntegerParam returns a querystring parameter as string.
func (r *Request) QueryIntegerParam(param string, defaultValue int) int {
value := r.request.URL.Query().Get(param)
if value == "" {
return defaultValue
}
val, err := strconv.Atoi(value)
if err != nil {
return defaultValue
}
if val < 0 {
return defaultValue
}
return val
}
// HasQueryParam checks if the query string contains the given parameter.
func (r *Request) HasQueryParam(param string) bool {
values := r.request.URL.Query()
_, ok := values[param]
return ok
}
// NewRequest returns a new Request.
func NewRequest(r *http.Request) *Request {
return &Request{r}
}
-88
View File
@@ -1,88 +0,0 @@
// Copyright 2017 Frédéric Guillot. All rights reserved.
// Use of this source code is governed by the Apache 2.0
// license that can be found in the LICENSE file.
package handler
import (
"net/http"
"time"
"github.com/miniflux/miniflux/config"
"github.com/miniflux/miniflux/template"
)
// Response handles HTTP responses.
type Response struct {
cfg *config.Config
writer http.ResponseWriter
request *http.Request
template *template.Engine
}
// SetCookie send a cookie to the client.
func (r *Response) SetCookie(cookie *http.Cookie) {
http.SetCookie(r.writer, cookie)
}
// JSON returns a JSONResponse.
func (r *Response) JSON() *JSONResponse {
r.commonHeaders()
return NewJSONResponse(r.writer, r.request)
}
// HTML returns a HTMLResponse.
func (r *Response) HTML() *HTMLResponse {
r.commonHeaders()
return &HTMLResponse{writer: r.writer, request: r.request, template: r.template}
}
// XML returns a XMLResponse.
func (r *Response) XML() *XMLResponse {
r.commonHeaders()
return &XMLResponse{writer: r.writer, request: r.request}
}
// Redirect redirects the user to another location.
func (r *Response) Redirect(path string) {
http.Redirect(r.writer, r.request, path, http.StatusFound)
}
// NotModified sends a response with a 304 status code.
func (r *Response) NotModified() {
r.commonHeaders()
r.writer.WriteHeader(http.StatusNotModified)
}
// Cache returns a response with caching headers.
func (r *Response) Cache(mimeType, etag string, content []byte, duration time.Duration) {
r.writer.Header().Set("Content-Type", mimeType)
r.writer.Header().Set("ETag", etag)
r.writer.Header().Set("Cache-Control", "public")
r.writer.Header().Set("Expires", time.Now().Add(duration).Format(time.RFC1123))
if etag == r.request.Header.Get("If-None-Match") {
r.writer.WriteHeader(http.StatusNotModified)
} else {
r.writer.Write(content)
}
}
func (r *Response) commonHeaders() {
r.writer.Header().Set("X-XSS-Protection", "1; mode=block")
r.writer.Header().Set("X-Content-Type-Options", "nosniff")
r.writer.Header().Set("X-Frame-Options", "DENY")
// Even if the directive "frame-src" has been deprecated in Firefox,
// we keep it to stay compatible with other browsers.
r.writer.Header().Set("Content-Security-Policy", "default-src 'self'; img-src *; media-src *; frame-src *; child-src *")
if r.cfg.IsHTTPS && r.cfg.HasHSTS() {
r.writer.Header().Set("Strict-Transport-Security", "max-age=31536000")
}
}
// NewResponse returns a new Response.
func NewResponse(cfg *config.Config, w http.ResponseWriter, r *http.Request, template *template.Engine) *Response {
return &Response{cfg: cfg, writer: w, request: r, template: template}
}
-29
View File
@@ -1,29 +0,0 @@
// Copyright 2017 Frédéric Guillot. All rights reserved.
// Use of this source code is governed by the Apache 2.0
// license that can be found in the LICENSE file.
package handler
import (
"fmt"
"net/http"
)
// XMLResponse handles XML responses.
type XMLResponse struct {
writer http.ResponseWriter
request *http.Request
}
// Download force the download of a XML document.
func (x *XMLResponse) Download(filename, data string) {
x.writer.Header().Set("Content-Type", "text/xml")
x.writer.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%s", filename))
x.writer.Write([]byte(data))
}
// Serve forces the XML to be sent to browser.
func (x *XMLResponse) Serve(data string) {
x.writer.Header().Set("Content-Type", "text/xml")
x.writer.Write([]byte(data))
}
-36
View File
@@ -1,36 +0,0 @@
// Copyright 2017 Frédéric Guillot. All rights reserved.
// Use of this source code is governed by the Apache 2.0
// license that can be found in the LICENSE file.
package middleware
import (
"net/http"
)
// Middleware represents a HTTP middleware.
type Middleware func(http.Handler) http.Handler
// Chain handles a list of middlewares.
type Chain struct {
middlewares []Middleware
}
// Wrap adds a HTTP handler into the chain.
func (m *Chain) Wrap(h http.Handler) http.Handler {
for i := range m.middlewares {
h = m.middlewares[len(m.middlewares)-1-i](h)
}
return h
}
// WrapFunc adds a HTTP handler function into the chain.
func (m *Chain) WrapFunc(fn http.HandlerFunc) http.Handler {
return m.Wrap(fn)
}
// NewChain returns a new Chain.
func NewChain(middlewares ...Middleware) *Chain {
return &Chain{append(([]Middleware)(nil), middlewares...)}
}
-85
View File
@@ -1,85 +0,0 @@
// Copyright 2017 Frédéric Guillot. All rights reserved.
// Use of this source code is governed by the Apache 2.0
// license that can be found in the LICENSE file.
package middleware
import (
"context"
"net/http"
"github.com/miniflux/miniflux/config"
"github.com/miniflux/miniflux/http/cookie"
"github.com/miniflux/miniflux/logger"
"github.com/miniflux/miniflux/model"
"github.com/miniflux/miniflux/storage"
)
// SessionMiddleware represents a session middleware.
type SessionMiddleware struct {
cfg *config.Config
store *storage.Storage
}
// Handler execute the middleware.
func (s *SessionMiddleware) Handler(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var err error
session := s.getSessionValueFromCookie(r)
if session == nil {
logger.Debug("[Middleware:Session] Session not found")
session, err = s.store.CreateSession()
if err != nil {
logger.Error("[Middleware:Session] %v", err)
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
return
}
http.SetCookie(w, cookie.New(cookie.CookieSessionID, session.ID, s.cfg.IsHTTPS, s.cfg.BasePath()))
} else {
logger.Debug("[Middleware:Session] %s", session)
}
if r.Method == "POST" {
formValue := r.FormValue("csrf")
headerValue := r.Header.Get("X-Csrf-Token")
if session.Data.CSRF != formValue && session.Data.CSRF != headerValue {
logger.Error(`[Middleware:Session] Invalid or missing CSRF token: Form="%s", Header="%s"`, formValue, headerValue)
w.WriteHeader(http.StatusBadRequest)
w.Write([]byte("Invalid or missing CSRF session!"))
return
}
}
ctx := r.Context()
ctx = context.WithValue(ctx, SessionIDContextKey, session.ID)
ctx = context.WithValue(ctx, CSRFContextKey, session.Data.CSRF)
ctx = context.WithValue(ctx, OAuth2StateContextKey, session.Data.OAuth2State)
ctx = context.WithValue(ctx, FlashMessageContextKey, session.Data.FlashMessage)
ctx = context.WithValue(ctx, FlashErrorMessageContextKey, session.Data.FlashErrorMessage)
ctx = context.WithValue(ctx, UserLanguageContextKey, session.Data.Language)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
func (s *SessionMiddleware) getSessionValueFromCookie(r *http.Request) *model.Session {
sessionCookie, err := r.Cookie(cookie.CookieSessionID)
if err == http.ErrNoCookie {
return nil
}
session, err := s.store.Session(sessionCookie.Value)
if err != nil {
logger.Error("[Middleware:Session] %v", err)
return nil
}
return session
}
// NewSessionMiddleware returns a new SessionMiddleware.
func NewSessionMiddleware(cfg *config.Config, store *storage.Storage) *SessionMiddleware {
return &SessionMiddleware{cfg, store}
}
+119
View File
@@ -0,0 +1,119 @@
// Copyright 2018 Frédéric Guillot. All rights reserved.
// Use of this source code is governed by the Apache 2.0
// license that can be found in the LICENSE file.
package request
import (
"fmt"
"net"
"net/http"
"strconv"
"strings"
"github.com/gorilla/mux"
)
// Cookie returns the cookie value.
func Cookie(r *http.Request, name string) string {
cookie, err := r.Cookie(name)
if err == http.ErrNoCookie {
return ""
}
return cookie.Value
}
// FormIntValue returns a form value as integer.
func FormIntValue(r *http.Request, param string) int64 {
value := r.FormValue(param)
integer, _ := strconv.Atoi(value)
return int64(integer)
}
// IntParam returns an URL route parameter as integer.
func IntParam(r *http.Request, param string) (int64, error) {
vars := mux.Vars(r)
value, err := strconv.Atoi(vars[param])
if err != nil {
return 0, fmt.Errorf("request: %s parameter is not an integer", param)
}
if value < 0 {
return 0, nil
}
return int64(value), nil
}
// Param returns an URL route parameter as string.
func Param(r *http.Request, param, defaultValue string) string {
vars := mux.Vars(r)
value := vars[param]
if value == "" {
value = defaultValue
}
return value
}
// QueryParam returns a querystring parameter as string.
func QueryParam(r *http.Request, param, defaultValue string) string {
value := r.URL.Query().Get(param)
if value == "" {
value = defaultValue
}
return value
}
// QueryIntParam returns a querystring parameter as integer.
func QueryIntParam(r *http.Request, param string, defaultValue int) int {
value := r.URL.Query().Get(param)
if value == "" {
return defaultValue
}
val, err := strconv.Atoi(value)
if err != nil {
return defaultValue
}
if val < 0 {
return defaultValue
}
return val
}
// HasQueryParam checks if the query string contains the given parameter.
func HasQueryParam(r *http.Request, param string) bool {
values := r.URL.Query()
_, ok := values[param]
return ok
}
// RealIP returns client's real IP address.
func RealIP(r *http.Request) string {
headers := []string{"X-Forwarded-For", "X-Real-Ip"}
for _, header := range headers {
value := r.Header.Get(header)
if value != "" {
addresses := strings.Split(value, ",")
address := strings.TrimSpace(addresses[0])
if net.ParseIP(address) != nil {
return address
}
}
}
// Fallback to TCP/IP source IP address.
var remoteIP string
if strings.ContainsRune(r.RemoteAddr, ':') {
remoteIP, _, _ = net.SplitHostPort(r.RemoteAddr)
} else {
remoteIP = r.RemoteAddr
}
return remoteIP
}
+82
View File
@@ -0,0 +1,82 @@
// Copyright 2018 Frédéric Guillot. All rights reserved.
// Use of this source code is governed by the Apache 2.0
// license that can be found in the LICENSE file.
package request
import (
"net/http"
"testing"
)
func TestRealIPWithoutHeaders(t *testing.T) {
r := &http.Request{RemoteAddr: "192.168.0.1:4242"}
if ip := RealIP(r); ip != "192.168.0.1" {
t.Fatalf(`Unexpected result, got: %q`, ip)
}
r = &http.Request{RemoteAddr: "192.168.0.1"}
if ip := RealIP(r); ip != "192.168.0.1" {
t.Fatalf(`Unexpected result, got: %q`, ip)
}
}
func TestRealIPWithXFFHeader(t *testing.T) {
// Test with multiple IPv4 addresses.
headers := http.Header{}
headers.Set("X-Forwarded-For", "203.0.113.195, 70.41.3.18, 150.172.238.178")
r := &http.Request{RemoteAddr: "192.168.0.1:4242", Header: headers}
if ip := RealIP(r); ip != "203.0.113.195" {
t.Fatalf(`Unexpected result, got: %q`, ip)
}
// Test with single IPv6 address.
headers = http.Header{}
headers.Set("X-Forwarded-For", "2001:db8:85a3:8d3:1319:8a2e:370:7348")
r = &http.Request{RemoteAddr: "192.168.0.1:4242", Header: headers}
if ip := RealIP(r); ip != "2001:db8:85a3:8d3:1319:8a2e:370:7348" {
t.Fatalf(`Unexpected result, got: %q`, ip)
}
// Test with single IPv4 address.
headers = http.Header{}
headers.Set("X-Forwarded-For", "70.41.3.18")
r = &http.Request{RemoteAddr: "192.168.0.1:4242", Header: headers}
if ip := RealIP(r); ip != "70.41.3.18" {
t.Fatalf(`Unexpected result, got: %q`, ip)
}
// Test with invalid IP address.
headers = http.Header{}
headers.Set("X-Forwarded-For", "fake IP")
r = &http.Request{RemoteAddr: "192.168.0.1:4242", Header: headers}
if ip := RealIP(r); ip != "192.168.0.1" {
t.Fatalf(`Unexpected result, got: %q`, ip)
}
}
func TestRealIPWithXRealIPHeader(t *testing.T) {
headers := http.Header{}
headers.Set("X-Real-Ip", "192.168.122.1")
r := &http.Request{RemoteAddr: "192.168.0.1:4242", Header: headers}
if ip := RealIP(r); ip != "192.168.122.1" {
t.Fatalf(`Unexpected result, got: %q`, ip)
}
}
func TestRealIPWithBothHeaders(t *testing.T) {
headers := http.Header{}
headers.Set("X-Forwarded-For", "203.0.113.195, 70.41.3.18, 150.172.238.178")
headers.Set("X-Real-Ip", "192.168.122.1")
r := &http.Request{RemoteAddr: "192.168.0.1:4242", Header: headers}
if ip := RealIP(r); ip != "203.0.113.195" {
t.Fatalf(`Unexpected result, got: %q`, ip)
}
}
+57
View File
@@ -0,0 +1,57 @@
// Copyright 2018 Frédéric Guillot. All rights reserved.
// Use of this source code is governed by the Apache 2.0
// license that can be found in the LICENSE file.
package html
import (
"net/http"
"github.com/miniflux/miniflux/logger"
)
// OK writes a standard HTML response.
func OK(w http.ResponseWriter, b []byte) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Write(b)
}
// ServerError sends a 500 error to the browser.
func ServerError(w http.ResponseWriter, err error) {
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
w.WriteHeader(http.StatusInternalServerError)
if err != nil {
logger.Error("[Internal Server Error] %v", err)
w.Write([]byte("Internal Server Error: " + err.Error()))
} else {
w.Write([]byte("Internal Server Error"))
}
}
// BadRequest sends a 400 error to the browser.
func BadRequest(w http.ResponseWriter, err error) {
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
w.WriteHeader(http.StatusBadRequest)
if err != nil {
logger.Error("[Bad Request] %v", err)
w.Write([]byte("Bad Request: " + err.Error()))
} else {
w.Write([]byte("Bad Request"))
}
}
// NotFound sends a 404 error to the browser.
func NotFound(w http.ResponseWriter) {
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
w.WriteHeader(http.StatusNotFound)
w.Write([]byte("Page Not Found"))
}
// Forbidden sends a 403 error to the browser.
func Forbidden(w http.ResponseWriter) {
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
w.WriteHeader(http.StatusForbidden)
w.Write([]byte("Access Forbidden"))
}
+107
View File
@@ -0,0 +1,107 @@
// Copyright 2018 Frédéric Guillot. All rights reserved.
// Use of this source code is governed by the Apache 2.0
// license that can be found in the LICENSE file.
package json
import (
"encoding/json"
"errors"
"net/http"
"github.com/miniflux/miniflux/logger"
)
// OK sends a JSON response with the status code 200.
func OK(w http.ResponseWriter, v interface{}) {
commonHeaders(w)
w.WriteHeader(http.StatusOK)
w.Write(toJSON(v))
}
// Created sends a JSON response with the status code 201.
func Created(w http.ResponseWriter, v interface{}) {
commonHeaders(w)
w.WriteHeader(http.StatusCreated)
w.Write(toJSON(v))
}
// NoContent sends a JSON response with the status code 204.
func NoContent(w http.ResponseWriter) {
commonHeaders(w)
w.WriteHeader(http.StatusNoContent)
}
// NotFound sends a JSON response with the status code 404.
func NotFound(w http.ResponseWriter, err error) {
logger.Error("[Not Found] %v", err)
commonHeaders(w)
w.WriteHeader(http.StatusNotFound)
w.Write(encodeError(err))
}
// ServerError sends a JSON response with the status code 500.
func ServerError(w http.ResponseWriter, err error) {
logger.Error("[Internal Server Error] %v", err)
commonHeaders(w)
w.WriteHeader(http.StatusInternalServerError)
if err != nil {
w.Write(encodeError(err))
}
}
// Forbidden sends a JSON response with the status code 403.
func Forbidden(w http.ResponseWriter) {
logger.Info("[Forbidden]")
commonHeaders(w)
w.WriteHeader(http.StatusForbidden)
w.Write(encodeError(errors.New("Access Forbidden")))
}
// Unauthorized sends a JSON response with the status code 401.
func Unauthorized(w http.ResponseWriter) {
commonHeaders(w)
w.WriteHeader(http.StatusUnauthorized)
w.Write(encodeError(errors.New("Access Unauthorized")))
}
// BadRequest sends a JSON response with the status code 400.
func BadRequest(w http.ResponseWriter, err error) {
logger.Error("[Bad Request] %v", err)
commonHeaders(w)
w.WriteHeader(http.StatusBadRequest)
if err != nil {
w.Write(encodeError(err))
}
}
func commonHeaders(w http.ResponseWriter) {
w.Header().Set("Accept", "application/json")
w.Header().Set("Content-Type", "application/json; charset=utf-8")
}
func encodeError(err error) []byte {
type errorMsg struct {
ErrorMessage string `json:"error_message"`
}
tmp := errorMsg{ErrorMessage: err.Error()}
data, err := json.Marshal(tmp)
if err != nil {
logger.Error("json encoding error: %v", err)
}
return data
}
func toJSON(v interface{}) []byte {
b, err := json.Marshal(v)
if err != nil {
logger.Error("json encoding error: %v", err)
return []byte("")
}
return b
}
+34
View File
@@ -0,0 +1,34 @@
// Copyright 2018 Frédéric Guillot. All rights reserved.
// Use of this source code is governed by the Apache 2.0
// license that can be found in the LICENSE file.
package response
import (
"net/http"
"time"
)
// Redirect redirects the user to another location.
func Redirect(w http.ResponseWriter, r *http.Request, path string) {
http.Redirect(w, r, path, http.StatusFound)
}
// NotModified sends a response with a 304 status code.
func NotModified(w http.ResponseWriter) {
w.WriteHeader(http.StatusNotModified)
}
// Cache returns a response with caching headers.
func Cache(w http.ResponseWriter, r *http.Request, mimeType, etag string, content []byte, duration time.Duration) {
w.Header().Set("Content-Type", mimeType)
w.Header().Set("ETag", etag)
w.Header().Set("Cache-Control", "public")
w.Header().Set("Expires", time.Now().Add(duration).Format(time.RFC1123))
if etag == r.Header.Get("If-None-Match") {
w.WriteHeader(http.StatusNotModified)
} else {
w.Write(content)
}
}
+23
View File
@@ -0,0 +1,23 @@
// Copyright 2018 Frédéric Guillot. All rights reserved.
// Use of this source code is governed by the Apache 2.0
// license that can be found in the LICENSE file.
package xml
import (
"fmt"
"net/http"
)
// OK sends a XML document.
func OK(w http.ResponseWriter, data string) {
w.Header().Set("Content-Type", "text/xml")
w.Write([]byte(data))
}
// Attachment forces the download of a XML document.
func Attachment(w http.ResponseWriter, filename, data string) {
w.Header().Set("Content-Type", "text/xml")
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%s", filename))
w.Write([]byte(data))
}
+13 -4
View File
@@ -8,7 +8,7 @@ import (
"fmt"
"net/url"
"github.com/miniflux/miniflux/http"
"github.com/miniflux/miniflux/http/client"
)
// Client represents an Instapaper client.
@@ -19,18 +19,27 @@ type Client struct {
// AddURL sends a link to Instapaper.
func (c *Client) AddURL(link, title string) error {
if c.username == "" || c.password == "" {
return fmt.Errorf("instapaper: missing credentials")
}
values := url.Values{}
values.Add("url", link)
values.Add("title", title)
apiURL := "https://www.instapaper.com/api/add?" + values.Encode()
client := http.NewClientWithCredentials(apiURL, c.username, c.password)
response, err := client.Get()
clt := client.New(apiURL)
clt.WithCredentials(c.username, c.password)
response, err := clt.Get()
if err != nil {
return fmt.Errorf("instapaper: unable to send url: %v", err)
}
if response.HasServerFailure() {
return fmt.Errorf("instapaper: unable to send url, status=%d", response.StatusCode)
}
return err
return nil
}
// NewClient returns a new Instapaper client.
+25 -4
View File
@@ -5,15 +5,18 @@
package integration
import (
"github.com/miniflux/miniflux/config"
"github.com/miniflux/miniflux/integration/instapaper"
"github.com/miniflux/miniflux/integration/nunuxkeeper"
"github.com/miniflux/miniflux/integration/pinboard"
"github.com/miniflux/miniflux/integration/pocket"
"github.com/miniflux/miniflux/integration/wallabag"
"github.com/miniflux/miniflux/logger"
"github.com/miniflux/miniflux/model"
)
// SendEntry send the entry to the activated providers.
func SendEntry(entry *model.Entry, integration *model.Integration) {
func SendEntry(cfg *config.Config, entry *model.Entry, integration *model.Integration) {
if integration.PinboardEnabled {
client := pinboard.NewClient(integration.PinboardToken)
err := client.AddBookmark(
@@ -24,14 +27,14 @@ func SendEntry(entry *model.Entry, integration *model.Integration) {
)
if err != nil {
logger.Error("[Integration] %v", err)
logger.Error("[Integration] UserID #%d: %v", integration.UserID, err)
}
}
if integration.InstapaperEnabled {
client := instapaper.NewClient(integration.InstapaperUsername, integration.InstapaperPassword)
if err := client.AddURL(entry.URL, entry.Title); err != nil {
logger.Error("[Integration] %v", err)
logger.Error("[Integration] UserID #%d: %v", integration.UserID, err)
}
}
@@ -45,7 +48,25 @@ 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)
}
}
if integration.NunuxKeeperEnabled {
client := nunuxkeeper.NewClient(
integration.NunuxKeeperURL,
integration.NunuxKeeperAPIKey,
)
if err := client.AddEntry(entry.URL, entry.Title, entry.Content); err != nil {
logger.Error("[Integration] UserID #%d: %v", integration.UserID, err)
}
}
if integration.PocketEnabled {
client := pocket.NewClient(cfg.PocketConsumerKey(integration.PocketConsumerKey), integration.PocketAccessToken)
if err := client.AddURL(entry.URL, entry.Title); err != nil {
logger.Error("[Integration] UserID #%d: %v", integration.UserID, err)
}
}
}
+73
View File
@@ -0,0 +1,73 @@
// 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 nunuxkeeper
import (
"fmt"
"net/url"
"path"
"github.com/miniflux/miniflux/http/client"
)
// Document structure of a Nununx Keeper document
type Document struct {
Title string `json:"title,omitempty"`
Origin string `json:"origin,omitempty"`
Content string `json:"content,omitempty"`
ContentType string `json:"contentType,omitempty"`
}
// Client represents an Nunux Keeper client.
type Client struct {
baseURL string
apiKey string
}
// 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,
Content: content,
ContentType: "text/html",
}
apiURL, err := getAPIEndpoint(c.baseURL, "/v2/documents")
if err != nil {
return err
}
clt := client.New(apiURL)
clt.WithCredentials("api", c.apiKey)
response, err := clt.PostJSON(doc)
if err != nil {
return fmt.Errorf("nunux-keeper: unable to send entry: %v", err)
}
if response.HasServerFailure() {
return fmt.Errorf("nunux-keeper: unable to send entry, status=%d", response.StatusCode)
}
return nil
}
// NewClient returns a new Nunux Keeepr client.
func NewClient(baseURL, apiKey string) *Client {
return &Client{baseURL: baseURL, apiKey: apiKey}
}
func getAPIEndpoint(baseURL, pathURL string) (string, error) {
u, err := url.Parse(baseURL)
if err != nil {
return "", fmt.Errorf("nunux-keeper: invalid API endpoint: %v", err)
}
u.Path = path.Join(u.Path, pathURL)
return u.String(), nil
}
+12 -4
View File
@@ -8,7 +8,7 @@ import (
"fmt"
"net/url"
"github.com/miniflux/miniflux/http"
"github.com/miniflux/miniflux/http/client"
)
// Client represents a Pinboard client.
@@ -18,6 +18,10 @@ type Client struct {
// AddBookmark sends a link to Pinboard.
func (c *Client) AddBookmark(link, title, tags string, markAsUnread bool) error {
if c.authToken == "" {
return fmt.Errorf("pinboard: missing credentials")
}
toRead := "no"
if markAsUnread {
toRead = "yes"
@@ -30,13 +34,17 @@ func (c *Client) AddBookmark(link, title, tags string, markAsUnread bool) error
values.Add("tags", tags)
values.Add("toread", toRead)
client := http.NewClient("https://api.pinboard.in/v1/posts/add?" + values.Encode())
response, err := client.Get()
clt := client.New("https://api.pinboard.in/v1/posts/add?" + values.Encode())
response, err := clt.Get()
if err != nil {
return fmt.Errorf("pinboard: unable to send bookmark: %v", err)
}
if response.HasServerFailure() {
return fmt.Errorf("pinboard: unable to send bookmark, status=%d", response.StatusCode)
}
return err
return nil
}
// NewClient returns a new Pinboard client.
+103
View File
@@ -0,0 +1,103 @@
// Copyright 2018 Frédéric Guillot. All rights reserved.
// Use of this source code is governed by the Apache 2.0
// license that can be found in the LICENSE file.
package pocket
import (
"errors"
"fmt"
"io/ioutil"
"net/url"
"github.com/miniflux/miniflux/http/client"
)
// Connector manages the authorization flow with Pocket to get a personal access token.
type Connector struct {
consumerKey string
}
// RequestToken fetches a new request token from Pocket API.
func (c *Connector) RequestToken(redirectURL string) (string, error) {
type req struct {
ConsumerKey string `json:"consumer_key"`
RedirectURI string `json:"redirect_uri"`
}
clt := client.New("https://getpocket.com/v3/oauth/request")
response, err := clt.PostJSON(&req{ConsumerKey: c.consumerKey, RedirectURI: redirectURL})
if err != nil {
return "", fmt.Errorf("pocket: unable to fetch request token: %v", err)
}
if response.HasServerFailure() {
return "", fmt.Errorf("pocket: unable to fetch request token, status=%d", response.StatusCode)
}
body, err := ioutil.ReadAll(response.Body)
if err != nil {
return "", fmt.Errorf("pocket: unable to read response body: %v", err)
}
values, err := url.ParseQuery(string(body))
if err != nil {
return "", fmt.Errorf("pocket: unable to parse response: %v", err)
}
code := values.Get("code")
if code == "" {
return "", errors.New("pocket: code is empty")
}
return code, nil
}
// AccessToken fetches a new access token once the end-user authorized the application.
func (c *Connector) AccessToken(requestToken string) (string, error) {
type req struct {
ConsumerKey string `json:"consumer_key"`
Code string `json:"code"`
}
clt := client.New("https://getpocket.com/v3/oauth/authorize")
response, err := clt.PostJSON(&req{ConsumerKey: c.consumerKey, Code: requestToken})
if err != nil {
return "", fmt.Errorf("pocket: unable to fetch access token: %v", err)
}
if response.HasServerFailure() {
return "", fmt.Errorf("pocket: unable to fetch access token, status=%d", response.StatusCode)
}
body, err := ioutil.ReadAll(response.Body)
if err != nil {
return "", fmt.Errorf("pocket: unable to read response body: %v", err)
}
values, err := url.ParseQuery(string(body))
if err != nil {
return "", fmt.Errorf("pocket: unable to parse response: %v", err)
}
token := values.Get("access_token")
if token == "" {
return "", errors.New("pocket: access_token is empty")
}
return token, nil
}
// AuthorizationURL returns the authorization URL for the end-user.
func (c *Connector) AuthorizationURL(requestToken, redirectURL string) string {
return fmt.Sprintf(
"https://getpocket.com/auth/authorize?request_token=%s&redirect_uri=%s",
requestToken,
redirectURL,
)
}
// NewConnector returns a new Pocket Connector.
func NewConnector(consumerKey string) *Connector {
return &Connector{consumerKey}
}
+55
View File
@@ -0,0 +1,55 @@
// Copyright 2018 Frédéric Guillot. All rights reserved.
// Use of this source code is governed by the Apache 2.0
// license that can be found in the LICENSE file.
package pocket
import (
"fmt"
"github.com/miniflux/miniflux/http/client"
)
// Client represents a Pocket client.
type Client struct {
consumerKey string
accessToken string
}
// AddURL sends a single link to Pocket.
func (c *Client) AddURL(link, title string) error {
if c.consumerKey == "" || c.accessToken == "" {
return fmt.Errorf("pocket: missing credentials")
}
type body struct {
AccessToken string `json:"access_token"`
ConsumerKey string `json:"consumer_key"`
Title string `json:"title,omitempty"`
URL string `json:"url"`
}
data := &body{
AccessToken: c.accessToken,
ConsumerKey: c.consumerKey,
Title: title,
URL: link,
}
clt := client.New("https://getpocket.com/v3/add")
response, err := clt.PostJSON(data)
if err != nil {
return fmt.Errorf("pocket: unable to send url: %v", err)
}
if response.HasServerFailure() {
return fmt.Errorf("pocket: unable to send url, status=%d", response.StatusCode)
}
return nil
}
// NewClient returns a new Pocket client.
func NewClient(consumerKey, accessToken string) *Client {
return &Client{consumerKey, accessToken}
}
+10 -5
View File
@@ -10,7 +10,7 @@ import (
"io"
"net/url"
"github.com/miniflux/miniflux/http"
"github.com/miniflux/miniflux/http/client"
)
// Client represents a Wallabag client.
@@ -24,6 +24,10 @@ type Client struct {
// AddEntry sends a link to Wallabag.
func (c *Client) AddEntry(link, title string) error {
if c.baseURL == "" || c.clientID == "" || c.clientSecret == "" || c.username == "" || c.password == "" {
return fmt.Errorf("wallabag: missing credentials")
}
accessToken, err := c.getAccessToken()
if err != nil {
return err
@@ -38,8 +42,9 @@ func (c *Client) createEntry(accessToken, link, title string) error {
return fmt.Errorf("wallbag: unable to get entries endpoint: %v", err)
}
client := http.NewClientWithAuthorization(endpoint, "Bearer "+accessToken)
response, err := client.PostJSON(map[string]string{"url": link, "title": title})
clt := client.New(endpoint)
clt.WithAuthorization("Bearer " + accessToken)
response, err := clt.PostJSON(map[string]string{"url": link, "title": title})
if err != nil {
return fmt.Errorf("wallabag: unable to post entry: %v", err)
}
@@ -64,8 +69,8 @@ func (c *Client) getAccessToken() (string, error) {
return "", fmt.Errorf("wallbag: unable to get token endpoint: %v", err)
}
client := http.NewClient(endpoint)
response, err := client.PostForm(values)
clt := client.New(endpoint)
response, err := clt.PostForm(values)
if err != nil {
return "", fmt.Errorf("wallabag: unable to get access token: %v", err)
}
+44
View File
@@ -7,6 +7,8 @@
package main
import (
"bytes"
"io/ioutil"
"math/rand"
"strconv"
"strings"
@@ -42,6 +44,22 @@ func TestWithWrongCredentials(t *testing.T) {
}
}
func TestGetCurrentLoggedUser(t *testing.T) {
client := miniflux.NewClient(testBaseURL, testAdminUsername, testAdminPassword)
user, err := client.Me()
if err != nil {
t.Fatal(err)
}
if user.ID == 0 {
t.Fatalf(`Invalid userID, got %q`, user.ID)
}
if user.Username != testAdminUsername {
t.Fatalf(`Invalid username, got %q`, user.Username)
}
}
func TestGetUsers(t *testing.T) {
client := miniflux.NewClient(testBaseURL, testAdminUsername, testAdminPassword)
users, err := client.Users()
@@ -653,6 +671,32 @@ func TestExport(t *testing.T) {
}
}
func TestImport(t *testing.T) {
username := getRandomUsername()
client := miniflux.NewClient(testBaseURL, testAdminUsername, testAdminPassword)
_, err := client.CreateUser(username, testStandardPassword, false)
if err != nil {
t.Fatal(err)
}
client = miniflux.NewClient(testBaseURL, username, testStandardPassword)
data := `<?xml version="1.0" encoding="UTF-8"?>
<opml version="2.0">
<body>
<outline text="Test Category">
<outline title="Test" text="Test" xmlUrl="` + testFeedURL + `" htmlUrl="` + testWebsiteURL + `"></outline>
</outline>
</body>
</opml>`
b := bytes.NewReader([]byte(data))
err = client.Import(ioutil.NopCloser(b))
if err != nil {
t.Fatal(err)
}
}
func TestUpdateFeed(t *testing.T) {
username := getRandomUsername()
client := miniflux.NewClient(testBaseURL, testAdminUsername, testAdminPassword)
Regular → Executable
+3
View File
@@ -30,5 +30,8 @@ func AvailableLanguages() map[string]string {
"en_US": "English",
"fr_FR": "Français",
"de_DE": "Deutsch",
"pl_PL": "Polski",
"zh_CN": "简体中文",
"nl_NL": "Nederlands",
}
}
Regular → Executable
+731 -30
View File
@@ -1,13 +1,13 @@
// Code generated by go generate; DO NOT EDIT.
// 2018-02-01 14:52:01.163022 +0100 CET m=+0.026333224
// 2018-05-20 15:22:33.795349513 -0700 PDT m=+0.026820026
package locale
var translations = map[string]string{
"de_DE": `{
"plural.feed.error_count": [
"%d Error",
"%d Errors"
"%d Fehler",
"%d Fehler"
],
"plural.categories.feed_count": [
"Es gibt %d Abonnement.",
@@ -90,8 +90,8 @@ var translations = map[string]string{
"Last Login": "Letzte Anmeldung",
"Yes": "Ja",
"No": "Nein",
"This feed already exists (%s).": "Diese Abonnement existiert bereits (%s).",
"Unable to fetch feed (statusCode=%d).": "Abonnement konnte nicht abgerufen werden (code=%d).",
"This feed already exists (%s)": "Diese Abonnement existiert bereits (%s)",
"Unable to fetch feed (statusCode=%d)": "Abonnement konnte nicht abgerufen werden (code=%d)",
"Unable to open this link: %v": "Dieser Link konnte nicht geöffnet werden: %v",
"Unable to analyze this page: %v": "Diese Seite konnte nicht analysiert werden: %v",
"Unable to find any subscription.": "Es wurden keine Abonnements gefunden.",
@@ -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:",
@@ -114,14 +114,14 @@ var translations = map[string]string{
"Invalid username or password.": "Benutzername oder Passwort ungültig.",
"Never": "Niemals",
"Unable to execute request: %v": "Diese Anfrage konnte nicht ausgeführt werden: %v",
"Last Parsing Error": "Letzter Analyse Error",
"Last Parsing Error": "Letzter Analysefehler",
"There is a problem with this feed": "Es gibt ein Problem mit diesem Abonnement",
"Unable to parse OPML file: %v.": "OPML Datei konnte nicht gelesen werden: %v.",
"Unable to parse RSS feed: %v.": "RSS Feed konnte nicht gelesen werden: %v.",
"Unable to parse Atom feed: %v.": "Atom Feed konnte nicht gelesen werden: %v.",
"Unable to parse JSON feed: %v.": "JSON Feed konnte nicht gelesen werden: %v.",
"Unable to parse RDF feed: %v.": "RDF Feed konnte nicht gelesen werden: %v.",
"Unable to normalize encoding: %v.": "Zeichenkodierung konnte nicht normalisiert werden: %v.",
"Unable to parse OPML file: %q": "OPML Datei konnte nicht gelesen werden: %q",
"Unable to parse RSS feed: %q": "RSS Feed konnte nicht gelesen werden: %q",
"Unable to parse Atom feed: %q": "Atom Feed konnte nicht gelesen werden: %q",
"Unable to parse JSON feed: %q": "JSON Feed konnte nicht gelesen werden: %q",
"Unable to parse RDF feed: %q": "RDF Feed konnte nicht gelesen werden: %q",
"Unable to normalize encoding: %q": "Zeichenkodierung konnte nicht normalisiert werden: %q",
"Unable to create this category.": "Diese Kategorie konnte nicht angelegt werden.",
"yes": "ja",
"no": "nein",
@@ -139,7 +139,7 @@ var translations = map[string]string{
"Sign in with Google": "Anmeldung mit Google",
"Unlink my Google account": "Google Konto abmelden",
"Link my Google account": "Google Konto verknüpfen",
"Category not found for this user.": "Diese Kategorie existiert nicht für diesen Benutzer.",
"Category not found for this user": "Diese Kategorie existiert nicht für diesen Benutzer",
"Invalid theme.": "Dieses Thema ist fehlerhaft.",
"Entry Sorting": "Sortierung der Artikel",
"Older entries first": "Älteste Artikel zuerst",
@@ -161,13 +161,16 @@ 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",
"Wallabag Client Secret": "Wallabag Client-Secret",
"Wallabag Username": "Wallabag Benutzername",
"Wallabag Password": "Wallabag Passwort",
"Save articles to Nunux Keeper": "Artikel in Nunux Keeper speichern",
"Nunux Keeper API Endpoint": "Nunux Keeper API-Endpunkt",
"Nunux Keeper API key": "Nunux Keeper API-Schlüssel",
"Keyboard Shortcut: %s": "Tastenkürzel: %s",
"Favorites": "Lesezeichen",
"Star": "Lesezeichen hinzufügen",
@@ -215,7 +218,14 @@ var translations = map[string]string{
"Fever API endpoint:": "Fever API Endpunkt:",
"Miniflux API": "Miniflux API",
"API Endpoint": "API Endpunkt",
"Your account password": "Ihr Konto Passwort"
"Your account password": "Ihr Konto Passwort",
"This web page is empty": "Diese Webseite ist leer",
"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",
"Comments": "Kommentare",
"View Comments": "Kommentare anzeigen"
}
`,
"en_US": `{
@@ -314,8 +324,8 @@ var translations = map[string]string{
"Last Login": "Dernière connexion",
"Yes": "Oui",
"No": "Non",
"This feed already exists (%s).": "Cet abonnement existe déjà (%s).",
"Unable to fetch feed (statusCode=%d).": "Impossible de récupérer cet abonnement (code=%d).",
"This feed already exists (%s)": "Cet abonnement existe déjà (%s)",
"Unable to fetch feed (statusCode=%d)": "Impossible de récupérer cet abonnement (code=%d)",
"Unable to open this link: %v": "Impossible d'ouvrir ce lien : %v",
"Unable to analyze this page: %v": "Impossible d'analyzer cette page : %v",
"Unable to find any subscription.": "Impossible de trouver un abonnement.",
@@ -327,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 :",
@@ -340,12 +350,12 @@ var translations = map[string]string{
"Unable to execute request: %v": "Impossible d'exécuter cette requête: %v",
"Last Parsing Error": "Dernière erreur d'analyse",
"There is a problem with this feed": "Il y a un problème avec cet abonnement",
"Unable to parse OPML file: %v.": "Impossible de lire ce fichier OPML : %v.",
"Unable to parse RSS feed: %v.": "Impossible de lire ce flux RSS: %v.",
"Unable to parse Atom feed: %v.": "Impossible de lire ce flux Atom: %v.",
"Unable to parse JSON feed: %v.": "Impossible de lire ce flux JSON: %v.",
"Unable to parse RDF feed: %v.": "Impossible de lire ce flux RDF: %v.",
"Unable to normalize encoding: %v.": "Impossible de normaliser l'encodage : %v.",
"Unable to parse OPML file: %q": "Impossible de lire ce fichier OPML : %q",
"Unable to parse RSS feed: %q": "Impossible de lire ce flux RSS : %q",
"Unable to parse Atom feed: %q": "Impossible de lire ce flux Atom : %q",
"Unable to parse JSON feed: %q": "Impossible de lire ce flux JSON : %q",
"Unable to parse RDF feed: %q": "Impossible de lire ce flux RDF : %q",
"Unable to normalize encoding: %q": "Impossible de normaliser l'encodage : %q",
"Unable to create this category.": "Impossible de créer cette catégorie.",
"yes": "oui",
"no": "non",
@@ -363,7 +373,7 @@ var translations = map[string]string{
"Sign in with Google": "Se connecter avec Google",
"Unlink my Google account": "Dissocier mon compte Google",
"Link my Google account": "Associer mon compte Google",
"Category not found for this user.": "Cette catégorie n'existe pas pour cet utilisateur.",
"Category not found for this user": "Cette catégorie n'existe pas pour cet utilisateur",
"Invalid theme.": "Le thème est invalide.",
"Entry Sorting": "Ordre des éléments",
"Older entries first": "Ancien éléments en premier",
@@ -385,13 +395,16 @@ 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",
"Wallabag Client Secret": "Clé secrète du client Wallabag",
"Wallabag Username": "Nom d'utilisateur de Wallabag",
"Wallabag Password": "Mot de passe de Wallabag",
"Save articles to Nunux Keeper": "Sauvegarder les articles vers Nunux Keeper",
"Nunux Keeper API Endpoint": "URL de l'API de Nunux Keeper",
"Nunux Keeper API key": "Clé d'API de Nunux Keeper",
"Keyboard Shortcut: %s": "Raccourci clavier : %s",
"Favorites": "Favoris",
"Star": "Favoris",
@@ -414,6 +427,7 @@ var translations = map[string]string{
"Items Navigation": "Naviguation entre les éléments",
"Go to previous item": "Élément précédent",
"Go to next item": "Élément suivant",
"Pages Navigation": "Naviguation entre les pages",
"Go to previous page": "Page précédente",
"Go to next page": "Page suivante",
"Open selected item": "Ouvrir élément sélectionné",
@@ -438,13 +452,700 @@ var translations = map[string]string{
"Fever API endpoint:": "Point de terminaison de l'API Fever :",
"Miniflux API": "API de Miniflux",
"API Endpoint": "Point de terminaison de l'API",
"Your account password": "Le mot de passe de votre compte"
"Your account password": "Le mot de passe de votre compte",
"This web page is empty": "Cette page web est vide",
"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",
"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.",
"Save articles to Pocket": "Sauvegarder les articles vers Pocket",
"Connect your Pocket account": "Connectez votre compte Pocket",
"Pocket Consumer Key": "« Pocket Consumer Key »",
"Pocket Access Token": "« Pocket Access Token »",
"Your Pocket account is now linked!": "Votre compte Pocket est maintenant connecté !",
"Unable to fetch access token from Pocket!": "Impossible de récupérer le jeton d'accès depuis Pocket !",
"Unable to fetch request token from Pocket!": "Impossible de récupérer le jeton d'accès depuis Pocket !"
}
`,
"nl_NL": `{
"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": `{
"plural.feed.error_count": [
"%d błąd",
"%d błąd",
"%d błędów"
],
"plural.categories.feed_count": [
"Jest %d kanał.",
"Są %d kanały.",
"Jest %d kanałów."
],
"Username": "Nazwa użytkownika",
"Password": "Hasło",
"Unread": "Nieprzeczytane",
"History": "Historia",
"Feeds": "Kanały",
"Categories": "Kategorie",
"Settings": "Ustawienia",
"Logout": "Wyloguj się",
"Next": "Następny",
"Previous": "Poprzedni",
"New Subscription": "Nowa subskrypcja",
"Import": "Importuj",
"Export": "Eksportuj",
"There is no category. You must have at least one category.": "Nie ma żadnej kategorii. Musisz mieć co najmniej jedną kategorię",
"URL": "URL",
"Category": "Kategoria",
"Find a subscription": "Znajdź subskrypcję",
"Loading...": "Ładowanie...",
"Create a category": "Utwórz kategorię",
"There is no category.": "Nie masz żadnej kategorii",
"Edit": "Edytuj",
"Remove": "Usuń",
"No feed.": "Brak kanałów.",
"There is no article in this category.": "W tej kategorii nie ma żadnych artykułów",
"Original": "Oryginalny artykuł",
"Mark this page as read": "Oznacz jako przeczytane",
"not yet": "jeszcze nie",
"just now": "przed chwilą",
"1 minute ago": "minutę temu",
"%d minutes ago": "%d minut temu",
"1 hour ago": "godzinę temu",
"%d hours ago": "%d godzin temu",
"yesterday": "wczoraj",
"%d days ago": "%d dni temu",
"%d weeks ago": "%d tygodni temu",
"%d months ago": "%d miesięcy temu",
"%d years ago": "%d lat temu",
"Date": "Data",
"IP Address": "Adres IP",
"User Agent": "User Agent",
"Actions": "Działania",
"Current session": "Bieżąca sesja",
"Sessions": "Sesje",
"Users": "Użytkownicy",
"Add user": "Dodaj użytkownika",
"Choose a Subscription": "Wybierz subskrypcję",
"Subscribe": "Subskrypcja",
"New Category": "Nowa kategoria",
"Title": "Tytuł",
"Save": "Zapisz",
"or": "lub",
"cancel": "anuluj",
"New User": "Nowy użytkownik",
"Confirmation": "Potwierdź",
"Administrator": "Administrator",
"Edit Category: %s": "Edycja Kategorii: %s",
"Update": "Zaktualizuj",
"Edit Feed: %s": "Edytuj kanał: %s",
"There is no category!": "Nie ma żadnej kategorii!",
"Edit user: %s": "Edytuj użytkownika: %s",
"There is no article for this feed.": "Nie ma artykułu dla tego kanału.",
"Add subscription": "Dodaj subskrypcję",
"You don't have any subscription.": "Nie masz żadnej subskrypcji",
"Last check:": "Ostatnia aktualizacja:",
"Refresh": "Odśwież",
"There is no history at the moment.": "Obecnie nie ma żadnej historii.",
"OPML file": "Plik OPML",
"Sign In": "Zaloguj się",
"Sign in": "Zaloguj się",
"Theme": "Wygląd",
"Timezone": "Strefa czasowa",
"Language": "Język",
"There is no unread article.": "Nie ma żadnych nieprzeczytanych artykułów.",
"You are the only user.": "Jesteś jedynym użytkownikiem.",
"Last Login": "Ostatnie logowanie",
"Yes": "Tak",
"No": "Nie",
"This feed already exists (%s)": "Ten kanał już istnieje (%s)",
"Unable to fetch feed (statusCode=%d)": "Kanał nie mógł zostać pobrany (kod=%d)",
"Unable to open this link: %v": "Nie można było otworzyć tego linku: %v",
"Unable to analyze this page: %v": "Nie można przeanalizować tej strony: %v",
"Unable to find any subscription.": "Nie znaleziono żadnych subskrypcji.",
"The URL and the category are mandatory.": "URL i kategoria są obowiązkowe.",
"All fields are mandatory.": "Wszystkie pola są obowiązkowe.",
"Passwords are not the same.": "Hasła nie są identyczne.",
"You must use at least 6 characters.": "Musisz użyć co najmniej 6 znaków.",
"The username is mandatory.": "Nazwa użytkownika jest obowiązkowa.",
"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 :",
"Build Date:": "Data opracowania:",
"Author:": "Autor:",
"Authors": "Autorzy",
"License:": "Licencja:",
"Attachments": "Załączniki",
"Download": "Pobierz",
"Invalid username or password.": "Nieprawidłowa nazwa użytkownika lub hasło.",
"Never": "Nigdy",
"Unable to execute request: %v": "To polecenie nie mogło zostać wykonane: %v",
"Last Parsing Error": "Ostatni błąd analizy",
"There is a problem with this feed": "Z tym kanałem jest problem",
"Unable to parse OPML file: %q": "Plik OPML nie mógł zostać odczytany: %q",
"Unable to parse RSS feed: %q": "Nie można było odczytać kanału RSS: %q",
"Unable to parse Atom feed: %q": "Nie można było odczytać kanału Atom: %q",
"Unable to parse JSON feed: %q": "Nie można było odczytać kanału JSON: %q",
"Unable to parse RDF feed: %q": "Nie można było odczytać kanału RDF: %q",
"Unable to normalize encoding: %q": "Kodowanie znaków nie mogło zostać znormalizowane: %q",
"Unable to create this category.": "Ta kategoria nie mogła zostać utworzona.",
"yes": "tak",
"no": "nie",
"Are you sure?": "Czy jesteś pewny?",
"Work in progress...": "W toku...",
"This user already exists.": "Ten użytkownik już istnieje.",
"This category already exists.": "Ta kategoria już istnieje.",
"Unable to update this category.": "Ta kategoria nie mogła zostać zaktualizowana.",
"Integrations": "Usługi",
"Bookmarklet": "Bookmarklet",
"Drag and drop this link to your bookmarks.": "Przeciągnij i upuść to łącze do zakładek.",
"This special link allows you to subscribe to a website directly by using a bookmark in your web browser.": "Ten link umożliwia subskrypcję strony internetowej bezpośrednio za pomocą zakładki w przeglądarce internetowej",
"Add to Miniflux": "Dodaj do Miniflux",
"Refresh all feeds in background": "Odśwież wszystkie subskrypcje w tle",
"Sign in with Google": "Zaloguj przez Google",
"Unlink my Google account": "Odłącz moje konto Google",
"Link my Google account": "Połącz z moim kontem Google",
"Category not found for this user": "Kategoria nie znaleziona dla tego użytkownika",
"Invalid theme.": "Ten temat jest nieprawidłowy.",
"Entry Sorting": "Sortowanie artykułów",
"Older entries first": "Najstarsze wpisy jako pierwsze",
"Recent entries first": "Najnowsze wpisy jako pierwsze",
"Saving...": "Zapisywanie...",
"Done!": "Gotowe!",
"Save this article": "Zapisz ten artykuł",
"Mark bookmark as unread": "Zaznacz zakładkę jako nieprzeczytaną",
"Pinboard Tags": "Pinboard Tags",
"Pinboard API Token": "Token Pinboard API",
"Save articles to Pinboard": "Zapisz artykuł w Pinboard",
"Save articles to Instapaper": "Zapisz artykuł w Instapaper",
"Instapaper Username": "Login do Instapaper",
"Instapaper Password": "Hasło do Instapaper",
"Activate Fever API": "Aktywuj Fever API",
"Fever Username": "Login do Fever",
"Fever Password": "Hasło do Fever",
"Fetch original content": "Pobierz oryginalną treść",
"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!",
"Save articles to Wallabag": "Zapisz artykuły do Wallabag",
"Wallabag API Endpoint": "Wallabag URL",
"Wallabag Client ID": "Wallabag Client-ID",
"Wallabag Client Secret": "Wallabag Client Secret",
"Wallabag Username": "Login do Wallabag",
"Wallabag Password": "Hasło do Wallabag",
"Save articles to Nunux Keeper": "Zapisz artykuly do Nunux Keeper",
"Nunux Keeper API Endpoint": "Nunux Keeper URL",
"Nunux Keeper API key": "Nunux Keeper API key",
"Keyboard Shortcut: %s": "Skróty klawiszowe: %s",
"Favorites": "Ulubione",
"Star": "Oznacz gwiazdką",
"Unstar": "Usuń gwiazdkę",
"Starred": "Oznaczone gwiazdką",
"There is no bookmark at the moment.": "Obecnie nie ma żadnych zakładek.",
"Last checked:": "Ostatnio sprawdzone:",
"ETag header:": "Nagłówek ETag:",
"LastModified header:": "Ostatnio zmienione:",
"None": "Brak",
"Keyboard Shortcuts": "Skróty klawiszowe",
"Sections Navigation": "Nawigacja między punktami menu",
"Go to unread": "Przejdź do nieprzeczytanych artykułów",
"Go to bookmarks": "Przejdź do zakładek",
"Go to history": "Przejdź do historii",
"Go to feeds": "Przejdź do kanałów",
"Go to categories": "Przejdź do kategorii",
"Go to settings": "Przejdź do ustawień",
"Show keyboard shortcuts": "Pokaż listę skrótów klawiszowych",
"Items Navigation": "Nawigacja między artykułami",
"Go to previous item": "Przejdź do poprzedniego artykułu",
"Go to next item": "Przejdź do następnego punktu artykułu",
"Pages Navigation": "Nawigacja między stronami",
"Go to previous page": "Przejdź do poprzedniej strony",
"Go to next page": "Przejdź do następnej strony",
"Open selected item": "Otwórz zaznaczony artykuł",
"Open original link": "Otwórz oryginalny artykuł",
"Toggle read/unread": "Oznacz jako przeczytane/nieprzeczytane",
"Mark current page as read": "Zaznacz aktualną stronę jako przeczytaną",
"Download original content": "Pobierz oryginalną zawartość",
"Toggle bookmark": "Dodaj/usuń zakładki",
"Close modal dialog": "Zamknij listę skrótów klawiszowych",
"Save article": "Zapisz artykuł",
"There is already someone associated with this provider!": "Już ktoś jest powiązany z tym dostawcą!",
"There is already someone else with the same Fever username!": "Już ktoś inny używa tej nazwy użytkownika Fever!",
"Mark all as read": "Oznacz wszystko jako przeczytane",
"This feed is empty": "Ten kanał jest pusty",
"Flush history": "Usuń historię",
"Site URL": "URL strony",
"Feed URL": "URL kanału",
"Logged as %s": "Zalogowany jako %s",
"Unread Items": "Nieprzeczytane",
"Change entry status": "Zmień status artykułu",
"Read": "Przeczytane",
"Fever API endpoint:": "Fever API endpoint:",
"Miniflux API": "Miniflux API",
"API Endpoint": "API endpoint",
"Your account password": "Hasło konta",
"This web page is empty": "Ta strona jest pusta",
"Invalid SSL certificate (original error: %q)": "Certyfikat SSL jest nieprawidłowy (błąd: %q)",
"This website is temporarily unreachable (original error: %q)": "Ta strona jest tymczasowo niedostępna (błąd: %q)",
"This website is permanently unreachable (original error: %q)": "Ta strona jest niedostępna (błąd: %q)",
"Website unreachable, the request timed out after %d seconds": "Strona internetowa nieosiągalna, żądanie wygasło po %d sekundach"
}
`,
"zh_CN": `{
"plural.feed.error_count": [
"%d 错误",
"%d 错误"
],
"plural.categories.feed_count": [
"有 %d 个源.",
"有 %d 个源."
],
"Username": "用户名",
"Password": "密码",
"Unread": "未读",
"History": "历史",
"Feeds": "源",
"Categories": "分类",
"Settings": "设置",
"Logout": "登出",
"Next": "下一页",
"Previous": "上一页",
"New Subscription": "新订阅",
"Import": "导入",
"Export": "导出",
"There is no category. You must have at least one category.": "目前没有分类.需要有一个已有的分类存在.",
"URL": "URL",
"Category": "分类",
"Find a subscription": "寻找订阅",
"Loading...": "载入中...",
"Create a category": "新建分类",
"There is no category.": "目前没有分类.",
"Edit": "编辑",
"Remove": "删除",
"No feed.": "没有源.",
"There is no article in this category.": "该分类下没有文章.",
"Original": "原始链接",
"Mark this page as read": "标记为已读",
"not yet": "尚未",
"just now": "刚刚",
"1 minute ago": "1 分钟前",
"%d minutes ago": "%d 分钟前",
"1 hour ago": "1 小时前",
"%d hours ago": "%d 小时前",
"yesterday": "昨天",
"%d days ago": "%d 天前",
"%d weeks ago": "%d 周前",
"%d months ago": "%d 月前",
"%d years ago": "%d 年前",
"Date": "日期",
"IP Address": "IP地址",
"User Agent": "User Agent",
"Actions": "操作",
"Current session": "当前会话",
"Sessions": "会话",
"Users": "用户",
"Add user": "新建用户",
"Choose a Subscription": "选择一个订阅",
"Subscribe": "订阅",
"New Category": "新分类",
"Title": "标题",
"Save": "保存",
"or": "或",
"cancel": "取消",
"New User": "新用户",
"Confirmation": "确认",
"Administrator": "管理员",
"Edit Category: %s": "编辑分类 : %s",
"Update": "更新",
"Edit Feed: %s": "编辑源 : %s",
"There is no category!": "没有分类!",
"Edit user: %s": "编辑用户: %s",
"There is no article for this feed.": "这个源中没有文章.",
"Add subscription": "新增订阅",
"You don't have any subscription.": "当前没有订阅",
"Last check:": "最后检查时间:",
"Refresh": "更新",
"There is no history at the moment.": "当前没有历史.",
"OPML file": "OPML 文件",
"Sign In": "登陆",
"Sign in": "登陆",
"Theme": "主题",
"Timezone": "时区",
"Language": "语言",
"There is no unread article.": "目前没有未读文章.",
"You are the only user.": "你是目前仅有的用户.",
"Last Login": "最后登录时间",
"Yes": "是",
"No": "否",
"This feed already exists (%s)": "源已存在 (%s)",
"Unable to fetch feed (statusCode=%d)": "无法获取源 (错误代码=%d)",
"Unable to open this link: %v": "无法打开这一链接: %v",
"Unable to analyze this page: %v": "无法分析这一页面: %v",
"Unable to find any subscription.": "找不到任何订阅.",
"The URL and the category are mandatory.": "必须填写URL和分类.",
"All fields are mandatory.": "必须填写全部信息.",
"Passwords are not the same.": "两次输入的密码不同.",
"You must use at least 6 characters.": "请至少使用6个字符.",
"The username is mandatory.": "必须填写用户名.",
"The username, theme, language and timezone fields are mandatory.": "必须填写用户名,主题,语言和时区.",
"The title is mandatory.": "必须填写标题.",
"About": "关于",
"Version": "版本",
"Version:": "版本:",
"Build Date:": "构建日期:",
"Author:": "作者:",
"Authors": "作者",
"License:": "协议:",
"Attachments": "附件",
"Download": "下载",
"Invalid username or password.": "用户名或密码无效.",
"Never": "永不",
"Unable to execute request: %v": "无法执行这一请求: %v",
"Last Parsing Error": "最后一次解析错误",
"There is a problem with this feed": "这一源存在问题",
"Unable to parse OPML file: %q": "无法解析OPML文件: %q",
"Unable to parse RSS feed: %q": "无法解析RSS源: %q",
"Unable to parse Atom feed: %q": "无法解析Atom源: %q",
"Unable to parse JSON feed: %q": "无法解析JSON源: %q",
"Unable to parse RDF feed: %q": "无法解析RDF源: %q",
"Unable to normalize encoding: %q": "无法正则化编码: %q",
"Unable to create this category.": "无法建立这个分类.",
"yes": "是",
"no": "否",
"Are you sure?": "您确认吗?",
"Work in progress...": "执行中...",
"This user already exists.": "用户已存在.",
"This category already exists.": "分类已存在.",
"Unable to update this category.": "无法更新该分类.",
"Integrations": "集成",
"Bookmarklet": "书签小应用",
"Drag and drop this link to your bookmarks.": "拖动这个链接到书签.",
"This special link allows you to subscribe to a website directly by using a bookmark in your web browser.": "你可以打开这个特殊的书签来直接订阅网站.",
"Add to Miniflux": "新增到Miniflux",
"Refresh all feeds in background": "在后台更新全部源",
"Sign in with Google": "使用Google登陆",
"Unlink my Google account": "去除Google账号关联",
"Link my Google account": "关联我的Google账户",
"Category not found for this user": "未找到该用户的这一分类",
"Invalid theme.": "无效的主题.",
"Entry Sorting": "内容排序",
"Older entries first": "旧->新",
"Recent entries first": "新->旧",
"Saving...": "保存中",
"Done!": "完成!",
"Save this article": "保存这篇文章",
"Mark bookmark as unread": "标记为未读",
"Pinboard Tags": "Pinboard 标签",
"Pinboard API Token": "Pinboard API Token",
"Save articles to Pinboard": "保存文章到Pinboard",
"Save articles to Instapaper": "保存文章到Instapaper",
"Instapaper Username": "Instapaper 用户名",
"Instapaper Password": "Instapaper 密码",
"Activate Fever API": "启用 Fever API",
"Fever Username": "Fever 用户名",
"Fever Password": "Fever 密码",
"Fetch original content": "抓取原内容",
"Scraper Rules": "Scraper规则",
"Rewrite Rules": "重写规则",
"Preferences saved!": "偏好已存储!",
"Your external account is now linked!": "您的外部账号已关联!",
"Save articles to Wallabag": "保存文章到Wallabag",
"Wallabag API Endpoint": "Wallabag URL",
"Wallabag Client ID": "Wallabag 客户端ID",
"Wallabag Client Secret": "Wallabag 客户端Secret",
"Wallabag Username": "Wallabag 用户名",
"Wallabag Password": "Wallabag 密码",
"Save articles to Nunux Keeper": "保存文章到Nunux Keeper",
"Nunux Keeper API Endpoint": "Nunux Keeper API Endpoint",
"Nunux Keeper API key": "Nunux Keeper API key",
"Keyboard Shortcut: %s": "快捷键: %s",
"Favorites": "收藏",
"Star": "标记星标",
"Unstar": "去掉星标",
"Starred": "星标",
"There is no bookmark at the moment.": "当前没有书签.",
"Last checked:": "上次检查:",
"ETag header:": "ETag header:",
"LastModified header:": "最后修改的Header:",
"None": "无",
"Keyboard Shortcuts": "快捷键",
"Sections Navigation": "分区导航",
"Go to unread": "去往未读",
"Go to bookmarks": "去往书签",
"Go to history": "去往历史",
"Go to feeds": "去往源",
"Go to categories": "去往分类",
"Go to settings": "去往设定",
"Show keyboard shortcuts": "显示快捷键",
"Items Navigation": "条目导航",
"Go to previous item": "上一条目",
"Go to next item": "下一条目",
"Pages Navigation": "页面导航",
"Go to previous page": "上一页",
"Go to next page": "下一页",
"Open selected item": "打开选定的条目",
"Open original link": "打开原始链接",
"Toggle read/unread": "切换已读/未读状态",
"Mark current page as read": "标记当前",
"Download original content": "下载原始内容",
"Toggle bookmark": "切换收藏状态",
"Close modal dialog": "关闭模态对话窗口",
"Save article": "保存文章",
"There is already someone associated with this provider!": "该Provider已被关联!",
"There is already someone else with the same Fever username!": "Fever用户名已被占用!",
"Mark all as read": "全标记为已读",
"This feed is empty": "该源是空的",
"Flush history": "清理历史",
"Site URL": "站点URL",
"Feed URL": "源URL",
"Logged as %s": "当前登录 %s",
"Unread Items": "未读条目",
"Change entry status": "更改状态",
"Read": "标为已读",
"Fever API endpoint:": "Fever API Endpoint:",
"Miniflux API": "Miniflux API",
"API Endpoint": "API Endpoint",
"Your account password": "您账户的密码",
"This web page is empty": "该网页是空的",
"Invalid SSL certificate (original error: %q)": "无效的SSL证书 (原始错误: %q)",
"This website is temporarily unreachable (original error: %q)": "该网站暂时不可达 (原始错误: %q)",
"This website is permanently unreachable (original error: %q)": "该网站永久不可达 (原始错误: %q)",
"Website unreachable, the request timed out after %d seconds": "网站不可达, 请求已在 %d 秒后超时"
}
`,
}
var translationsChecksums = map[string]string{
"de_DE": "f4a66bffedb7bf99294281da5a1fa78901509db67a9fba15a160b656feb1425a",
"de_DE": "791d72c96137ab03b729017bdfa27c8eed2f65912e372fcb5b2796d5099d5498",
"en_US": "6fe95384260941e8a5a3c695a655a932e0a8a6a572c1e45cb2b1ae8baa01b897",
"fr_FR": "e6305fd54508a4f54d630e3ef231a8eadc2335ed0ffac7b5266b2ec751824e28",
"fr_FR": "808f1561135bb3d0f886833f00a51402113925e9086c01f71b30bcc679d9b7c2",
"nl_NL": "1a73f1dd1c4c0d2c2adc8695cdd050c2dad81c14876caed3892b44adc2491265",
"pl_PL": "da709c14ff71f3b516eec66cb2758d89c5feab1472c94b2b518f425162a9f806",
"zh_CN": "d80594c1b67d15e9f4673d3d62fe4949e8606a5fdfb741d8a8921f21dceb8cf2",
}
+25 -15
View File
@@ -1,7 +1,7 @@
{
"plural.feed.error_count": [
"%d Error",
"%d Errors"
"%d Fehler",
"%d Fehler"
],
"plural.categories.feed_count": [
"Es gibt %d Abonnement.",
@@ -84,8 +84,8 @@
"Last Login": "Letzte Anmeldung",
"Yes": "Ja",
"No": "Nein",
"This feed already exists (%s).": "Diese Abonnement existiert bereits (%s).",
"Unable to fetch feed (statusCode=%d).": "Abonnement konnte nicht abgerufen werden (code=%d).",
"This feed already exists (%s)": "Diese Abonnement existiert bereits (%s)",
"Unable to fetch feed (statusCode=%d)": "Abonnement konnte nicht abgerufen werden (code=%d)",
"Unable to open this link: %v": "Dieser Link konnte nicht geöffnet werden: %v",
"Unable to analyze this page: %v": "Diese Seite konnte nicht analysiert werden: %v",
"Unable to find any subscription.": "Es wurden keine Abonnements gefunden.",
@@ -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:",
@@ -108,14 +108,14 @@
"Invalid username or password.": "Benutzername oder Passwort ungültig.",
"Never": "Niemals",
"Unable to execute request: %v": "Diese Anfrage konnte nicht ausgeführt werden: %v",
"Last Parsing Error": "Letzter Analyse Error",
"Last Parsing Error": "Letzter Analysefehler",
"There is a problem with this feed": "Es gibt ein Problem mit diesem Abonnement",
"Unable to parse OPML file: %v.": "OPML Datei konnte nicht gelesen werden: %v.",
"Unable to parse RSS feed: %v.": "RSS Feed konnte nicht gelesen werden: %v.",
"Unable to parse Atom feed: %v.": "Atom Feed konnte nicht gelesen werden: %v.",
"Unable to parse JSON feed: %v.": "JSON Feed konnte nicht gelesen werden: %v.",
"Unable to parse RDF feed: %v.": "RDF Feed konnte nicht gelesen werden: %v.",
"Unable to normalize encoding: %v.": "Zeichenkodierung konnte nicht normalisiert werden: %v.",
"Unable to parse OPML file: %q": "OPML Datei konnte nicht gelesen werden: %q",
"Unable to parse RSS feed: %q": "RSS Feed konnte nicht gelesen werden: %q",
"Unable to parse Atom feed: %q": "Atom Feed konnte nicht gelesen werden: %q",
"Unable to parse JSON feed: %q": "JSON Feed konnte nicht gelesen werden: %q",
"Unable to parse RDF feed: %q": "RDF Feed konnte nicht gelesen werden: %q",
"Unable to normalize encoding: %q": "Zeichenkodierung konnte nicht normalisiert werden: %q",
"Unable to create this category.": "Diese Kategorie konnte nicht angelegt werden.",
"yes": "ja",
"no": "nein",
@@ -133,7 +133,7 @@
"Sign in with Google": "Anmeldung mit Google",
"Unlink my Google account": "Google Konto abmelden",
"Link my Google account": "Google Konto verknüpfen",
"Category not found for this user.": "Diese Kategorie existiert nicht für diesen Benutzer.",
"Category not found for this user": "Diese Kategorie existiert nicht für diesen Benutzer",
"Invalid theme.": "Dieses Thema ist fehlerhaft.",
"Entry Sorting": "Sortierung der Artikel",
"Older entries first": "Älteste Artikel zuerst",
@@ -155,13 +155,16 @@
"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",
"Wallabag Client Secret": "Wallabag Client-Secret",
"Wallabag Username": "Wallabag Benutzername",
"Wallabag Password": "Wallabag Passwort",
"Save articles to Nunux Keeper": "Artikel in Nunux Keeper speichern",
"Nunux Keeper API Endpoint": "Nunux Keeper API-Endpunkt",
"Nunux Keeper API key": "Nunux Keeper API-Schlüssel",
"Keyboard Shortcut: %s": "Tastenkürzel: %s",
"Favorites": "Lesezeichen",
"Star": "Lesezeichen hinzufügen",
@@ -209,5 +212,12 @@
"Fever API endpoint:": "Fever API Endpunkt:",
"Miniflux API": "Miniflux API",
"API Endpoint": "API Endpunkt",
"Your account password": "Ihr Konto Passwort"
"Your account password": "Ihr Konto Passwort",
"This web page is empty": "Diese Webseite ist leer",
"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",
"Comments": "Kommentare",
"View Comments": "Kommentare anzeigen"
}
+33 -12
View File
@@ -84,8 +84,8 @@
"Last Login": "Dernière connexion",
"Yes": "Oui",
"No": "Non",
"This feed already exists (%s).": "Cet abonnement existe déjà (%s).",
"Unable to fetch feed (statusCode=%d).": "Impossible de récupérer cet abonnement (code=%d).",
"This feed already exists (%s)": "Cet abonnement existe déjà (%s)",
"Unable to fetch feed (statusCode=%d)": "Impossible de récupérer cet abonnement (code=%d)",
"Unable to open this link: %v": "Impossible d'ouvrir ce lien : %v",
"Unable to analyze this page: %v": "Impossible d'analyzer cette page : %v",
"Unable to find any subscription.": "Impossible de trouver un abonnement.",
@@ -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 :",
@@ -110,12 +110,12 @@
"Unable to execute request: %v": "Impossible d'exécuter cette requête: %v",
"Last Parsing Error": "Dernière erreur d'analyse",
"There is a problem with this feed": "Il y a un problème avec cet abonnement",
"Unable to parse OPML file: %v.": "Impossible de lire ce fichier OPML : %v.",
"Unable to parse RSS feed: %v.": "Impossible de lire ce flux RSS: %v.",
"Unable to parse Atom feed: %v.": "Impossible de lire ce flux Atom: %v.",
"Unable to parse JSON feed: %v.": "Impossible de lire ce flux JSON: %v.",
"Unable to parse RDF feed: %v.": "Impossible de lire ce flux RDF: %v.",
"Unable to normalize encoding: %v.": "Impossible de normaliser l'encodage : %v.",
"Unable to parse OPML file: %q": "Impossible de lire ce fichier OPML : %q",
"Unable to parse RSS feed: %q": "Impossible de lire ce flux RSS : %q",
"Unable to parse Atom feed: %q": "Impossible de lire ce flux Atom : %q",
"Unable to parse JSON feed: %q": "Impossible de lire ce flux JSON : %q",
"Unable to parse RDF feed: %q": "Impossible de lire ce flux RDF : %q",
"Unable to normalize encoding: %q": "Impossible de normaliser l'encodage : %q",
"Unable to create this category.": "Impossible de créer cette catégorie.",
"yes": "oui",
"no": "non",
@@ -133,7 +133,7 @@
"Sign in with Google": "Se connecter avec Google",
"Unlink my Google account": "Dissocier mon compte Google",
"Link my Google account": "Associer mon compte Google",
"Category not found for this user.": "Cette catégorie n'existe pas pour cet utilisateur.",
"Category not found for this user": "Cette catégorie n'existe pas pour cet utilisateur",
"Invalid theme.": "Le thème est invalide.",
"Entry Sorting": "Ordre des éléments",
"Older entries first": "Ancien éléments en premier",
@@ -155,13 +155,16 @@
"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",
"Wallabag Client Secret": "Clé secrète du client Wallabag",
"Wallabag Username": "Nom d'utilisateur de Wallabag",
"Wallabag Password": "Mot de passe de Wallabag",
"Save articles to Nunux Keeper": "Sauvegarder les articles vers Nunux Keeper",
"Nunux Keeper API Endpoint": "URL de l'API de Nunux Keeper",
"Nunux Keeper API key": "Clé d'API de Nunux Keeper",
"Keyboard Shortcut: %s": "Raccourci clavier : %s",
"Favorites": "Favoris",
"Star": "Favoris",
@@ -184,6 +187,7 @@
"Items Navigation": "Naviguation entre les éléments",
"Go to previous item": "Élément précédent",
"Go to next item": "Élément suivant",
"Pages Navigation": "Naviguation entre les pages",
"Go to previous page": "Page précédente",
"Go to next page": "Page suivante",
"Open selected item": "Ouvrir élément sélectionné",
@@ -208,5 +212,22 @@
"Fever API endpoint:": "Point de terminaison de l'API Fever :",
"Miniflux API": "API de Miniflux",
"API Endpoint": "Point de terminaison de l'API",
"Your account password": "Le mot de passe de votre compte"
"Your account password": "Le mot de passe de votre compte",
"This web page is empty": "Cette page web est vide",
"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",
"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.",
"Save articles to Pocket": "Sauvegarder les articles vers Pocket",
"Connect your Pocket account": "Connectez votre compte Pocket",
"Pocket Consumer Key": "« Pocket Consumer Key »",
"Pocket Access Token": "« Pocket Access Token »",
"Your Pocket account is now linked!": "Votre compte Pocket est maintenant connecté !",
"Unable to fetch access token from Pocket!": "Impossible de récupérer le jeton d'accès depuis Pocket !",
"Unable to fetch request token from Pocket!": "Impossible de récupérer le jeton d'accès depuis Pocket !"
}
+220
View File
@@ -0,0 +1,220 @@
{
"plural.feed.error_count": [
"%d error",
"%d errors"
],
"plural.categories.feed_count": [
"Er is %d feed.",
"Er zijn %d feeds."
],
"Username": "Gebruikersnaam",
"Password": "Wachtwoord",
"Unread": "Ongelezen",
"History": "Geschiedenis",
"Feeds": "Feeds",
"Categories": "Categorieën",
"Settings": "Instellingen",
"Logout": "Uitloggen",
"Next": "Volgende",
"Previous": "Vorige",
"New Subscription": "Nieuwe feed",
"Import": "Importeren",
"Export": "Exporteren",
"There is no category. You must have at least one category.": "Er zijn geen categorieën. Je moet op zijn minst één caterogie hebben.",
"URL": "URL",
"Category": "Categorie",
"Find a subscription": "Feed zoeken",
"Loading...": "Laden...",
"Create a category": "Categorie toevoegen",
"There is no category.": "Er zijn geen categorieën.",
"Edit": "Bewerken",
"Remove": "Verwijderen",
"No feed.": "Geen feeds.",
"There is no article in this category.": "Deze categorie bevat geen feeds.",
"Original": "Origineel",
"Mark this page as read": "Markeer deze pagina als gelezen",
"not yet": "in de toekomst",
"just now": "minder dan een minuut geleden",
"1 minute ago": "een minuut geleden",
"%d minutes ago": "%d minuten geleden",
"1 hour ago": "een uur geleden",
"%d hours ago": "%d uur geleden",
"yesterday": "gisteren",
"%d days ago": "%d dagen geleden",
"%d weeks ago": "%d weken geleden",
"%d months ago": "%d maanden geleden",
"%d years ago": "%d jaar geleden",
"Date": "Datum",
"IP Address": "IP-adres",
"User Agent": "User-agent",
"Actions": "Acties",
"Current session": "Huidige sessie",
"Sessions": "Sessies",
"Users": "Gebruikers",
"Add user": "Gebruiker toevoegen",
"Choose a Subscription": "Feed kiezen",
"Subscribe": "Abboneren",
"New Category": "Nieuwe categorie",
"Title": "Naam",
"Save": "Opslaan",
"or": "of",
"cancel": "annuleren",
"New User": "Nieuwe gebruiker",
"Confirmation": "Bevestig wachtwoord",
"Administrator": "Administrator",
"Edit Category: %s": "Bewerken van categorie: %s",
"Update": "Updaten",
"Edit Feed: %s": "Bewerken van feed: %s",
"There is no category!": "Er zijn geen categorieën!",
"Edit user: %s": "Gebruiker aanpassen: %s",
"There is no article for this feed.": "Er zijn geen artikelen in deze feed.",
"Add subscription": "Feed toevoegen",
"You don't have any subscription.": "Je hebt nog geen feeds geabboneerd staan.",
"Last check:": "Laatste update:",
"Refresh": "Vernieuwen",
"There is no history at the moment.": "Geschiedenis is op dit moment leeg.",
"OPML file": "OPML-bestand",
"Sign In": "Inloggen",
"Theme": "Skin",
"Timezone": "Tijdzone",
"Language": "Taal",
"There is no unread article.": "Er zijn geen ongelezen artikelen.",
"You are the only user.": "Je bent de enige gebruiker.",
"Last Login": "Laatste login",
"Yes": "Ja",
"No": "Nee",
"This feed already exists (%s)": "Deze feed bestaat al (%s)",
"Unable to fetch feed (statusCode=%d)": "Kon feed niet update (code=%d)",
"Unable to open this link: %v": "Kon link niet volgen: %v",
"Unable to analyze this page: %v": "Kon pagina niet analyseren: %v",
"Unable to find any subscription.": "Kon geen feeds vinden.",
"The URL and the category are mandatory.": "The URL en de categorie zijn verplicht.",
"All fields are mandatory.": "Alle velden moeten ingevuld zijn.",
"Passwords are not the same.": "Wachtwoorden zijn niet hetzelfde.",
"You must use at least 6 characters.": "Je moet minstens 6 tekens gebruiken.",
"The username is mandatory.": "Gebruikersnaam is verplicht",
"The username, theme, language and timezone fields are mandatory.": "Gebruikersnaam, skin, taal en tijdzone zijn verplicht.",
"The title is mandatory.": "Naam van categorie is verplicht.",
"About": "Over Miniflux",
"Version": "Versie",
"Version:": "Versie:",
"Build Date:": "Datum build:",
"Author:": "Auteur:",
"Authors": "Auteurs",
"License:": "Licentie:",
"Attachments": "Bijlages",
"Download": "Download",
"Invalid username or password.": "Onjuiste gebruikersnaam of wachtwoord.",
"Never": "Nooit",
"Unable to execute request: %v": "Kon request niet uitvoeren: %v",
"Last Parsing Error": "Laatste parse error",
"There is a problem with this feed": "Er is een probleem met deze feed",
"Unable to parse OPML file: %q": "Kon OPML niet parsen: %q",
"Unable to parse RSS feed: %q": "Kon RSS-feed niet parsen: %q",
"Unable to parse Atom feed: %q": "Kon Atom-feed niet parsen: %q",
"Unable to parse JSON feed: %q": "Kon JSON-feed niet parsen: %q",
"Unable to parse RDF feed: %q": "Kon RDF-feed niet parsen: %q",
"Unable to normalize encoding: %q": "Kon encoding niet normaliseren: %q",
"Unable to create this category.": "Kon categorie niet aanmaken.",
"yes": "ja",
"no": "nee",
"Are you sure?": "Weet je het zeker?",
"Work in progress...": "Bezig...",
"This user already exists.": "Deze gebruiker bestaat al.",
"This category already exists.": "Deze categorie bestaat al.",
"Unable to update this category.": "Kon categorie niet updaten.",
"Integrations": "Integraties",
"Bookmarklet": "Bookmarklet",
"Drag and drop this link to your bookmarks.": "Sleep deze link naar je bookmarks.",
"This special link allows you to subscribe to a website directly by using a bookmark in your web browser.": "Gebruik deze link als bookmark in je browser om je direct te abboneren op een website.",
"Add to Miniflux": "Toevoegen aan Miniflux",
"Refresh all feeds in background": "Vernieuw alle feeds in de achtergrond",
"Sign in with Google": "Inloggen via Google",
"Unlink my Google account": "Ontkoppel mijn Google-account",
"Link my Google account": "Koppel mijn Google-account",
"Category not found for this user": "Categorie niet gevonden voor deze gebruiker",
"Invalid theme.": "Ongeldige skin.",
"Entry Sorting": "Volgorde van items",
"Older entries first": "Oudere items eerst",
"Recent entries first": "Recente items eerst",
"Saving...": "Opslaag...",
"Done!": "Klaar!",
"Save this article": "Artikel opslaan",
"Mark bookmark as unread": "Markeer bookmark als gelezen",
"Pinboard Tags": "Pinboard tags",
"Pinboard API Token": "Pinboard API token",
"Save articles to Pinboard": "Artikelen opslaan naar Pinboard",
"Save articles to Instapaper": "Artikelen opstaan naar Instapaper",
"Instapaper Username": "Instapaper gebruikersnaam",
"Instapaper Password": "Instapaper wachtwoord",
"Activate Fever API": "Activeer Fever API",
"Fever Username": "Fever gebruikersnaam",
"Fever Password": "Fever wachtwoord",
"Fetch original content": "Download originele content",
"Scraper Rules": "Scraper regels",
"Rewrite Rules": "Rewrite regels",
"Preferences saved!": "Instellingen opgeslagen!",
"Your external account is now linked!": "Jouw externe account is nu gekoppeld!",
"Save articles to Wallabag": "Sauvegarder les articles vers Wallabag",
"Wallabag API Endpoint": "Wallabag URL",
"Wallabag Client ID": "Wallabag Client-ID",
"Wallabag Client Secret": "Wallabag Client-Secret",
"Wallabag Username": "Wallabag gebruikersnaam",
"Wallabag Password": "Wallabag wachtwoord",
"Save articles to Nunux Keeper": "Opslaan naar Nunux Keeper",
"Nunux Keeper API Endpoint": "Nunux Keeper URL",
"Nunux Keeper API key": "Nunux Keeper API-sleutel",
"Keyboard Shortcut: %s": "Sneltoets: %s",
"Favorites": "Favorieten",
"Star": "Ster toevoegen",
"Unstar": "Ster weghalen",
"Starred": "Favorieten",
"There is no bookmark at the moment.": "Er zijn op dit moment geen favorieten.",
"Last checked:": "Laatste update:",
"ETag header:": "ETAG-header:",
"LastModified header:": "LastModified-header:",
"None": "Geen",
"Keyboard Shortcuts": "Sneltoetsen",
"Sections Navigation": "Naviguatie tussen menu's",
"Go to unread": "Ga naar ongelezen",
"Go to bookmarks": "Ga naar favorieten",
"Go to history": "Ga naar geschiedenis",
"Go to feeds": "Ga naar feeds",
"Go to categories": "Ga naar categorieën",
"Go to settings": "Ga naar instellingen",
"Show keyboard shortcuts": "Laat sneltoetsen zien",
"Items Navigation": "Navigatie tussen items",
"Go to previous item": "Vorige item",
"Go to next item": "Volgende item",
"Pages Navigation": "Naviguatie tussen pagina's",
"Go to previous page": "Vorige pagina",
"Go to next page": "Volgende pagina",
"Open selected item": "Open geselecteerde link",
"Open original link": "Open originele link",
"Toggle read/unread": "Markeer gelezen/ongelezen",
"Mark current page as read": "Markeer deze pagina als gelezen",
"Download original content": "Download originele content",
"Toggle bookmark": "Ster toevoegen/weghalen",
"Close modal dialog": "Sluit dialoogscherm",
"Save article": "Artikel opslaan",
"There is already someone associated with this provider!": "Er is al iemand geregistreerd met deze provider!",
"There is already someone else with the same Fever username!": "Er is al iemand met dezelfde Fever gebruikersnaam!",
"Mark all as read": "Markeer alle items als gelezen",
"This feed is empty": "Deze feed is leeg",
"Flush history": "Verwijder geschiedenis",
"Site URL": "Website URL",
"Feed URL": "Feed URL",
"Logged as %s": "Ingelogd als %s",
"Unread Items": "Ongelezen items",
"Change entry status": "Verander status van item",
"Read": "Gelezen",
"Fever API endpoint:": "Fever URL:",
"Miniflux API": "Miniflux API",
"API Endpoint": "API-URL",
"Your account password": "Wachtwoord van jouw account",
"This web page is empty": "Deze webpagina is leeg",
"Invalid SSL certificate (original error: %q)": "Ongeldig SSL-certificaat (originele error: %q)",
"This website is temporarily unreachable (original error: %q)": "Deze website is tijdelijk onbereikbaar (originele error: %q)",
"This website is permanently unreachable (original error: %q)": "Deze website is permanent onbereikbaar (originele error: %q)",
"Website unreachable, the request timed out after %d seconds": "Website onbereikbaar, de request gaf een timeout na %d seconden"
}
+223
View File
@@ -0,0 +1,223 @@
{
"plural.feed.error_count": [
"%d błąd",
"%d błąd",
"%d błędów"
],
"plural.categories.feed_count": [
"Jest %d kanał.",
"Są %d kanały.",
"Jest %d kanałów."
],
"Username": "Nazwa użytkownika",
"Password": "Hasło",
"Unread": "Nieprzeczytane",
"History": "Historia",
"Feeds": "Kanały",
"Categories": "Kategorie",
"Settings": "Ustawienia",
"Logout": "Wyloguj się",
"Next": "Następny",
"Previous": "Poprzedni",
"New Subscription": "Nowa subskrypcja",
"Import": "Importuj",
"Export": "Eksportuj",
"There is no category. You must have at least one category.": "Nie ma żadnej kategorii. Musisz mieć co najmniej jedną kategorię",
"URL": "URL",
"Category": "Kategoria",
"Find a subscription": "Znajdź subskrypcję",
"Loading...": "Ładowanie...",
"Create a category": "Utwórz kategorię",
"There is no category.": "Nie masz żadnej kategorii",
"Edit": "Edytuj",
"Remove": "Usuń",
"No feed.": "Brak kanałów.",
"There is no article in this category.": "W tej kategorii nie ma żadnych artykułów",
"Original": "Oryginalny artykuł",
"Mark this page as read": "Oznacz jako przeczytane",
"not yet": "jeszcze nie",
"just now": "przed chwilą",
"1 minute ago": "minutę temu",
"%d minutes ago": "%d minut temu",
"1 hour ago": "godzinę temu",
"%d hours ago": "%d godzin temu",
"yesterday": "wczoraj",
"%d days ago": "%d dni temu",
"%d weeks ago": "%d tygodni temu",
"%d months ago": "%d miesięcy temu",
"%d years ago": "%d lat temu",
"Date": "Data",
"IP Address": "Adres IP",
"User Agent": "User Agent",
"Actions": "Działania",
"Current session": "Bieżąca sesja",
"Sessions": "Sesje",
"Users": "Użytkownicy",
"Add user": "Dodaj użytkownika",
"Choose a Subscription": "Wybierz subskrypcję",
"Subscribe": "Subskrypcja",
"New Category": "Nowa kategoria",
"Title": "Tytuł",
"Save": "Zapisz",
"or": "lub",
"cancel": "anuluj",
"New User": "Nowy użytkownik",
"Confirmation": "Potwierdź",
"Administrator": "Administrator",
"Edit Category: %s": "Edycja Kategorii: %s",
"Update": "Zaktualizuj",
"Edit Feed: %s": "Edytuj kanał: %s",
"There is no category!": "Nie ma żadnej kategorii!",
"Edit user: %s": "Edytuj użytkownika: %s",
"There is no article for this feed.": "Nie ma artykułu dla tego kanału.",
"Add subscription": "Dodaj subskrypcję",
"You don't have any subscription.": "Nie masz żadnej subskrypcji",
"Last check:": "Ostatnia aktualizacja:",
"Refresh": "Odśwież",
"There is no history at the moment.": "Obecnie nie ma żadnej historii.",
"OPML file": "Plik OPML",
"Sign In": "Zaloguj się",
"Sign in": "Zaloguj się",
"Theme": "Wygląd",
"Timezone": "Strefa czasowa",
"Language": "Język",
"There is no unread article.": "Nie ma żadnych nieprzeczytanych artykułów.",
"You are the only user.": "Jesteś jedynym użytkownikiem.",
"Last Login": "Ostatnie logowanie",
"Yes": "Tak",
"No": "Nie",
"This feed already exists (%s)": "Ten kanał już istnieje (%s)",
"Unable to fetch feed (statusCode=%d)": "Kanał nie mógł zostać pobrany (kod=%d)",
"Unable to open this link: %v": "Nie można było otworzyć tego linku: %v",
"Unable to analyze this page: %v": "Nie można przeanalizować tej strony: %v",
"Unable to find any subscription.": "Nie znaleziono żadnych subskrypcji.",
"The URL and the category are mandatory.": "URL i kategoria są obowiązkowe.",
"All fields are mandatory.": "Wszystkie pola są obowiązkowe.",
"Passwords are not the same.": "Hasła nie są identyczne.",
"You must use at least 6 characters.": "Musisz użyć co najmniej 6 znaków.",
"The username is mandatory.": "Nazwa użytkownika jest obowiązkowa.",
"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 :",
"Build Date:": "Data opracowania:",
"Author:": "Autor:",
"Authors": "Autorzy",
"License:": "Licencja:",
"Attachments": "Załączniki",
"Download": "Pobierz",
"Invalid username or password.": "Nieprawidłowa nazwa użytkownika lub hasło.",
"Never": "Nigdy",
"Unable to execute request: %v": "To polecenie nie mogło zostać wykonane: %v",
"Last Parsing Error": "Ostatni błąd analizy",
"There is a problem with this feed": "Z tym kanałem jest problem",
"Unable to parse OPML file: %q": "Plik OPML nie mógł zostać odczytany: %q",
"Unable to parse RSS feed: %q": "Nie można było odczytać kanału RSS: %q",
"Unable to parse Atom feed: %q": "Nie można było odczytać kanału Atom: %q",
"Unable to parse JSON feed: %q": "Nie można było odczytać kanału JSON: %q",
"Unable to parse RDF feed: %q": "Nie można było odczytać kanału RDF: %q",
"Unable to normalize encoding: %q": "Kodowanie znaków nie mogło zostać znormalizowane: %q",
"Unable to create this category.": "Ta kategoria nie mogła zostać utworzona.",
"yes": "tak",
"no": "nie",
"Are you sure?": "Czy jesteś pewny?",
"Work in progress...": "W toku...",
"This user already exists.": "Ten użytkownik już istnieje.",
"This category already exists.": "Ta kategoria już istnieje.",
"Unable to update this category.": "Ta kategoria nie mogła zostać zaktualizowana.",
"Integrations": "Usługi",
"Bookmarklet": "Bookmarklet",
"Drag and drop this link to your bookmarks.": "Przeciągnij i upuść to łącze do zakładek.",
"This special link allows you to subscribe to a website directly by using a bookmark in your web browser.": "Ten link umożliwia subskrypcję strony internetowej bezpośrednio za pomocą zakładki w przeglądarce internetowej",
"Add to Miniflux": "Dodaj do Miniflux",
"Refresh all feeds in background": "Odśwież wszystkie subskrypcje w tle",
"Sign in with Google": "Zaloguj przez Google",
"Unlink my Google account": "Odłącz moje konto Google",
"Link my Google account": "Połącz z moim kontem Google",
"Category not found for this user": "Kategoria nie znaleziona dla tego użytkownika",
"Invalid theme.": "Ten temat jest nieprawidłowy.",
"Entry Sorting": "Sortowanie artykułów",
"Older entries first": "Najstarsze wpisy jako pierwsze",
"Recent entries first": "Najnowsze wpisy jako pierwsze",
"Saving...": "Zapisywanie...",
"Done!": "Gotowe!",
"Save this article": "Zapisz ten artykuł",
"Mark bookmark as unread": "Zaznacz zakładkę jako nieprzeczytaną",
"Pinboard Tags": "Pinboard Tags",
"Pinboard API Token": "Token Pinboard API",
"Save articles to Pinboard": "Zapisz artykuł w Pinboard",
"Save articles to Instapaper": "Zapisz artykuł w Instapaper",
"Instapaper Username": "Login do Instapaper",
"Instapaper Password": "Hasło do Instapaper",
"Activate Fever API": "Aktywuj Fever API",
"Fever Username": "Login do Fever",
"Fever Password": "Hasło do Fever",
"Fetch original content": "Pobierz oryginalną treść",
"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!",
"Save articles to Wallabag": "Zapisz artykuły do Wallabag",
"Wallabag API Endpoint": "Wallabag URL",
"Wallabag Client ID": "Wallabag Client-ID",
"Wallabag Client Secret": "Wallabag Client Secret",
"Wallabag Username": "Login do Wallabag",
"Wallabag Password": "Hasło do Wallabag",
"Save articles to Nunux Keeper": "Zapisz artykuly do Nunux Keeper",
"Nunux Keeper API Endpoint": "Nunux Keeper URL",
"Nunux Keeper API key": "Nunux Keeper API key",
"Keyboard Shortcut: %s": "Skróty klawiszowe: %s",
"Favorites": "Ulubione",
"Star": "Oznacz gwiazdką",
"Unstar": "Usuń gwiazdkę",
"Starred": "Oznaczone gwiazdką",
"There is no bookmark at the moment.": "Obecnie nie ma żadnych zakładek.",
"Last checked:": "Ostatnio sprawdzone:",
"ETag header:": "Nagłówek ETag:",
"LastModified header:": "Ostatnio zmienione:",
"None": "Brak",
"Keyboard Shortcuts": "Skróty klawiszowe",
"Sections Navigation": "Nawigacja między punktami menu",
"Go to unread": "Przejdź do nieprzeczytanych artykułów",
"Go to bookmarks": "Przejdź do zakładek",
"Go to history": "Przejdź do historii",
"Go to feeds": "Przejdź do kanałów",
"Go to categories": "Przejdź do kategorii",
"Go to settings": "Przejdź do ustawień",
"Show keyboard shortcuts": "Pokaż listę skrótów klawiszowych",
"Items Navigation": "Nawigacja między artykułami",
"Go to previous item": "Przejdź do poprzedniego artykułu",
"Go to next item": "Przejdź do następnego punktu artykułu",
"Pages Navigation": "Nawigacja między stronami",
"Go to previous page": "Przejdź do poprzedniej strony",
"Go to next page": "Przejdź do następnej strony",
"Open selected item": "Otwórz zaznaczony artykuł",
"Open original link": "Otwórz oryginalny artykuł",
"Toggle read/unread": "Oznacz jako przeczytane/nieprzeczytane",
"Mark current page as read": "Zaznacz aktualną stronę jako przeczytaną",
"Download original content": "Pobierz oryginalną zawartość",
"Toggle bookmark": "Dodaj/usuń zakładki",
"Close modal dialog": "Zamknij listę skrótów klawiszowych",
"Save article": "Zapisz artykuł",
"There is already someone associated with this provider!": "Już ktoś jest powiązany z tym dostawcą!",
"There is already someone else with the same Fever username!": "Już ktoś inny używa tej nazwy użytkownika Fever!",
"Mark all as read": "Oznacz wszystko jako przeczytane",
"This feed is empty": "Ten kanał jest pusty",
"Flush history": "Usuń historię",
"Site URL": "URL strony",
"Feed URL": "URL kanału",
"Logged as %s": "Zalogowany jako %s",
"Unread Items": "Nieprzeczytane",
"Change entry status": "Zmień status artykułu",
"Read": "Przeczytane",
"Fever API endpoint:": "Fever API endpoint:",
"Miniflux API": "Miniflux API",
"API Endpoint": "API endpoint",
"Your account password": "Hasło konta",
"This web page is empty": "Ta strona jest pusta",
"Invalid SSL certificate (original error: %q)": "Certyfikat SSL jest nieprawidłowy (błąd: %q)",
"This website is temporarily unreachable (original error: %q)": "Ta strona jest tymczasowo niedostępna (błąd: %q)",
"This website is permanently unreachable (original error: %q)": "Ta strona jest niedostępna (błąd: %q)",
"Website unreachable, the request timed out after %d seconds": "Strona internetowa nieosiągalna, żądanie wygasło po %d sekundach"
}
+221
View File
@@ -0,0 +1,221 @@
{
"plural.feed.error_count": [
"%d 错误",
"%d 错误"
],
"plural.categories.feed_count": [
"有 %d 个源.",
"有 %d 个源."
],
"Username": "用户名",
"Password": "密码",
"Unread": "未读",
"History": "历史",
"Feeds": "源",
"Categories": "分类",
"Settings": "设置",
"Logout": "登出",
"Next": "下一页",
"Previous": "上一页",
"New Subscription": "新订阅",
"Import": "导入",
"Export": "导出",
"There is no category. You must have at least one category.": "目前没有分类.需要有一个已有的分类存在.",
"URL": "URL",
"Category": "分类",
"Find a subscription": "寻找订阅",
"Loading...": "载入中...",
"Create a category": "新建分类",
"There is no category.": "目前没有分类.",
"Edit": "编辑",
"Remove": "删除",
"No feed.": "没有源.",
"There is no article in this category.": "该分类下没有文章.",
"Original": "原始链接",
"Mark this page as read": "标记为已读",
"not yet": "尚未",
"just now": "刚刚",
"1 minute ago": "1 分钟前",
"%d minutes ago": "%d 分钟前",
"1 hour ago": "1 小时前",
"%d hours ago": "%d 小时前",
"yesterday": "昨天",
"%d days ago": "%d 天前",
"%d weeks ago": "%d 周前",
"%d months ago": "%d 月前",
"%d years ago": "%d 年前",
"Date": "日期",
"IP Address": "IP地址",
"User Agent": "User Agent",
"Actions": "操作",
"Current session": "当前会话",
"Sessions": "会话",
"Users": "用户",
"Add user": "新建用户",
"Choose a Subscription": "选择一个订阅",
"Subscribe": "订阅",
"New Category": "新分类",
"Title": "标题",
"Save": "保存",
"or": "或",
"cancel": "取消",
"New User": "新用户",
"Confirmation": "确认",
"Administrator": "管理员",
"Edit Category: %s": "编辑分类 : %s",
"Update": "更新",
"Edit Feed: %s": "编辑源 : %s",
"There is no category!": "没有分类!",
"Edit user: %s": "编辑用户: %s",
"There is no article for this feed.": "这个源中没有文章.",
"Add subscription": "新增订阅",
"You don't have any subscription.": "当前没有订阅",
"Last check:": "最后检查时间:",
"Refresh": "更新",
"There is no history at the moment.": "当前没有历史.",
"OPML file": "OPML 文件",
"Sign In": "登陆",
"Sign in": "登陆",
"Theme": "主题",
"Timezone": "时区",
"Language": "语言",
"There is no unread article.": "目前没有未读文章.",
"You are the only user.": "你是目前仅有的用户.",
"Last Login": "最后登录时间",
"Yes": "是",
"No": "否",
"This feed already exists (%s)": "源已存在 (%s)",
"Unable to fetch feed (statusCode=%d)": "无法获取源 (错误代码=%d)",
"Unable to open this link: %v": "无法打开这一链接: %v",
"Unable to analyze this page: %v": "无法分析这一页面: %v",
"Unable to find any subscription.": "找不到任何订阅.",
"The URL and the category are mandatory.": "必须填写URL和分类.",
"All fields are mandatory.": "必须填写全部信息.",
"Passwords are not the same.": "两次输入的密码不同.",
"You must use at least 6 characters.": "请至少使用6个字符.",
"The username is mandatory.": "必须填写用户名.",
"The username, theme, language and timezone fields are mandatory.": "必须填写用户名,主题,语言和时区.",
"The title is mandatory.": "必须填写标题.",
"About": "关于",
"Version": "版本",
"Version:": "版本:",
"Build Date:": "构建日期:",
"Author:": "作者:",
"Authors": "作者",
"License:": "协议:",
"Attachments": "附件",
"Download": "下载",
"Invalid username or password.": "用户名或密码无效.",
"Never": "永不",
"Unable to execute request: %v": "无法执行这一请求: %v",
"Last Parsing Error": "最后一次解析错误",
"There is a problem with this feed": "这一源存在问题",
"Unable to parse OPML file: %q": "无法解析OPML文件: %q",
"Unable to parse RSS feed: %q": "无法解析RSS源: %q",
"Unable to parse Atom feed: %q": "无法解析Atom源: %q",
"Unable to parse JSON feed: %q": "无法解析JSON源: %q",
"Unable to parse RDF feed: %q": "无法解析RDF源: %q",
"Unable to normalize encoding: %q": "无法正则化编码: %q",
"Unable to create this category.": "无法建立这个分类.",
"yes": "是",
"no": "否",
"Are you sure?": "您确认吗?",
"Work in progress...": "执行中...",
"This user already exists.": "用户已存在.",
"This category already exists.": "分类已存在.",
"Unable to update this category.": "无法更新该分类.",
"Integrations": "集成",
"Bookmarklet": "书签小应用",
"Drag and drop this link to your bookmarks.": "拖动这个链接到书签.",
"This special link allows you to subscribe to a website directly by using a bookmark in your web browser.": "你可以打开这个特殊的书签来直接订阅网站.",
"Add to Miniflux": "新增到Miniflux",
"Refresh all feeds in background": "在后台更新全部源",
"Sign in with Google": "使用Google登陆",
"Unlink my Google account": "去除Google账号关联",
"Link my Google account": "关联我的Google账户",
"Category not found for this user": "未找到该用户的这一分类",
"Invalid theme.": "无效的主题.",
"Entry Sorting": "内容排序",
"Older entries first": "旧->新",
"Recent entries first": "新->旧",
"Saving...": "保存中",
"Done!": "完成!",
"Save this article": "保存这篇文章",
"Mark bookmark as unread": "标记为未读",
"Pinboard Tags": "Pinboard 标签",
"Pinboard API Token": "Pinboard API Token",
"Save articles to Pinboard": "保存文章到Pinboard",
"Save articles to Instapaper": "保存文章到Instapaper",
"Instapaper Username": "Instapaper 用户名",
"Instapaper Password": "Instapaper 密码",
"Activate Fever API": "启用 Fever API",
"Fever Username": "Fever 用户名",
"Fever Password": "Fever 密码",
"Fetch original content": "抓取原内容",
"Scraper Rules": "Scraper规则",
"Rewrite Rules": "重写规则",
"Preferences saved!": "偏好已存储!",
"Your external account is now linked!": "您的外部账号已关联!",
"Save articles to Wallabag": "保存文章到Wallabag",
"Wallabag API Endpoint": "Wallabag URL",
"Wallabag Client ID": "Wallabag 客户端ID",
"Wallabag Client Secret": "Wallabag 客户端Secret",
"Wallabag Username": "Wallabag 用户名",
"Wallabag Password": "Wallabag 密码",
"Save articles to Nunux Keeper": "保存文章到Nunux Keeper",
"Nunux Keeper API Endpoint": "Nunux Keeper API Endpoint",
"Nunux Keeper API key": "Nunux Keeper API key",
"Keyboard Shortcut: %s": "快捷键: %s",
"Favorites": "收藏",
"Star": "标记星标",
"Unstar": "去掉星标",
"Starred": "星标",
"There is no bookmark at the moment.": "当前没有书签.",
"Last checked:": "上次检查:",
"ETag header:": "ETag header:",
"LastModified header:": "最后修改的Header:",
"None": "无",
"Keyboard Shortcuts": "快捷键",
"Sections Navigation": "分区导航",
"Go to unread": "去往未读",
"Go to bookmarks": "去往书签",
"Go to history": "去往历史",
"Go to feeds": "去往源",
"Go to categories": "去往分类",
"Go to settings": "去往设定",
"Show keyboard shortcuts": "显示快捷键",
"Items Navigation": "条目导航",
"Go to previous item": "上一条目",
"Go to next item": "下一条目",
"Pages Navigation": "页面导航",
"Go to previous page": "上一页",
"Go to next page": "下一页",
"Open selected item": "打开选定的条目",
"Open original link": "打开原始链接",
"Toggle read/unread": "切换已读/未读状态",
"Mark current page as read": "标记当前",
"Download original content": "下载原始内容",
"Toggle bookmark": "切换收藏状态",
"Close modal dialog": "关闭模态对话窗口",
"Save article": "保存文章",
"There is already someone associated with this provider!": "该Provider已被关联!",
"There is already someone else with the same Fever username!": "Fever用户名已被占用!",
"Mark all as read": "全标记为已读",
"This feed is empty": "该源是空的",
"Flush history": "清理历史",
"Site URL": "站点URL",
"Feed URL": "源URL",
"Logged as %s": "当前登录 %s",
"Unread Items": "未读条目",
"Change entry status": "更改状态",
"Read": "标为已读",
"Fever API endpoint:": "Fever API Endpoint:",
"Miniflux API": "Miniflux API",
"API Endpoint": "API Endpoint",
"Your account password": "您账户的密码",
"This web page is empty": "该网页是空的",
"Invalid SSL certificate (original error: %q)": "无效的SSL证书 (原始错误: %q)",
"This website is temporarily unreachable (original error: %q)": "该网站暂时不可达 (原始错误: %q)",
"This website is permanently unreachable (original error: %q)": "该网站永久不可达 (原始错误: %q)",
"Website unreachable, the request timed out after %d seconds": "网站不可达, 请求已在 %d 秒后超时"
}
+76
View File
@@ -0,0 +1,76 @@
// Copyright 2018 Frédéric Guillot. All rights reserved.
// Use of this source code is governed by the Apache 2.0
// license that can be found in the LICENSE file.
package middleware
import (
"context"
"errors"
"net/http"
"github.com/miniflux/miniflux/http/cookie"
"github.com/miniflux/miniflux/http/request"
"github.com/miniflux/miniflux/http/response/html"
"github.com/miniflux/miniflux/logger"
"github.com/miniflux/miniflux/model"
)
// AppSession handles application session middleware.
func (m *Middleware) AppSession(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var err error
session := m.getAppSessionValueFromCookie(r)
if session == nil {
logger.Debug("[Middleware:AppSession] Session not found")
session, err = m.store.CreateSession()
if err != nil {
logger.Error("[Middleware:AppSession] %v", err)
html.ServerError(w, err)
return
}
http.SetCookie(w, cookie.New(cookie.CookieSessionID, session.ID, m.cfg.IsHTTPS, m.cfg.BasePath()))
} else {
logger.Debug("[Middleware:AppSession] %s", session)
}
if r.Method == "POST" {
formValue := r.FormValue("csrf")
headerValue := r.Header.Get("X-Csrf-Token")
if session.Data.CSRF != formValue && session.Data.CSRF != headerValue {
logger.Error(`[Middleware:AppSession] Invalid or missing CSRF token: Form="%s", Header="%s"`, formValue, headerValue)
html.BadRequest(w, errors.New("invalid or missing CSRF"))
return
}
}
ctx := r.Context()
ctx = context.WithValue(ctx, SessionIDContextKey, session.ID)
ctx = context.WithValue(ctx, CSRFContextKey, session.Data.CSRF)
ctx = context.WithValue(ctx, OAuth2StateContextKey, session.Data.OAuth2State)
ctx = context.WithValue(ctx, FlashMessageContextKey, session.Data.FlashMessage)
ctx = context.WithValue(ctx, FlashErrorMessageContextKey, session.Data.FlashErrorMessage)
ctx = context.WithValue(ctx, UserLanguageContextKey, session.Data.Language)
ctx = context.WithValue(ctx, PocketRequestTokenContextKey, session.Data.PocketRequestToken)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
func (m *Middleware) getAppSessionValueFromCookie(r *http.Request) *model.Session {
cookieValue := request.Cookie(r, cookie.CookieSessionID)
if cookieValue == "" {
return nil
}
session, err := m.store.Session(cookieValue)
if err != nil {
logger.Error("[Middleware:AppSession] %v", err)
return nil
}
return session
}
@@ -1,4 +1,4 @@
// Copyright 2017 Frédéric Guillot. All rights reserved.
// Copyright 2018 Frédéric Guillot. All rights reserved.
// Use of this source code is governed by the Apache 2.0
// license that can be found in the LICENSE file.
@@ -8,53 +8,43 @@ import (
"context"
"net/http"
"github.com/miniflux/miniflux/http/response/json"
"github.com/miniflux/miniflux/logger"
"github.com/miniflux/miniflux/storage"
)
// BasicAuthMiddleware is the middleware for HTTP Basic authentication.
type BasicAuthMiddleware struct {
store *storage.Storage
}
// Handler executes the middleware.
func (b *BasicAuthMiddleware) Handler(next http.Handler) http.Handler {
// BasicAuth handles HTTP basic authentication.
func (m *Middleware) BasicAuth(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("WWW-Authenticate", `Basic realm="Restricted"`)
errorResponse := `{"error_message": "Not Authorized"}`
username, password, authOK := r.BasicAuth()
if !authOK {
logger.Debug("[Middleware:BasicAuth] No authentication headers sent")
w.WriteHeader(http.StatusUnauthorized)
w.Write([]byte(errorResponse))
json.Unauthorized(w)
return
}
if err := b.store.CheckPassword(username, password); err != nil {
if err := m.store.CheckPassword(username, password); err != nil {
logger.Info("[Middleware:BasicAuth] Invalid username or password: %s", username)
w.WriteHeader(http.StatusUnauthorized)
w.Write([]byte(errorResponse))
json.Unauthorized(w)
return
}
user, err := b.store.UserByUsername(username)
user, err := m.store.UserByUsername(username)
if err != nil {
logger.Error("[Middleware:BasicAuth] %v", err)
w.WriteHeader(http.StatusInternalServerError)
w.Write([]byte(errorResponse))
json.ServerError(w, err)
return
}
if user == nil {
logger.Info("[Middleware:BasicAuth] User not found: %s", username)
w.WriteHeader(http.StatusUnauthorized)
w.Write([]byte(errorResponse))
json.Unauthorized(w)
return
}
logger.Info("[Middleware:BasicAuth] User authenticated: %s", username)
b.store.SetLastLogin(user.ID)
m.store.SetLastLogin(user.ID)
ctx := r.Context()
ctx = context.WithValue(ctx, UserIDContextKey, user.ID)
@@ -65,8 +55,3 @@ func (b *BasicAuthMiddleware) Handler(next http.Handler) http.Handler {
next.ServeHTTP(w, r.WithContext(ctx))
})
}
// NewBasicAuthMiddleware returns a new BasicAuthMiddleware.
func NewBasicAuthMiddleware(s *storage.Storage) *BasicAuthMiddleware {
return &BasicAuthMiddleware{store: s}
}
+25
View File
@@ -0,0 +1,25 @@
// Copyright 2018 Frédéric Guillot. All rights reserved.
// Use of this source code is governed by the Apache 2.0
// license that can be found in the LICENSE file.
package middleware
import (
"net/http"
)
// CommonHeaders sends common HTTP headers.
func (m *Middleware) CommonHeaders(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("X-XSS-Protection", "1; mode=block")
w.Header().Set("X-Content-Type-Options", "nosniff")
w.Header().Set("X-Frame-Options", "DENY")
w.Header().Set("Content-Security-Policy", "default-src 'self'; img-src *; media-src *; frame-src *; child-src *")
if m.cfg.IsHTTPS && m.cfg.HasHSTS() {
w.Header().Set("Strict-Transport-Security", "max-age=31536000")
}
next.ServeHTTP(w, r)
})
}
@@ -1,4 +1,4 @@
// Copyright 2017 Frédéric Guillot. All rights reserved.
// Copyright 2018 Frédéric Guillot. All rights reserved.
// Use of this source code is governed by the Apache 2.0
// license that can be found in the LICENSE file.
@@ -46,4 +46,7 @@ var (
// FlashErrorMessageContextKey is the context key used to store a flash error message.
FlashErrorMessageContextKey = &ContextKey{"FlashErrorMessage"}
// PocketRequestTokenContextKey is the context key for Pocket Request Token.
PocketRequestTokenContextKey = &ContextKey{"PocketRequestToken"}
)
@@ -1,4 +1,4 @@
// Copyright 2017 Frédéric Guillot. All rights reserved.
// Copyright 2018 Frédéric Guillot. All rights reserved.
// Use of this source code is governed by the Apache 2.0
// license that can be found in the LICENSE file.
@@ -8,38 +8,30 @@ import (
"context"
"net/http"
"github.com/miniflux/miniflux/http/response/json"
"github.com/miniflux/miniflux/logger"
"github.com/miniflux/miniflux/storage"
)
// FeverMiddleware is the middleware that handles Fever API.
type FeverMiddleware struct {
store *storage.Storage
}
// Handler executes the middleware.
func (f *FeverMiddleware) Handler(next http.Handler) http.Handler {
// FeverAuth handles Fever API authentication.
func (m *Middleware) FeverAuth(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
logger.Debug("[Middleware:Fever]")
apiKey := r.FormValue("api_key")
user, err := f.store.UserByFeverToken(apiKey)
user, err := m.store.UserByFeverToken(apiKey)
if err != nil {
logger.Error("[Fever] %v", err)
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{"api_version": 3, "auth": 0}`))
logger.Error("[Middleware:Fever] %v", err)
json.OK(w, map[string]int{"api_version": 3, "auth": 0})
return
}
if user == nil {
logger.Info("[Middleware:Fever] Fever authentication failure")
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{"api_version": 3, "auth": 0}`))
json.OK(w, map[string]int{"api_version": 3, "auth": 0})
return
}
logger.Info("[Middleware:Fever] User #%d is authenticated", user.ID)
f.store.SetLastLogin(user.ID)
m.store.SetLastLogin(user.ID)
ctx := r.Context()
ctx = context.WithValue(ctx, UserIDContextKey, user.ID)
@@ -50,8 +42,3 @@ func (f *FeverMiddleware) Handler(next http.Handler) http.Handler {
next.ServeHTTP(w, r.WithContext(ctx))
})
}
// NewFeverMiddleware returns a new FeverMiddleware.
func NewFeverMiddleware(s *storage.Storage) *FeverMiddleware {
return &FeverMiddleware{store: s}
}
+19
View File
@@ -0,0 +1,19 @@
// Copyright 2018 Frédéric Guillot. All rights reserved.
// Use of this source code is governed by the Apache 2.0
// license that can be found in the LICENSE file.
package middleware
import (
"net/http"
)
// HeaderConfig changes config values according to HTTP headers.
func (m *Middleware) HeaderConfig(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("X-Forwarded-Proto") == "https" {
m.cfg.IsHTTPS = true
}
next.ServeHTTP(w, r)
})
}
+20
View File
@@ -0,0 +1,20 @@
// Copyright 2018 Frédéric Guillot. All rights reserved.
// Use of this source code is governed by the Apache 2.0
// license that can be found in the LICENSE file.
package middleware
import (
"net/http"
"github.com/miniflux/miniflux/http/request"
"github.com/miniflux/miniflux/logger"
)
// Logging logs the HTTP request.
func (m *Middleware) Logging(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
logger.Debug("[HTTP] %s %s %s", request.RealIP(r), r.Method, r.RequestURI)
next.ServeHTTP(w, r)
})
}
+23
View File
@@ -0,0 +1,23 @@
// Copyright 2018 Frédéric Guillot. All rights reserved.
// Use of this source code is governed by the Apache 2.0
// license that can be found in the LICENSE file.
package middleware
import (
"github.com/gorilla/mux"
"github.com/miniflux/miniflux/config"
"github.com/miniflux/miniflux/storage"
)
// Middleware handles different middleware handlers.
type Middleware struct {
cfg *config.Config
store *storage.Storage
router *mux.Router
}
// New returns a new middleware.
func New(cfg *config.Config, store *storage.Storage, router *mux.Router) *Middleware {
return &Middleware{cfg, store, router}
}
@@ -9,34 +9,30 @@ import (
"net/http"
"github.com/miniflux/miniflux/http/cookie"
"github.com/miniflux/miniflux/http/request"
"github.com/miniflux/miniflux/http/response"
"github.com/miniflux/miniflux/http/route"
"github.com/miniflux/miniflux/logger"
"github.com/miniflux/miniflux/model"
"github.com/miniflux/miniflux/storage"
"github.com/gorilla/mux"
)
// UserSessionMiddleware represents a user session middleware.
type UserSessionMiddleware struct {
store *storage.Storage
router *mux.Router
}
// Handler execute the middleware.
func (s *UserSessionMiddleware) Handler(next http.Handler) http.Handler {
// UserSession handles the user session middleware.
func (m *Middleware) UserSession(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
session := s.getSessionFromCookie(r)
session := m.getUserSessionFromCookie(r)
if session == nil {
logger.Debug("[Middleware:UserSession] Session not found")
if s.isPublicRoute(r) {
if m.isPublicRoute(r) {
next.ServeHTTP(w, r)
} else {
http.Redirect(w, r, route.Path(s.router, "login"), http.StatusFound)
response.Redirect(w, r, route.Path(m.router, "login"))
}
} else {
logger.Debug("[Middleware:UserSession] %s", session)
ctx := r.Context()
ctx = context.WithValue(ctx, UserIDContextKey, session.UserID)
ctx = context.WithValue(ctx, IsAuthenticatedContextKey, true)
@@ -47,7 +43,7 @@ func (s *UserSessionMiddleware) Handler(next http.Handler) http.Handler {
})
}
func (s *UserSessionMiddleware) isPublicRoute(r *http.Request) bool {
func (m *Middleware) isPublicRoute(r *http.Request) bool {
route := mux.CurrentRoute(r)
switch route.GetName() {
case "login",
@@ -65,13 +61,13 @@ func (s *UserSessionMiddleware) isPublicRoute(r *http.Request) bool {
}
}
func (s *UserSessionMiddleware) getSessionFromCookie(r *http.Request) *model.UserSession {
sessionCookie, err := r.Cookie(cookie.CookieUserSessionID)
if err == http.ErrNoCookie {
func (m *Middleware) getUserSessionFromCookie(r *http.Request) *model.UserSession {
cookieValue := request.Cookie(r, cookie.CookieUserSessionID)
if cookieValue == "" {
return nil
}
session, err := s.store.UserSessionByToken(sessionCookie.Value)
session, err := m.store.UserSessionByToken(cookieValue)
if err != nil {
logger.Error("[Middleware:UserSession] %v", err)
return nil
@@ -79,8 +75,3 @@ func (s *UserSessionMiddleware) getSessionFromCookie(r *http.Request) *model.Use
return session
}
// NewUserSessionMiddleware returns a new UserSessionMiddleware.
func NewUserSessionMiddleware(s *storage.Storage, r *mux.Router) *UserSessionMiddleware {
return &UserSessionMiddleware{store: s, router: r}
}
+1 -1
View File
@@ -11,7 +11,7 @@ type Enclosure struct {
EntryID int64 `json:"entry_id"`
URL string `json:"url"`
MimeType string `json:"mime_type"`
Size int `json:"size"`
Size int64 `json:"size"`
}
// EnclosureList represents a list of attachments.
+15 -14
View File
@@ -20,20 +20,21 @@ const (
// Entry represents a feed item in the system.
type Entry struct {
ID int64 `json:"id"`
UserID int64 `json:"user_id"`
FeedID int64 `json:"feed_id"`
Status string `json:"status"`
Hash string `json:"hash"`
Title string `json:"title"`
URL string `json:"url"`
Date time.Time `json:"published_at"`
Content string `json:"content"`
Author string `json:"author"`
Starred bool `json:"starred"`
Enclosures EnclosureList `json:"enclosures,omitempty"`
Feed *Feed `json:"feed,omitempty"`
Category *Category `json:"category,omitempty"`
ID int64 `json:"id"`
UserID int64 `json:"user_id"`
FeedID int64 `json:"feed_id"`
Status string `json:"status"`
Hash string `json:"hash"`
Title string `json:"title"`
URL string `json:"url"`
CommentsURL string `json:"comments_url"`
Date time.Time `json:"published_at"`
Content string `json:"content"`
Author string `json:"author"`
Starred bool `json:"starred"`
Enclosures EnclosureList `json:"enclosures,omitempty"`
Feed *Feed `json:"feed,omitempty"`
Category *Category `json:"category,omitempty"`
}
// Entries represents a list of entries.
+6
View File
@@ -24,4 +24,10 @@ type Integration struct {
WallabagClientSecret string
WallabagUsername string
WallabagPassword string
NunuxKeeperEnabled bool
NunuxKeeperURL string
NunuxKeeperAPIKey string
PocketEnabled bool
PocketAccessToken string
PocketConsumerKey string
}
+6 -5
View File
@@ -13,11 +13,12 @@ import (
// SessionData represents the data attached to the session.
type SessionData struct {
CSRF string `json:"csrf"`
OAuth2State string `json:"oauth2_state"`
FlashMessage string `json:"flash_message"`
FlashErrorMessage string `json:"flash_error_message"`
Language string `json:"language"`
CSRF string `json:"csrf"`
OAuth2State string `json:"oauth2_state"`
FlashMessage string `json:"flash_message"`
FlashErrorMessage string `json:"flash_error_message"`
Language string `json:"language"`
PocketRequestToken string `json:"pocket_request_token"`
}
func (s SessionData) String() string {
+16
View File
@@ -7,6 +7,8 @@ package model
import (
"errors"
"time"
"github.com/miniflux/miniflux/timezone"
)
// User represents a user in the system.
@@ -99,5 +101,19 @@ func (u *User) Merge(override *User) {
}
}
// UseTimezone converts last login date to the given timezone.
func (u *User) UseTimezone(tz string) {
if u.LastLoginAt != nil {
*u.LastLoginAt = timezone.Convert(tz, *u.LastLoginAt)
}
}
// Users represents a list of users.
type Users []*User
// UseTimezone converts last login timestamp of all users to the given timezone.
func (u Users) UseTimezone(tz string) {
for _, user := range u {
user.UseTimezone(tz)
}
}
+20 -4
View File
@@ -4,8 +4,12 @@
package model
import "time"
import "fmt"
import (
"fmt"
"time"
"github.com/miniflux/miniflux/timezone"
)
// UserSession represents a user session in the system.
type UserSession struct {
@@ -17,9 +21,21 @@ type UserSession struct {
IP string
}
func (s *UserSession) String() string {
return fmt.Sprintf(`ID="%d", UserID="%d", IP="%s", Token="%s"`, s.ID, s.UserID, s.IP, s.Token)
func (u *UserSession) String() string {
return fmt.Sprintf(`ID="%d", UserID="%d", IP="%s", Token="%s"`, u.ID, u.UserID, u.IP, u.Token)
}
// UseTimezone converts creation date to the given timezone.
func (u *UserSession) UseTimezone(tz string) {
u.CreatedAt = timezone.Convert(tz, u.CreatedAt)
}
// UserSessions represents a list of sessions.
type UserSessions []*UserSession
// UseTimezone converts creation date of all sessions to the given timezone.
func (u UserSessions) UseTimezone(tz string) {
for _, session := range u {
session.UseTimezone(tz)
}
}
+15 -3
View File
@@ -14,6 +14,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"
)
@@ -28,7 +29,7 @@ type atomFeed struct {
type atomEntry struct {
ID string `xml:"id"`
Title string `xml:"title"`
Title atomContent `xml:"title"`
Updated string `xml:"updated"`
Links []atomLink `xml:"link"`
Summary string `xml:"summary"`
@@ -97,7 +98,7 @@ func (a *atomEntry) Transform() *model.Entry {
entry.Author = getAuthor(a.Author)
entry.Hash = getHash(a)
entry.Content = getContent(a)
entry.Title = strings.TrimSpace(a.Title)
entry.Title = getTitle(a)
entry.Enclosures = getEnclosures(a)
return entry
}
@@ -160,6 +161,17 @@ func getContent(a *atomEntry) string {
return ""
}
func getTitle(a *atomEntry) string {
title := ""
if a.Title.Type == "xhtml" {
title = a.Title.XML
} else {
title = a.Title.Data
}
return strings.TrimSpace(sanitizer.StripTags(title))
}
func getHash(a *atomEntry) string {
for _, value := range []string{a.ID, getURL(a.Links)} {
if value != "" {
@@ -175,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})
}
}
+2 -2
View File
@@ -14,14 +14,14 @@ import (
)
// Parse returns a normalized feed struct from a Atom feed.
func Parse(data io.Reader) (*model.Feed, error) {
func Parse(data io.Reader) (*model.Feed, *errors.LocalizedError) {
atomFeed := new(atomFeed)
decoder := xml.NewDecoder(data)
decoder.CharsetReader = encoding.CharsetReader
err := decoder.Decode(atomFeed)
if err != nil {
return nil, errors.NewLocalizedError("Unable to parse Atom feed: %v.", err)
return nil, errors.NewLocalizedError("Unable to parse Atom feed: %q", err)
}
return atomFeed.Transform(), nil
+78 -6
View File
@@ -8,8 +8,6 @@ import (
"bytes"
"testing"
"time"
"github.com/miniflux/miniflux/errors"
)
func TestParseAtomSample(t *testing.T) {
@@ -206,6 +204,84 @@ func TestParseEntryTitleWithWhitespaces(t *testing.T) {
}
}
func TestParseEntryTitleWithHTMLAndCDATA(t *testing.T) {
data := `<?xml version="1.0" encoding="utf-8"?>
<feed xmlns="http://www.w3.org/2005/Atom">
<title>Example Feed</title>
<link href="http://example.org/"/>
<entry>
<title type="html"><![CDATA[Test &#8220;Test&#8221;]]></title>
<link href="http://example.org/2003/12/13/atom03"/>
<id>urn:uuid:1225c695-cfb8-4ebb-aaaa-80da344efa6a</id>
<updated>2003-12-13T18:30:02Z</updated>
<summary>Some text.</summary>
</entry>
</feed>`
feed, err := Parse(bytes.NewBufferString(data))
if err != nil {
t.Error(err)
}
if feed.Entries[0].Title != "Test “Test”" {
t.Errorf("Incorrect entry title, got: %q", feed.Entries[0].Title)
}
}
func TestParseEntryTitleWithHTML(t *testing.T) {
data := `<?xml version="1.0" encoding="utf-8"?>
<feed xmlns="http://www.w3.org/2005/Atom">
<title>Example Feed</title>
<link href="http://example.org/"/>
<entry>
<title type="html">&lt;code&gt;Test&lt;/code&gt; Test</title>
<link href="http://example.org/2003/12/13/atom03"/>
<id>urn:uuid:1225c695-cfb8-4ebb-aaaa-80da344efa6a</id>
<updated>2003-12-13T18:30:02Z</updated>
<summary>Some text.</summary>
</entry>
</feed>`
feed, err := Parse(bytes.NewBufferString(data))
if err != nil {
t.Error(err)
}
if feed.Entries[0].Title != "Test Test" {
t.Errorf("Incorrect entry title, got: %q", feed.Entries[0].Title)
}
}
func TestParseEntryTitleWithXHTML(t *testing.T) {
data := `<?xml version="1.0" encoding="utf-8"?>
<feed xmlns="http://www.w3.org/2005/Atom">
<title>Example Feed</title>
<link href="http://example.org/"/>
<entry>
<title type="xhtml"><code>Test</code> Test</title>
<link href="http://example.org/2003/12/13/atom03"/>
<id>urn:uuid:1225c695-cfb8-4ebb-aaaa-80da344efa6a</id>
<updated>2003-12-13T18:30:02Z</updated>
<summary>Some text.</summary>
</entry>
</feed>`
feed, err := Parse(bytes.NewBufferString(data))
if err != nil {
t.Error(err)
}
if feed.Entries[0].Title != "Test Test" {
t.Errorf("Incorrect entry title, got: %q", feed.Entries[0].Title)
}
}
func TestParseEntryWithAuthorName(t *testing.T) {
data := `<?xml version="1.0" encoding="utf-8"?>
<feed xmlns="http://www.w3.org/2005/Atom">
@@ -352,8 +428,4 @@ func TestParseInvalidXml(t *testing.T) {
if err == nil {
t.Error("Parse should returns an error")
}
if _, ok := err.(errors.LocalizedError); !ok {
t.Error("The error returned must be a LocalizedError")
}
}
+38
View File
@@ -7,6 +7,7 @@ package date
import (
"errors"
"fmt"
"strconv"
"strings"
"time"
)
@@ -71,6 +72,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 +195,12 @@ var dateFormats = []string{
// Parse parses a given date string using a large
// list of commonly found feed date formats.
func Parse(ds string) (t time.Time, err error) {
timestamp, err := strconv.ParseInt(ds, 10, 64)
if err == nil {
return time.Unix(timestamp, 0), nil
}
ds = replaceNonEnglishWords(ds)
d := strings.TrimSpace(ds)
if d == "" {
return t, errors.New("date parser: empty value")
@@ -211,3 +220,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)
}
+6 -1
View File
@@ -47,11 +47,16 @@ 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",
"1520932969",
}
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)
}
}
}
+41 -22
View File
@@ -9,7 +9,8 @@ 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"
"github.com/miniflux/miniflux/reader/icon"
@@ -20,17 +21,18 @@ import (
var (
errRequestFailed = "Unable to execute request: %v"
errServerFailure = "Unable to fetch feed (statusCode=%d)."
errDuplicate = "This feed already exists (%s)."
errServerFailure = "Unable to fetch feed (statusCode=%d)"
errDuplicate = "This feed already exists (%s)"
errNotFound = "Feed %d not found"
errEncoding = "Unable to normalize encoding: %v."
errCategoryNotFound = "Category not found for this user."
errEncoding = "Unable to normalize encoding: %q"
errCategoryNotFound = "Category not found for this user"
errEmptyFeed = "This feed is empty"
)
// Handler contains all the logic to create and refresh feeds.
type Handler struct {
store *storage.Storage
store *storage.Storage
translator *locale.Translator
}
// CreateFeed fetch, parse and store a new feed.
@@ -41,9 +43,12 @@ 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
}
return nil, errors.NewLocalizedError(errRequestFailed, err)
}
@@ -65,9 +70,9 @@ func (h *Handler) CreateFeed(userID, categoryID int64, url string, crawler bool)
return nil, errors.NewLocalizedError(errEncoding, err)
}
subscription, err := parseFeed(body)
if err != nil {
return nil, err
subscription, feedErr := parseFeed(body)
if feedErr != nil {
return nil, feedErr
}
feedProcessor := processor.NewFeedProcessor(userID, h.store, subscription)
@@ -107,6 +112,13 @@ func (h *Handler) CreateFeed(userID, categoryID int64, url string, crawler bool)
// RefreshFeed fetch and update a feed if necessary.
func (h *Handler) RefreshFeed(userID, feedID int64) error {
defer timer.ExecutionTime(time.Now(), fmt.Sprintf("[Handler:RefreshFeed] feedID=%d", feedID))
userLanguage, err := h.store.UserLanguage(userID)
if err != nil {
logger.Error("[Handler:RefreshFeed] %v", err)
userLanguage = "en_US"
}
currentLanguage := h.translator.GetLanguage(userLanguage)
originalFeed, err := h.store.FeedByID(userID, feedID)
if err != nil {
@@ -117,12 +129,19 @@ 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 {
customErr := errors.NewLocalizedError(errRequestFailed, err)
var customErr errors.LocalizedError
if lerr, ok := err.(*errors.LocalizedError); ok {
customErr = *lerr
} else {
customErr = *errors.NewLocalizedError(errRequestFailed, err)
}
originalFeed.ParsingErrorCount++
originalFeed.ParsingErrorMsg = customErr.Error()
originalFeed.ParsingErrorMsg = customErr.Localize(currentLanguage)
h.store.UpdateFeed(originalFeed)
return customErr
}
@@ -132,7 +151,7 @@ func (h *Handler) RefreshFeed(userID, feedID int64) error {
if response.HasServerFailure() {
err := errors.NewLocalizedError(errServerFailure, response.StatusCode)
originalFeed.ParsingErrorCount++
originalFeed.ParsingErrorMsg = err.Error()
originalFeed.ParsingErrorMsg = err.Localize(currentLanguage)
h.store.UpdateFeed(originalFeed)
return err
}
@@ -144,7 +163,7 @@ func (h *Handler) RefreshFeed(userID, feedID int64) error {
if response.ContentLength == 0 {
err := errors.NewLocalizedError(errEmptyFeed)
originalFeed.ParsingErrorCount++
originalFeed.ParsingErrorMsg = err.Error()
originalFeed.ParsingErrorMsg = err.Localize(currentLanguage)
h.store.UpdateFeed(originalFeed)
return err
}
@@ -154,10 +173,10 @@ func (h *Handler) RefreshFeed(userID, feedID int64) error {
return errors.NewLocalizedError(errEncoding, err)
}
subscription, err := parseFeed(body)
if err != nil {
subscription, parseErr := parseFeed(body)
if parseErr != nil {
originalFeed.ParsingErrorCount++
originalFeed.ParsingErrorMsg = err.Error()
originalFeed.ParsingErrorMsg = parseErr.Localize(currentLanguage)
h.store.UpdateFeed(originalFeed)
return err
}
@@ -200,6 +219,6 @@ func (h *Handler) RefreshFeed(userID, feedID int64) error {
}
// NewFeedHandler returns a feed handler.
func NewFeedHandler(store *storage.Storage) *Handler {
return &Handler{store: store}
func NewFeedHandler(store *storage.Storage, translator *locale.Translator) *Handler {
return &Handler{store, translator}
}
+33 -5
View File
@@ -7,11 +7,12 @@ package feed
import (
"bytes"
"encoding/xml"
"errors"
"io"
"strings"
"time"
"github.com/miniflux/miniflux/errors"
"github.com/miniflux/miniflux/logger"
"github.com/miniflux/miniflux/model"
"github.com/miniflux/miniflux/reader/atom"
"github.com/miniflux/miniflux/reader/encoding"
@@ -65,13 +66,17 @@ func DetectFeedFormat(r io.Reader) string {
return FormatUnknown
}
func parseFeed(r io.Reader) (*model.Feed, error) {
func parseFeed(r io.Reader) (*model.Feed, *errors.LocalizedError) {
defer timer.ExecutionTime(time.Now(), "[Feed:ParseFeed]")
var buffer bytes.Buffer
io.Copy(&buffer, r)
size, _ := io.Copy(&buffer, r)
if size == 0 {
return nil, errors.NewLocalizedError("This feed is empty")
}
reader := bytes.NewReader(buffer.Bytes())
str := stripInvalidXMLCharacters(buffer.String())
reader := strings.NewReader(str)
format := DetectFeedFormat(reader)
reader.Seek(0, io.SeekStart)
@@ -85,6 +90,29 @@ func parseFeed(r io.Reader) (*model.Feed, error) {
case FormatRDF:
return rdf.Parse(reader)
default:
return nil, errors.New("Unsupported feed format")
return nil, errors.NewLocalizedError("Unsupported feed format")
}
}
func stripInvalidXMLCharacters(input string) string {
return strings.Map(func(r rune) rune {
if isInCharacterRange(r) {
return r
}
logger.Debug("Strip invalid XML characters: %U", r)
return -1
}, input)
}
// Decide whether the given rune is in the XML Character Range, per
// the Char production of http://www.xml.com/axml/testaxml.htm,
// Section 2.2 Characters.
func isInCharacterRange(r rune) (inrange bool) {
return r == 0x09 ||
r == 0x0A ||
r == 0x0D ||
r >= 0x20 && r <= 0xDF77 ||
r >= 0xE000 && r <= 0xFFFD ||
r >= 0x10000 && r <= 0x10FFFF
}
+7
View File
@@ -205,3 +205,10 @@ func TestParseUnknownFeed(t *testing.T) {
t.Error("ParseFeed must returns an error")
}
}
func TestParseEmptyFeed(t *testing.T) {
_, err := parseFeed(bytes.NewBufferString(""))
if err == nil {
t.Error("ParseFeed must returns an error")
}
}
+5 -5
View File
@@ -12,7 +12,7 @@ import (
"strings"
"github.com/miniflux/miniflux/crypto"
"github.com/miniflux/miniflux/http"
"github.com/miniflux/miniflux/http/client"
"github.com/miniflux/miniflux/logger"
"github.com/miniflux/miniflux/model"
"github.com/miniflux/miniflux/url"
@@ -23,8 +23,8 @@ import (
// FindIcon try to find the website's icon.
func FindIcon(websiteURL string) (*model.Icon, error) {
rootURL := url.RootURL(websiteURL)
client := http.NewClient(rootURL)
response, err := client.Get()
clt := client.New(rootURL)
response, err := clt.Get()
if err != nil {
return nil, fmt.Errorf("unable to download website index page: %v", err)
}
@@ -87,8 +87,8 @@ func parseDocument(websiteURL string, data io.Reader) (string, error) {
}
func downloadIcon(iconURL string) (*model.Icon, error) {
client := http.NewClient(iconURL)
response, err := client.Get()
clt := client.New(iconURL)
response, err := clt.Get()
if err != nil {
return nil, fmt.Errorf("unable to download iconURL: %v", err)
}
+1 -1
View File
@@ -47,7 +47,7 @@ type jsonAttachment struct {
URL string `json:"url"`
MimeType string `json:"mime_type"`
Title string `json:"title"`
Size int `json:"size_in_bytes"`
Size int64 `json:"size_in_bytes"`
Duration int `json:"duration_in_seconds"`
}
+2 -2
View File
@@ -13,11 +13,11 @@ import (
)
// Parse returns a normalized feed struct from a JON feed.
func Parse(data io.Reader) (*model.Feed, error) {
func Parse(data io.Reader) (*model.Feed, *errors.LocalizedError) {
feed := new(jsonFeed)
decoder := json.NewDecoder(data)
if err := decoder.Decode(&feed); err != nil {
return nil, errors.NewLocalizedError("Unable to parse JSON Feed: %v", err)
return nil, errors.NewLocalizedError("Unable to parse JSON Feed: %q", err)
}
return feed.Transform(), nil
-6
View File
@@ -9,8 +9,6 @@ import (
"strings"
"testing"
"time"
"github.com/miniflux/miniflux/errors"
)
func TestParseJsonFeed(t *testing.T) {
@@ -377,8 +375,4 @@ func TestParseInvalidJSON(t *testing.T) {
if err == nil {
t.Error("Parse should returns an error")
}
if _, ok := err.(errors.LocalizedError); !ok {
t.Error("The error returned must be a LocalizedError")
}
}
+4 -4
View File
@@ -23,8 +23,7 @@ type Handler struct {
func (h *Handler) Export(userID int64) (string, error) {
feeds, err := h.store.Feeds(userID)
if err != nil {
logger.Error("[OPML:Export] %v", err)
return "", errors.New("unable to fetch feeds")
return "", err
}
var subscriptions SubcriptionList
@@ -41,7 +40,7 @@ func (h *Handler) Export(userID int64) (string, error) {
}
// Import parses and create feeds from an OPML import.
func (h *Handler) Import(userID int64, data io.Reader) (err error) {
func (h *Handler) Import(userID int64, data io.Reader) error {
subscriptions, err := Parse(data)
if err != nil {
return err
@@ -50,6 +49,7 @@ func (h *Handler) Import(userID int64, data io.Reader) (err error) {
for _, subscription := range subscriptions {
if !h.store.FeedURLExists(userID, subscription.FeedURL) {
var category *model.Category
var err error
if subscription.CategoryName == "" {
category, err = h.store.FirstCategory(userID)
@@ -73,7 +73,7 @@ func (h *Handler) Import(userID int64, data io.Reader) (err error) {
err := h.store.CreateCategory(category)
if err != nil {
logger.Error("[OPML:Import] %v", err)
return fmt.Errorf(`unable to create this category: "%s"`, subscription.CategoryName)
return fmt.Errorf(`unable to create this category: %q`, subscription.CategoryName)
}
}
}
+2 -2
View File
@@ -13,14 +13,14 @@ import (
)
// Parse reads an OPML file and returns a SubcriptionList.
func Parse(data io.Reader) (SubcriptionList, error) {
func Parse(data io.Reader) (SubcriptionList, *errors.LocalizedError) {
feeds := new(opml)
decoder := xml.NewDecoder(data)
decoder.CharsetReader = encoding.CharsetReader
err := decoder.Decode(feeds)
if err != nil {
return nil, errors.NewLocalizedError("Unable to parse OPML file: %v.", err)
return nil, errors.NewLocalizedError("Unable to parse OPML file: %q", err)
}
return feeds.Transform(), nil
-6
View File
@@ -7,8 +7,6 @@ package opml
import (
"bytes"
"testing"
"github.com/miniflux/miniflux/errors"
)
func TestParseOpmlWithoutCategories(t *testing.T) {
@@ -130,8 +128,4 @@ func TestParseInvalidXML(t *testing.T) {
if err == nil {
t.Error("Parse should generate an error")
}
if _, ok := err.(errors.LocalizedError); !ok {
t.Error("The error returned must be a LocalizedError")
}
}
+2 -2
View File
@@ -14,14 +14,14 @@ import (
)
// Parse returns a normalized feed struct from a RDF feed.
func Parse(data io.Reader) (*model.Feed, error) {
func Parse(data io.Reader) (*model.Feed, *errors.LocalizedError) {
feed := new(rdfFeed)
decoder := xml.NewDecoder(data)
decoder.CharsetReader = encoding.CharsetReader
err := decoder.Decode(feed)
if err != nil {
return nil, errors.NewLocalizedError("Unable to parse RDF feed: %v.", err)
return nil, errors.NewLocalizedError("Unable to parse RDF feed: %q", err)
}
return feed.Transform(), nil
+55 -6
View File
@@ -9,8 +9,6 @@ import (
"strings"
"testing"
"time"
"github.com/miniflux/miniflux/errors"
)
func TestParseRDFSample(t *testing.T) {
@@ -324,14 +322,65 @@ 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))
if err == nil {
t.Error("Parse should returns an error")
}
if _, ok := err.(errors.LocalizedError); !ok {
t.Error("The error returned must be a LocalizedError")
}
}
+18 -1
View File
@@ -10,7 +10,9 @@ import (
"time"
"github.com/miniflux/miniflux/crypto"
"github.com/miniflux/miniflux/logger"
"github.com/miniflux/miniflux/model"
"github.com/miniflux/miniflux/reader/date"
"github.com/miniflux/miniflux/reader/sanitizer"
"github.com/miniflux/miniflux/url"
)
@@ -54,6 +56,7 @@ type rdfItem struct {
Link string `xml:"link"`
Description string `xml:"description"`
Creator string `xml:"creator"`
Date string `xml:"date"`
}
func (r *rdfItem) Transform() *model.Entry {
@@ -63,10 +66,24 @@ func (r *rdfItem) Transform() *model.Entry {
entry.URL = r.Link
entry.Content = r.Description
entry.Hash = getHash(r)
entry.Date = time.Now()
entry.Date = getDate(r)
return entry
}
func getDate(r *rdfItem) time.Time {
if r.Date != "" {
result, err := date.Parse(r.Date)
if err != nil {
logger.Error("rdf: %v", err)
return time.Now()
}
return result
}
return time.Now()
}
func getHash(r *rdfItem) string {
value := r.Link
if value == "" {
+2 -2
View File
@@ -14,14 +14,14 @@ import (
)
// Parse returns a normalized feed struct from a RSS feed.
func Parse(data io.Reader) (*model.Feed, error) {
func Parse(data io.Reader) (*model.Feed, *errors.LocalizedError) {
feed := new(rssFeed)
decoder := xml.NewDecoder(data)
decoder.CharsetReader = encoding.CharsetReader
err := decoder.Decode(feed)
if err != nil {
return nil, errors.NewLocalizedError("Unable to parse RSS feed: %v.", err)
return nil, errors.NewLocalizedError("Unable to parse RSS feed: %q", err)
}
return feed.Transform(), nil
+51 -6
View File
@@ -8,8 +8,6 @@ import (
"bytes"
"testing"
"time"
"github.com/miniflux/miniflux/errors"
)
func TestParseRss2Sample(t *testing.T) {
@@ -232,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">
@@ -558,14 +581,36 @@ 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))
if err == nil {
t.Error("Parse should returns an error")
}
if _, ok := err.(errors.LocalizedError); !ok {
t.Error("The error returned must be a LocalizedError")
}
}
+56 -37
View File
@@ -15,6 +15,7 @@ import (
"github.com/miniflux/miniflux/logger"
"github.com/miniflux/miniflux/model"
"github.com/miniflux/miniflux/reader/date"
"github.com/miniflux/miniflux/reader/sanitizer"
"github.com/miniflux/miniflux/url"
)
@@ -37,25 +38,16 @@ type rssLink struct {
Rel string `xml:"rel,attr"`
}
type rssItem struct {
GUID string `xml:"guid"`
Title string `xml:"title"`
Links []rssLink `xml:"link"`
OriginalLink string `xml:"http://rssnamespace.org/feedburner/ext/1.0 origLink"`
Description string `xml:"description"`
Content string `xml:"http://purl.org/rss/1.0/modules/content/ encoded"`
PubDate string `xml:"pubDate"`
Date string `xml:"http://purl.org/dc/elements/1.1/ date"`
Authors []rssAuthor `xml:"author"`
Creator string `xml:"http://purl.org/dc/elements/1.1/ creator"`
Enclosures []rssEnclosure `xml:"enclosure"`
OrigEnclosureLink string `xml:"http://rssnamespace.org/feedburner/ext/1.0 origEnclosureLink"`
type rssCommentLink struct {
XMLName xml.Name
Data string `xml:",chardata"`
}
type rssAuthor struct {
XMLName xml.Name
Data string `xml:",chardata"`
Name string `xml:"name"`
Inner string `xml:",innerxml"`
}
type rssEnclosure struct {
@@ -64,7 +56,23 @@ type rssEnclosure struct {
Length string `xml:"length,attr"`
}
func (r *rssFeed) GetSiteURL() string {
type rssItem struct {
GUID string `xml:"guid"`
Title string `xml:"title"`
Links []rssLink `xml:"link"`
OriginalLink string `xml:"http://rssnamespace.org/feedburner/ext/1.0 origLink"`
CommentLinks []rssCommentLink `xml:"comments"`
Description string `xml:"description"`
EncodedContent string `xml:"http://purl.org/rss/1.0/modules/content/ encoded"`
PubDate string `xml:"pubDate"`
Date string `xml:"http://purl.org/dc/elements/1.1/ date"`
Authors []rssAuthor `xml:"author"`
Creator string `xml:"http://purl.org/dc/elements/1.1/ creator"`
EnclosureLinks []rssEnclosure `xml:"enclosure"`
OrigEnclosureLink string `xml:"http://rssnamespace.org/feedburner/ext/1.0 origEnclosureLink"`
}
func (r *rssFeed) SiteURL() string {
for _, element := range r.Links {
if element.XMLName.Space == "" {
return strings.TrimSpace(element.Data)
@@ -74,7 +82,7 @@ func (r *rssFeed) GetSiteURL() string {
return ""
}
func (r *rssFeed) GetFeedURL() string {
func (r *rssFeed) FeedURL() string {
for _, element := range r.Links {
if element.XMLName.Space == "http://www.w3.org/2005/Atom" {
return strings.TrimSpace(element.Href)
@@ -86,8 +94,8 @@ func (r *rssFeed) GetFeedURL() string {
func (r *rssFeed) Transform() *model.Feed {
feed := new(model.Feed)
feed.SiteURL = r.GetSiteURL()
feed.FeedURL = r.GetFeedURL()
feed.SiteURL = r.SiteURL()
feed.FeedURL = r.FeedURL()
feed.Title = strings.TrimSpace(r.Title)
if feed.Title == "" {
@@ -100,7 +108,7 @@ func (r *rssFeed) Transform() *model.Feed {
if entry.Author == "" && r.ItunesAuthor != "" {
entry.Author = r.ItunesAuthor
}
entry.Author = strings.TrimSpace(entry.Author)
entry.Author = strings.TrimSpace(sanitizer.StripTags(entry.Author))
if entry.URL == "" {
entry.URL = feed.SiteURL
@@ -121,7 +129,7 @@ func (r *rssFeed) Transform() *model.Feed {
return feed
}
func (r *rssItem) GetDate() time.Time {
func (r *rssItem) PublishedDate() time.Time {
value := r.PubDate
if r.Date != "" {
value = r.Date
@@ -140,22 +148,22 @@ func (r *rssItem) GetDate() time.Time {
return time.Now()
}
func (r *rssItem) GetAuthor() string {
func (r *rssItem) Author() string {
for _, element := range r.Authors {
if element.Name != "" {
return element.Name
}
if element.Data != "" {
return element.Data
if element.Inner != "" {
return element.Inner
}
}
return r.Creator
}
func (r *rssItem) GetHash() string {
for _, value := range []string{r.GUID, r.GetURL()} {
func (r *rssItem) Hash() string {
for _, value := range []string{r.GUID, r.URL()} {
if value != "" {
return crypto.Hash(value)
}
@@ -164,15 +172,15 @@ func (r *rssItem) GetHash() string {
return ""
}
func (r *rssItem) GetContent() string {
if r.Content != "" {
return r.Content
func (r *rssItem) Content() string {
if r.EncodedContent != "" {
return r.EncodedContent
}
return r.Description
}
func (r *rssItem) GetURL() string {
func (r *rssItem) URL() string {
if r.OriginalLink != "" {
return r.OriginalLink
}
@@ -190,11 +198,11 @@ func (r *rssItem) GetURL() string {
return ""
}
func (r *rssItem) GetEnclosures() model.EnclosureList {
func (r *rssItem) Enclosures() model.EnclosureList {
enclosures := make(model.EnclosureList, 0)
for _, enclosure := range r.Enclosures {
length, _ := strconv.Atoi(enclosure.Length)
for _, enclosure := range r.EnclosureLinks {
length, _ := strconv.ParseInt(enclosure.Length, 10, 0)
enclosureURL := enclosure.URL
if r.OrigEnclosureLink != "" {
@@ -214,15 +222,26 @@ func (r *rssItem) GetEnclosures() model.EnclosureList {
return enclosures
}
func (r *rssItem) CommentsURL() string {
for _, commentLink := range r.CommentLinks {
if commentLink.XMLName.Space == "" {
return strings.TrimSpace(commentLink.Data)
}
}
return ""
}
func (r *rssItem) Transform() *model.Entry {
entry := new(model.Entry)
entry.URL = r.GetURL()
entry.Date = r.GetDate()
entry.Author = r.GetAuthor()
entry.Hash = r.GetHash()
entry.Content = r.GetContent()
entry.URL = r.URL()
entry.CommentsURL = r.CommentsURL()
entry.Date = r.PublishedDate()
entry.Author = r.Author()
entry.Hash = r.Hash()
entry.Content = r.Content()
entry.Title = strings.TrimSpace(r.Title)
entry.Enclosures = r.GetEnclosures()
entry.Enclosures = r.Enclosures()
return entry
}
+6
View File
@@ -280,6 +280,12 @@ func isValidIframeSource(src string) bool {
"https://www.dailymotion.com",
"http://vk.com",
"https://vk.com",
"http://soundcloud.com",
"https://soundcloud.com",
"http://w.soundcloud.com",
"https://w.soundcloud.com",
"http://bandcamp.com",
"https://bandcamp.com",
}
for _, prefix := range whitelist {
+4 -4
View File
@@ -11,7 +11,7 @@ import (
"strings"
"github.com/PuerkitoBio/goquery"
"github.com/miniflux/miniflux/http"
"github.com/miniflux/miniflux/http/client"
"github.com/miniflux/miniflux/logger"
"github.com/miniflux/miniflux/reader/readability"
"github.com/miniflux/miniflux/url"
@@ -19,8 +19,8 @@ import (
// Fetch downloads a web page a returns relevant contents.
func Fetch(websiteURL, rules string) (string, error) {
client := http.NewClient(websiteURL)
response, err := client.Get()
clt := client.New(websiteURL)
response, err := clt.Get()
if err != nil {
return "", err
}
@@ -72,7 +72,7 @@ func scrapContent(page io.Reader, rules string) (string, error) {
var content string
// For some inline elements, we get the parent.
if s.Is("img") {
if s.Is("img") || s.Is("iframe") {
content, _ = s.Parent().Html()
} else {
content, _ = s.Html()
+17 -4
View File
@@ -11,7 +11,7 @@ import (
"time"
"github.com/miniflux/miniflux/errors"
"github.com/miniflux/miniflux/http"
"github.com/miniflux/miniflux/http/client"
"github.com/miniflux/miniflux/logger"
"github.com/miniflux/miniflux/reader/feed"
"github.com/miniflux/miniflux/timer"
@@ -23,25 +23,38 @@ import (
var (
errConnectionFailure = "Unable to open this link: %v"
errUnreadableDoc = "Unable to analyze this page: %v"
errEmptyBody = "This web page is empty"
)
// FindSubscriptions downloads and try to find one or more subscriptions from an URL.
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
}
return nil, errors.NewLocalizedError(errConnectionFailure, err)
}
// Content-Length = -1 when no Content-Length header is sent
if response.ContentLength == 0 {
return nil, errors.NewLocalizedError(errEmptyBody)
}
body, err := response.NormalizeBodyEncoding()
if err != nil {
return nil, err
}
var buffer bytes.Buffer
io.Copy(&buffer, body)
size, _ := io.Copy(&buffer, body)
if size == 0 {
return nil, errors.NewLocalizedError(errEmptyBody)
}
reader := bytes.NewReader(buffer.Bytes())
if format := feed.DetectFeedFormat(reader); format != feed.FormatUnknown {
+9 -5
View File
@@ -15,7 +15,7 @@ import (
func NewFeedScheduler(store *storage.Storage, workerPool *WorkerPool, frequency, batchSize int) {
go func() {
c := time.Tick(time.Duration(frequency) * time.Minute)
for _ = range c {
for range c {
jobs, err := store.NewBatch(batchSize)
if err != nil {
logger.Error("[FeedScheduler] %v", err)
@@ -27,14 +27,18 @@ func NewFeedScheduler(store *storage.Storage, workerPool *WorkerPool, frequency,
}()
}
// NewSessionScheduler starts a new scheduler that clean old sessions.
func NewSessionScheduler(store *storage.Storage, frequency int) {
// NewCleanupScheduler starts a new scheduler that clean old sessions and archive read items.
func NewCleanupScheduler(store *storage.Storage, frequency int) {
go func() {
c := time.Tick(time.Duration(frequency) * time.Hour)
for _ = range c {
for range c {
nbSessions := store.CleanOldSessions()
nbUserSessions := store.CleanOldUserSessions()
logger.Info("[SessionScheduler] cleaned %d sessions and %d user sessions", nbSessions, nbUserSessions)
logger.Info("[CleanupScheduler] Cleaned %d sessions and %d user sessions", nbSessions, nbUserSessions)
if err := store.ArchiveEntries(); err != nil {
logger.Error("[CleanupScheduler] %v", err)
}
}
}()
}
+2
View File
@@ -0,0 +1,2 @@
create index entries_user_status_idx on entries(user_id, status);
create index feeds_user_category_idx on feeds(user_id, category_id);
+3
View File
@@ -0,0 +1,3 @@
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 '';
+1
View File
@@ -0,0 +1 @@
alter table enclosures alter column size set data type bigint;

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