Compare commits
110 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 29131d4ee6 | |||
| cc1e4f916f | |||
| 240f801755 | |||
| 4c7fce0233 | |||
| 5cf504745b | |||
| 9c0f882ba0 | |||
| a40f592aab | |||
| 77947282e4 | |||
| 0b0f4751fa | |||
| 0227bb9828 | |||
| 9d8b5421ed | |||
| dd78fb4340 | |||
| 45d7105ed1 | |||
| c9131b0e89 | |||
| d847b10e32 | |||
| 7039df9af1 | |||
| cd77ebd742 | |||
| eb9d23c796 | |||
| bddca15b69 | |||
| 261695c14c | |||
| b5c317f971 | |||
| 7914261437 | |||
| be95e87e0e | |||
| c719cf7df0 | |||
| 36dab8b518 | |||
| c5373ff2bf | |||
| bd70640794 | |||
| 2bbe21fb89 | |||
| 084642eb0a | |||
| 11d880087d | |||
| 77cafe9c2c | |||
| 3b39f0883c | |||
| cf7a7e25fb | |||
| fb49ad24d5 | |||
| b270159aae | |||
| 44decae557 | |||
| 7f2fd1fdd8 | |||
| 0f3f5e442f | |||
| f19ab21b7d | |||
| ff8e0c6b3d | |||
| b0667e1b78 | |||
| 9554d0bd53 | |||
| e81e4f0ac3 | |||
| 4674d559a4 | |||
| 0c2e5ff0dc | |||
| 39c4452142 | |||
| 2f4cd59ad9 | |||
| 5cacae6cf2 | |||
| 7a1653a2e9 | |||
| 31da4db14f | |||
| b166ceaea7 | |||
| f49b42f70f | |||
| 1eba1730d1 | |||
| 04adf5fdf5 | |||
| ddd3af4b85 | |||
| 6b360d08c1 | |||
| 322b265d7a | |||
| 920dda79b7 | |||
| 1ce522b98a | |||
| 657e96e133 | |||
| 0429bbb19d | |||
| 45dde0cf4a | |||
| 4cdb2f820b | |||
| dcbb5047b1 | |||
| 15202b8675 | |||
| 02ba735ba9 | |||
| 20f874399d | |||
| e2d02bac5a | |||
| f76093690c | |||
| 7640a8cbab | |||
| 3d59cdba10 | |||
| d3855fef3f | |||
| 336d44b00e | |||
| 46d67acf22 | |||
| 702256bcc0 | |||
| 538d08c16c | |||
| 449020c1e8 | |||
| c811849771 | |||
| 6ea4da3bce | |||
| 34cdffda88 | |||
| b1da081ae6 | |||
| 482785c5e6 | |||
| fec391a336 | |||
| ec08f45bf5 | |||
| dd68181a83 | |||
| a8be61cdbb | |||
| 609c57332e | |||
| 5185bf0c7e | |||
| cbd273da2b | |||
| 0c7039de0e | |||
| f110384f11 | |||
| 953d0a2dc0 | |||
| 9694861cb6 | |||
| 34ce114231 | |||
| 3030145b30 | |||
| a9f0fdaf22 | |||
| 73a0a25b6c | |||
| 2786b8f163 | |||
| 9292d5d604 | |||
| 3ba280e10c | |||
| 4eceeaaca4 | |||
| 205aef595b | |||
| a006a93a04 | |||
| 30e80c675e | |||
| dda9114692 | |||
| 16c2dc4a8c | |||
| 7b0bfd9308 | |||
| c6fd9eb9b1 | |||
| 0fb87eba3f | |||
| 1e70ca1a19 |
+1
-4
@@ -1,4 +1 @@
|
||||
miniflux-linux-amd64
|
||||
miniflux-linux-arm*
|
||||
miniflux-darwin-amd64
|
||||
miniflux-test
|
||||
miniflux-*
|
||||
|
||||
+1
-1
@@ -6,7 +6,7 @@ addons:
|
||||
postgresql: "9.4"
|
||||
language: go
|
||||
go:
|
||||
- 1.9
|
||||
- "1.10"
|
||||
before_install:
|
||||
- npm install -g jshint
|
||||
- go get -u github.com/golang/lint/golint
|
||||
|
||||
@@ -1,3 +1,123 @@
|
||||
Version 2.0.9 (July 1, 2018)
|
||||
----------------------------
|
||||
|
||||
* Avoid Chrome to autocomplete no-login password fields
|
||||
* Add cli flag to reset all feed errors
|
||||
* Do not ignore errored feeds when a user refresh feeds manually
|
||||
* Add specific 404 and 401 error messages
|
||||
* Strip binaries to reduce size
|
||||
* Make sure we always get the pagination in unread mode
|
||||
* Fix incorrect data value when toggling bookmark flag on entry page
|
||||
* Set opener to null when opening original URL with JavaScript
|
||||
* Remove unnecessary style
|
||||
* Refactor AddImageTitle rewriter
|
||||
* Only processes images with `src` **and** `title` attributes (others are ignored)
|
||||
* Processes **all** images in the document (not just the first one)
|
||||
* Wraps the image and its title attribute in a `figure` tag with the title attribute's contents in a `figcaption` tag
|
||||
* Improve sanitizer to remove `style`, `noscript` and `script` tag contents
|
||||
* Improve feed and user API updates with optional values
|
||||
* Add new fields for feed username/password
|
||||
* Improve memory usage debug log
|
||||
* Disable keep-alive for HTTP client
|
||||
* Close HTTP response body even for failed requests
|
||||
* Add Sans-Serif theme
|
||||
* Rewrite iframe Youtube URLs to https://www.youtube-nocookie.com
|
||||
* Add more filters for API call `/entries`:
|
||||
* before (unix timestamp)
|
||||
* before_entry_id (int64)
|
||||
* after (unix timestamp)
|
||||
* after_entry_id (int64)
|
||||
* starred (boolean)
|
||||
* Rewrite individual entry pagination SQL queries
|
||||
* Simplify entry query builder
|
||||
* Prevent items from sticking on touchend
|
||||
* Extended horizontal overflow to feed and category views
|
||||
* Translate missing strings
|
||||
* Update German translation
|
||||
|
||||
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
@@ -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 = "cfb76c59831603c2b57e03356e9241facfa9834e"
|
||||
|
||||
[[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
@@ -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"
|
||||
|
||||
@@ -4,24 +4,28 @@ 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
|
||||
@ GOOS=linux GOARCH=amd64 go build -ldflags="-X 'github.com/miniflux/miniflux/version.Version=$(VERSION)' -X 'github.com/miniflux/miniflux/version.BuildDate=$(BUILD_DATE)'" -o $(APP)-linux-amd64 main.go
|
||||
@ GOOS=linux GOARCH=amd64 go build -ldflags="-s -w -X 'github.com/miniflux/miniflux/version.Version=$(VERSION)' -X 'github.com/miniflux/miniflux/version.BuildDate=$(BUILD_DATE)'" -o $(APP)-linux-amd64 main.go
|
||||
|
||||
linux-arm:
|
||||
@ go generate
|
||||
@ GOOS=linux GOARCH=arm64 go build -ldflags="-X 'github.com/miniflux/miniflux/version.Version=$(VERSION)' -X 'github.com/miniflux/miniflux/version.BuildDate=$(BUILD_DATE)'" -o $(APP)-linux-armv8 main.go
|
||||
@ GOOS=linux GOARCH=arm GOARM=7 go build -ldflags="-X 'github.com/miniflux/miniflux/version.Version=$(VERSION)' -X 'github.com/miniflux/miniflux/version.BuildDate=$(BUILD_DATE)'" -o $(APP)-linux-armv7 main.go
|
||||
@ GOOS=linux GOARCH=arm GOARM=6 go build -ldflags="-X 'github.com/miniflux/miniflux/version.Version=$(VERSION)' -X 'github.com/miniflux/miniflux/version.BuildDate=$(BUILD_DATE)'" -o $(APP)-linux-armv6 main.go
|
||||
@ GOOS=linux GOARCH=arm GOARM=5 go build -ldflags="-X 'github.com/miniflux/miniflux/version.Version=$(VERSION)' -X 'github.com/miniflux/miniflux/version.BuildDate=$(BUILD_DATE)'" -o $(APP)-linux-armv5 main.go
|
||||
@ GOOS=linux GOARCH=arm64 go build -ldflags="-s -w -X 'github.com/miniflux/miniflux/version.Version=$(VERSION)' -X 'github.com/miniflux/miniflux/version.BuildDate=$(BUILD_DATE)'" -o $(APP)-linux-armv8 main.go
|
||||
@ GOOS=linux GOARCH=arm GOARM=7 go build -ldflags="-s -w -X 'github.com/miniflux/miniflux/version.Version=$(VERSION)' -X 'github.com/miniflux/miniflux/version.BuildDate=$(BUILD_DATE)'" -o $(APP)-linux-armv7 main.go
|
||||
@ GOOS=linux GOARCH=arm GOARM=6 go build -ldflags="-s -w -X 'github.com/miniflux/miniflux/version.Version=$(VERSION)' -X 'github.com/miniflux/miniflux/version.BuildDate=$(BUILD_DATE)'" -o $(APP)-linux-armv6 main.go
|
||||
@ GOOS=linux GOARCH=arm GOARM=5 go build -ldflags="-s -w -X 'github.com/miniflux/miniflux/version.Version=$(VERSION)' -X 'github.com/miniflux/miniflux/version.BuildDate=$(BUILD_DATE)'" -o $(APP)-linux-armv5 main.go
|
||||
|
||||
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
|
||||
@ GOOS=darwin GOARCH=amd64 go build -ldflags="-s -w -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="-s -w -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
|
||||
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
|
||||
+95
-67
@@ -6,218 +6,246 @@ package api
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/miniflux/miniflux/http/handler"
|
||||
"github.com/miniflux/miniflux/http/context"
|
||||
"github.com/miniflux/miniflux/http/request"
|
||||
"github.com/miniflux/miniflux/http/response/json"
|
||||
"github.com/miniflux/miniflux/model"
|
||||
"github.com/miniflux/miniflux/storage"
|
||||
)
|
||||
|
||||
// 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)
|
||||
builder.WithDirection(direction)
|
||||
builder.WithOffset(offset)
|
||||
builder.WithLimit(limit)
|
||||
configureFilters(builder, r)
|
||||
|
||||
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)
|
||||
builder.WithOffset(offset)
|
||||
builder.WithLimit(limit)
|
||||
configureFilters(builder, r)
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
func configureFilters(builder *storage.EntryQueryBuilder, r *http.Request) {
|
||||
beforeEntryID := request.QueryInt64Param(r, "before_entry_id", 0)
|
||||
if beforeEntryID != 0 {
|
||||
builder.BeforeEntryID(beforeEntryID)
|
||||
}
|
||||
|
||||
afterEntryID := request.QueryInt64Param(r, "after_entry_id", 0)
|
||||
if afterEntryID != 0 {
|
||||
builder.AfterEntryID(afterEntryID)
|
||||
}
|
||||
|
||||
beforeTimestamp := request.QueryInt64Param(r, "before", 0)
|
||||
if beforeTimestamp != 0 {
|
||||
builder.BeforeDate(time.Unix(beforeTimestamp, 0))
|
||||
}
|
||||
|
||||
afterTimestamp := request.QueryInt64Param(r, "after", 0)
|
||||
if afterTimestamp != 0 {
|
||||
builder.AfterDate(time.Unix(afterTimestamp, 0))
|
||||
}
|
||||
|
||||
if request.HasQueryParam(r, "starred") {
|
||||
builder.WithStarred()
|
||||
}
|
||||
}
|
||||
|
||||
+98
-94
@@ -6,44 +6,54 @@ 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) {
|
||||
func (c *Controller) CreateFeed(w http.ResponseWriter, r *http.Request) {
|
||||
feedInfo, err := decodeFeedCreationPayload(r.Body)
|
||||
if err != nil {
|
||||
json.BadRequest(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
if feedInfo.FeedURL == "" {
|
||||
json.BadRequest(w, errors.New("The feed_url is required"))
|
||||
return
|
||||
}
|
||||
|
||||
if feedInfo.CategoryID <= 0 {
|
||||
json.BadRequest(w, errors.New("The category_id is required"))
|
||||
return
|
||||
}
|
||||
|
||||
ctx := context.New(r)
|
||||
userID := ctx.UserID()
|
||||
feedURL, categoryID, err := decodeFeedCreationPayload(request.Body())
|
||||
|
||||
if c.store.FeedURLExists(userID, feedInfo.FeedURL) {
|
||||
json.BadRequest(w, errors.New("This feed_url already exists"))
|
||||
return
|
||||
}
|
||||
|
||||
if !c.store.CategoryExists(userID, feedInfo.CategoryID) {
|
||||
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,
|
||||
feedInfo.CategoryID,
|
||||
feedInfo.FeedURL,
|
||||
feedInfo.Crawler,
|
||||
feedInfo.Username,
|
||||
feedInfo.Password,
|
||||
)
|
||||
if err != nil {
|
||||
response.JSON().BadRequest(err)
|
||||
return
|
||||
}
|
||||
|
||||
if feedURL == "" {
|
||||
response.JSON().BadRequest(errors.New("The feed_url is required"))
|
||||
return
|
||||
}
|
||||
|
||||
if categoryID <= 0 {
|
||||
response.JSON().BadRequest(errors.New("The category_id is required"))
|
||||
return
|
||||
}
|
||||
|
||||
if c.store.FeedURLExists(userID, feedURL) {
|
||||
response.JSON().BadRequest(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"))
|
||||
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 +61,136 @@ 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) {
|
||||
func (c *Controller) UpdateFeed(w http.ResponseWriter, r *http.Request) {
|
||||
feedID, err := request.IntParam(r, "feedID")
|
||||
if err != nil {
|
||||
json.BadRequest(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
feedChanges, err := decodeFeedModificationPayload(r.Body)
|
||||
if err != nil {
|
||||
json.BadRequest(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
ctx := context.New(r)
|
||||
userID := ctx.UserID()
|
||||
feedID, err := request.IntegerParam("feedID")
|
||||
if err != nil {
|
||||
response.JSON().BadRequest(err)
|
||||
return
|
||||
}
|
||||
|
||||
newFeed, err := decodeFeedModificationPayload(request.Body())
|
||||
if err != nil {
|
||||
response.JSON().BadRequest(err)
|
||||
return
|
||||
}
|
||||
|
||||
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"))
|
||||
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
|
||||
}
|
||||
|
||||
feedChanges.Update(originalFeed)
|
||||
|
||||
if !c.store.CategoryExists(userID, originalFeed.Category.ID) {
|
||||
json.BadRequest(w, errors.New("This category_id doesn't exists or doesn't belongs to this user"))
|
||||
return
|
||||
}
|
||||
|
||||
originalFeed.Merge(newFeed)
|
||||
if err := c.store.UpdateFeed(originalFeed); err != nil {
|
||||
response.JSON().ServerError(errors.New("Unable to update this feed"))
|
||||
json.ServerError(w, errors.New("Unable to update this feed"))
|
||||
return
|
||||
}
|
||||
|
||||
originalFeed, err = c.store.FeedByID(userID, feedID)
|
||||
if err != nil {
|
||||
response.JSON().ServerError(errors.New("Unable to fetch this feed"))
|
||||
json.ServerError(w, errors.New("Unable to fetch this feed"))
|
||||
return
|
||||
}
|
||||
|
||||
response.JSON().Created(originalFeed)
|
||||
json.Created(w, originalFeed)
|
||||
}
|
||||
|
||||
// GetFeeds is the API handler that get all feeds that belongs to the given user.
|
||||
func (c *Controller) GetFeeds(ctx *handler.Context, request *handler.Request, response *handler.Response) {
|
||||
feeds, err := c.store.Feeds(ctx.UserID())
|
||||
func (c *Controller) GetFeeds(w http.ResponseWriter, r *http.Request) {
|
||||
feeds, err := c.store.Feeds(context.New(r).UserID())
|
||||
if err != nil {
|
||||
response.JSON().ServerError(errors.New("Unable to fetch feeds from the database"))
|
||||
json.ServerError(w, errors.New("Unable to fetch feeds from the database"))
|
||||
return
|
||||
}
|
||||
|
||||
response.JSON().Standard(feeds)
|
||||
}
|
||||
|
||||
// Export is the API handler that incoves an OPML export.
|
||||
func (c *Controller) Export(ctx *handler.Context, request *handler.Request, response *handler.Response) {
|
||||
opmlHandler := opml.NewHandler(c.store)
|
||||
|
||||
opml, err := opmlHandler.Export(ctx.LoggedUser().ID)
|
||||
if err != nil {
|
||||
response.JSON().ServerError(errors.New("unable to export feeds to OPML"))
|
||||
}
|
||||
|
||||
response.XML().Serve(opml)
|
||||
json.OK(w, feeds)
|
||||
}
|
||||
|
||||
// GetFeed is the API handler to get a feed.
|
||||
func (c *Controller) GetFeed(ctx *handler.Context, request *handler.Request, response *handler.Response) {
|
||||
userID := ctx.UserID()
|
||||
feedID, err := request.IntegerParam("feedID")
|
||||
func (c *Controller) GetFeed(w http.ResponseWriter, r *http.Request) {
|
||||
feedID, err := request.IntParam(r, "feedID")
|
||||
if err != nil {
|
||||
response.JSON().BadRequest(err)
|
||||
json.BadRequest(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
feed, err := c.store.FeedByID(userID, feedID)
|
||||
feed, err := c.store.FeedByID(context.New(r).UserID(), feedID)
|
||||
if err != nil {
|
||||
response.JSON().ServerError(errors.New("Unable to fetch this feed"))
|
||||
json.ServerError(w, errors.New("Unable to fetch this feed"))
|
||||
return
|
||||
}
|
||||
|
||||
if feed == nil {
|
||||
response.JSON().NotFound(errors.New("Feed not found"))
|
||||
json.NotFound(w, errors.New("Feed not found"))
|
||||
return
|
||||
}
|
||||
|
||||
response.JSON().Standard(feed)
|
||||
json.OK(w, feed)
|
||||
}
|
||||
|
||||
// RemoveFeed is the API handler to remove a feed.
|
||||
func (c *Controller) RemoveFeed(ctx *handler.Context, request *handler.Request, response *handler.Response) {
|
||||
userID := ctx.UserID()
|
||||
feedID, err := request.IntegerParam("feedID")
|
||||
func (c *Controller) RemoveFeed(w http.ResponseWriter, r *http.Request) {
|
||||
feedID, err := request.IntParam(r, "feedID")
|
||||
if err != nil {
|
||||
response.JSON().BadRequest(err)
|
||||
json.BadRequest(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
ctx := context.New(r)
|
||||
userID := ctx.UserID()
|
||||
|
||||
if !c.store.FeedExists(userID, feedID) {
|
||||
response.JSON().NotFound(errors.New("Feed not found"))
|
||||
json.NotFound(w, errors.New("Feed not found"))
|
||||
return
|
||||
}
|
||||
|
||||
if err := c.store.RemoveFeed(userID, feedID); err != nil {
|
||||
response.JSON().ServerError(errors.New("Unable to remove this feed"))
|
||||
json.ServerError(w, errors.New("Unable to remove this feed"))
|
||||
return
|
||||
}
|
||||
|
||||
response.JSON().NoContent()
|
||||
json.NoContent(w)
|
||||
}
|
||||
|
||||
+12
-10
@@ -6,36 +6,38 @@ package api
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"github.com/miniflux/miniflux/http/handler"
|
||||
"github.com/miniflux/miniflux/http/context"
|
||||
"github.com/miniflux/miniflux/http/request"
|
||||
"github.com/miniflux/miniflux/http/response/json"
|
||||
)
|
||||
|
||||
// FeedIcon returns a feed icon.
|
||||
func (c *Controller) FeedIcon(ctx *handler.Context, request *handler.Request, response *handler.Response) {
|
||||
userID := ctx.UserID()
|
||||
feedID, err := request.IntegerParam("feedID")
|
||||
func (c *Controller) FeedIcon(w http.ResponseWriter, r *http.Request) {
|
||||
feedID, err := request.IntParam(r, "feedID")
|
||||
if err != nil {
|
||||
response.JSON().BadRequest(err)
|
||||
json.BadRequest(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
if !c.store.HasIcon(feedID) {
|
||||
response.JSON().NotFound(errors.New("This feed doesn't have any icon"))
|
||||
json.NotFound(w, errors.New("This feed doesn't have any icon"))
|
||||
return
|
||||
}
|
||||
|
||||
icon, err := c.store.IconByFeedID(userID, feedID)
|
||||
icon, err := c.store.IconByFeedID(context.New(r).UserID(), feedID)
|
||||
if err != nil {
|
||||
response.JSON().ServerError(errors.New("Unable to fetch feed icon"))
|
||||
json.ServerError(w, errors.New("Unable to fetch feed icon"))
|
||||
return
|
||||
}
|
||||
|
||||
if icon == nil {
|
||||
response.JSON().NotFound(errors.New("This feed doesn't have any icon"))
|
||||
json.NotFound(w, errors.New("This feed doesn't have any icon"))
|
||||
return
|
||||
}
|
||||
|
||||
response.JSON().Standard(&feedIcon{
|
||||
json.OK(w, &feedIcon{
|
||||
ID: icon.ID,
|
||||
MimeType: icon.MimeType,
|
||||
Data: icon.DataURL(),
|
||||
|
||||
+39
@@ -0,0 +1,39 @@
|
||||
// Copyright 2018 Frédéric Guillot. All rights reserved.
|
||||
// Use of this source code is governed by the Apache 2.0
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/miniflux/miniflux/http/context"
|
||||
"github.com/miniflux/miniflux/http/response/json"
|
||||
"github.com/miniflux/miniflux/http/response/xml"
|
||||
"github.com/miniflux/miniflux/reader/opml"
|
||||
)
|
||||
|
||||
// Export is the API handler that export feeds to OPML.
|
||||
func (c *Controller) Export(w http.ResponseWriter, r *http.Request) {
|
||||
opmlHandler := opml.NewHandler(c.store)
|
||||
opml, err := opmlHandler.Export(context.New(r).UserID())
|
||||
if err != nil {
|
||||
json.ServerError(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
xml.OK(w, opml)
|
||||
}
|
||||
|
||||
// Import is the API handler that import an OPML file.
|
||||
func (c *Controller) Import(w http.ResponseWriter, r *http.Request) {
|
||||
opmlHandler := opml.NewHandler(c.store)
|
||||
err := opmlHandler.Import(context.New(r).UserID(), r.Body)
|
||||
defer r.Body.Close()
|
||||
if err != nil {
|
||||
json.ServerError(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
json.Created(w, map[string]string{"message": "Feeds imported successfully"})
|
||||
}
|
||||
+148
-33
@@ -23,39 +23,155 @@ type entriesResponse struct {
|
||||
Entries model.Entries `json:"entries"`
|
||||
}
|
||||
|
||||
func decodeUserPayload(data io.Reader) (*model.User, error) {
|
||||
var user model.User
|
||||
type feedCreation struct {
|
||||
FeedURL string `json:"feed_url"`
|
||||
CategoryID int64 `json:"category_id"`
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
Crawler bool `json:"crawler"`
|
||||
}
|
||||
|
||||
decoder := json.NewDecoder(data)
|
||||
type subscriptionDiscovery struct {
|
||||
URL string `json:"url"`
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
}
|
||||
|
||||
type feedModification struct {
|
||||
FeedURL *string `json:"feed_url"`
|
||||
SiteURL *string `json:"site_url"`
|
||||
Title *string `json:"title"`
|
||||
ScraperRules *string `json:"scraper_rules"`
|
||||
RewriteRules *string `json:"rewrite_rules"`
|
||||
Crawler *bool `json:"crawler"`
|
||||
Username *string `json:"username"`
|
||||
Password *string `json:"password"`
|
||||
CategoryID *int64 `json:"category_id"`
|
||||
}
|
||||
|
||||
func (f *feedModification) Update(feed *model.Feed) {
|
||||
if f.FeedURL != nil && *f.FeedURL != "" {
|
||||
feed.FeedURL = *f.FeedURL
|
||||
}
|
||||
|
||||
if f.SiteURL != nil && *f.SiteURL != "" {
|
||||
feed.SiteURL = *f.SiteURL
|
||||
}
|
||||
|
||||
if f.Title != nil && *f.Title != "" {
|
||||
feed.Title = *f.Title
|
||||
}
|
||||
|
||||
if f.ScraperRules != nil {
|
||||
feed.ScraperRules = *f.ScraperRules
|
||||
}
|
||||
|
||||
if f.RewriteRules != nil {
|
||||
feed.RewriteRules = *f.RewriteRules
|
||||
}
|
||||
|
||||
if f.Crawler != nil {
|
||||
feed.Crawler = *f.Crawler
|
||||
}
|
||||
|
||||
if f.Username != nil {
|
||||
feed.Username = *f.Username
|
||||
}
|
||||
|
||||
if f.Password != nil {
|
||||
feed.Password = *f.Password
|
||||
}
|
||||
|
||||
if f.CategoryID != nil && *f.CategoryID > 0 {
|
||||
feed.Category.ID = *f.CategoryID
|
||||
}
|
||||
}
|
||||
|
||||
type userModification struct {
|
||||
Username *string `json:"username"`
|
||||
Password *string `json:"password"`
|
||||
IsAdmin *bool `json:"is_admin"`
|
||||
Theme *string `json:"theme"`
|
||||
Language *string `json:"language"`
|
||||
Timezone *string `json:"timezone"`
|
||||
EntryDirection *string `json:"entry_sorting_direction"`
|
||||
}
|
||||
|
||||
func (u *userModification) Update(user *model.User) {
|
||||
if u.Username != nil {
|
||||
user.Username = *u.Username
|
||||
}
|
||||
|
||||
if u.Password != nil {
|
||||
user.Password = *u.Password
|
||||
}
|
||||
|
||||
if u.IsAdmin != nil {
|
||||
user.IsAdmin = *u.IsAdmin
|
||||
}
|
||||
|
||||
if u.Theme != nil {
|
||||
user.Theme = *u.Theme
|
||||
}
|
||||
|
||||
if u.Language != nil {
|
||||
user.Language = *u.Language
|
||||
}
|
||||
|
||||
if u.Timezone != nil {
|
||||
user.Timezone = *u.Timezone
|
||||
}
|
||||
|
||||
if u.EntryDirection != nil {
|
||||
user.EntryDirection = *u.EntryDirection
|
||||
}
|
||||
}
|
||||
|
||||
func decodeUserModificationPayload(r io.ReadCloser) (*userModification, error) {
|
||||
defer r.Close()
|
||||
|
||||
var user userModification
|
||||
decoder := json.NewDecoder(r)
|
||||
if err := decoder.Decode(&user); err != nil {
|
||||
return nil, fmt.Errorf("Unable to decode user JSON object: %v", err)
|
||||
return nil, fmt.Errorf("Unable to decode user modification JSON object: %v", err)
|
||||
}
|
||||
|
||||
return &user, nil
|
||||
}
|
||||
|
||||
func decodeURLPayload(data io.Reader) (string, error) {
|
||||
type payload struct {
|
||||
URL string `json:"url"`
|
||||
func decodeUserCreationPayload(r io.ReadCloser) (*model.User, error) {
|
||||
defer r.Close()
|
||||
|
||||
var user model.User
|
||||
decoder := json.NewDecoder(r)
|
||||
if err := decoder.Decode(&user); err != nil {
|
||||
return nil, fmt.Errorf("Unable to decode user modification JSON object: %v", err)
|
||||
}
|
||||
|
||||
var p payload
|
||||
decoder := json.NewDecoder(data)
|
||||
if err := decoder.Decode(&p); err != nil {
|
||||
return "", fmt.Errorf("invalid JSON payload: %v", err)
|
||||
}
|
||||
|
||||
return p.URL, nil
|
||||
return &user, nil
|
||||
}
|
||||
|
||||
func decodeEntryStatusPayload(data io.Reader) ([]int64, string, error) {
|
||||
func decodeURLPayload(r io.ReadCloser) (*subscriptionDiscovery, error) {
|
||||
defer r.Close()
|
||||
|
||||
var s subscriptionDiscovery
|
||||
decoder := json.NewDecoder(r)
|
||||
if err := decoder.Decode(&s); err != nil {
|
||||
return nil, fmt.Errorf("invalid JSON payload: %v", err)
|
||||
}
|
||||
|
||||
return &s, nil
|
||||
}
|
||||
|
||||
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,36 +179,35 @@ func decodeEntryStatusPayload(data io.Reader) ([]int64, string, error) {
|
||||
return p.EntryIDs, p.Status, nil
|
||||
}
|
||||
|
||||
func decodeFeedCreationPayload(data io.Reader) (string, int64, error) {
|
||||
type payload struct {
|
||||
FeedURL string `json:"feed_url"`
|
||||
CategoryID int64 `json:"category_id"`
|
||||
func decodeFeedCreationPayload(r io.ReadCloser) (*feedCreation, error) {
|
||||
defer r.Close()
|
||||
|
||||
var fc feedCreation
|
||||
decoder := json.NewDecoder(r)
|
||||
if err := decoder.Decode(&fc); err != nil {
|
||||
return nil, fmt.Errorf("invalid JSON payload: %v", err)
|
||||
}
|
||||
|
||||
var p payload
|
||||
decoder := json.NewDecoder(data)
|
||||
if err := decoder.Decode(&p); err != nil {
|
||||
return "", 0, fmt.Errorf("invalid JSON payload: %v", err)
|
||||
}
|
||||
|
||||
return p.FeedURL, p.CategoryID, nil
|
||||
return &fc, nil
|
||||
}
|
||||
|
||||
func decodeFeedModificationPayload(data io.Reader) (*model.Feed, error) {
|
||||
var feed model.Feed
|
||||
func decodeFeedModificationPayload(r io.ReadCloser) (*feedModification, error) {
|
||||
defer r.Close()
|
||||
|
||||
decoder := json.NewDecoder(data)
|
||||
var feed feedModification
|
||||
decoder := json.NewDecoder(r)
|
||||
if err := decoder.Decode(&feed); err != nil {
|
||||
return nil, fmt.Errorf("Unable to decode feed JSON object: %v", err)
|
||||
return nil, fmt.Errorf("Unable to decode feed modification JSON object: %v", err)
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,192 @@
|
||||
// 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 (
|
||||
"testing"
|
||||
|
||||
"github.com/miniflux/miniflux/model"
|
||||
)
|
||||
|
||||
func TestUpdateFeedURL(t *testing.T) {
|
||||
feedURL := "http://example.com/"
|
||||
changes := &feedModification{FeedURL: &feedURL}
|
||||
feed := &model.Feed{FeedURL: "http://example.org/"}
|
||||
changes.Update(feed)
|
||||
|
||||
if feed.FeedURL != feedURL {
|
||||
t.Fatalf(`Unexpected value, got %q instead of %q`, feed.FeedURL, feedURL)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateFeedURLWithEmptyString(t *testing.T) {
|
||||
feedURL := ""
|
||||
changes := &feedModification{FeedURL: &feedURL}
|
||||
feed := &model.Feed{FeedURL: "http://example.org/"}
|
||||
changes.Update(feed)
|
||||
|
||||
if feed.FeedURL == feedURL {
|
||||
t.Fatal(`The FeedURL should not be modified`)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateFeedURLWhenNotSet(t *testing.T) {
|
||||
changes := &feedModification{}
|
||||
feed := &model.Feed{FeedURL: "http://example.org/"}
|
||||
changes.Update(feed)
|
||||
|
||||
if feed.FeedURL != "http://example.org/" {
|
||||
t.Fatal(`The FeedURL should not be modified`)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateFeedSiteURL(t *testing.T) {
|
||||
siteURL := "http://example.com/"
|
||||
changes := &feedModification{SiteURL: &siteURL}
|
||||
feed := &model.Feed{SiteURL: "http://example.org/"}
|
||||
changes.Update(feed)
|
||||
|
||||
if feed.SiteURL != siteURL {
|
||||
t.Fatalf(`Unexpected value, got %q instead of %q`, feed.SiteURL, siteURL)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateFeedSiteURLWithEmptyString(t *testing.T) {
|
||||
siteURL := ""
|
||||
changes := &feedModification{FeedURL: &siteURL}
|
||||
feed := &model.Feed{SiteURL: "http://example.org/"}
|
||||
changes.Update(feed)
|
||||
|
||||
if feed.SiteURL == siteURL {
|
||||
t.Fatal(`The FeedURL should not be modified`)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateFeedSiteURLWhenNotSet(t *testing.T) {
|
||||
changes := &feedModification{}
|
||||
feed := &model.Feed{SiteURL: "http://example.org/"}
|
||||
changes.Update(feed)
|
||||
|
||||
if feed.SiteURL != "http://example.org/" {
|
||||
t.Fatal(`The SiteURL should not be modified`)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateFeedTitle(t *testing.T) {
|
||||
title := "Example 2"
|
||||
changes := &feedModification{Title: &title}
|
||||
feed := &model.Feed{Title: "Example"}
|
||||
changes.Update(feed)
|
||||
|
||||
if feed.Title != title {
|
||||
t.Fatalf(`Unexpected value, got %q instead of %q`, feed.Title, title)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateFeedTitleWithEmptyString(t *testing.T) {
|
||||
title := ""
|
||||
changes := &feedModification{Title: &title}
|
||||
feed := &model.Feed{Title: "Example"}
|
||||
changes.Update(feed)
|
||||
|
||||
if feed.Title == title {
|
||||
t.Fatal(`The Title should not be modified`)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateFeedTitleWhenNotSet(t *testing.T) {
|
||||
changes := &feedModification{}
|
||||
feed := &model.Feed{Title: "Example"}
|
||||
changes.Update(feed)
|
||||
|
||||
if feed.Title != "Example" {
|
||||
t.Fatal(`The Title should not be modified`)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateFeedUsername(t *testing.T) {
|
||||
username := "Alice"
|
||||
changes := &feedModification{Username: &username}
|
||||
feed := &model.Feed{Username: "Bob"}
|
||||
changes.Update(feed)
|
||||
|
||||
if feed.Username != username {
|
||||
t.Fatalf(`Unexpected value, got %q instead of %q`, feed.Username, username)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateFeedUsernameWithEmptyString(t *testing.T) {
|
||||
username := ""
|
||||
changes := &feedModification{Username: &username}
|
||||
feed := &model.Feed{Username: "Bob"}
|
||||
changes.Update(feed)
|
||||
|
||||
if feed.Username != "" {
|
||||
t.Fatal(`The Username should be empty now`)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateFeedUsernameWhenNotSet(t *testing.T) {
|
||||
changes := &feedModification{}
|
||||
feed := &model.Feed{Username: "Alice"}
|
||||
changes.Update(feed)
|
||||
|
||||
if feed.Username != "Alice" {
|
||||
t.Fatal(`The Username should not be modified`)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateFeedCategory(t *testing.T) {
|
||||
categoryID := int64(1)
|
||||
changes := &feedModification{CategoryID: &categoryID}
|
||||
feed := &model.Feed{Category: &model.Category{ID: 42}}
|
||||
changes.Update(feed)
|
||||
|
||||
if feed.Category.ID != categoryID {
|
||||
t.Fatalf(`Unexpected value, got %q instead of %q`, feed.Username, categoryID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateFeedCategoryWithZero(t *testing.T) {
|
||||
categoryID := int64(0)
|
||||
changes := &feedModification{CategoryID: &categoryID}
|
||||
feed := &model.Feed{Category: &model.Category{ID: 42}}
|
||||
changes.Update(feed)
|
||||
|
||||
if feed.Category.ID != 42 {
|
||||
t.Fatal(`The CategoryID should not be modified`)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateFeedCategoryWhenNotSet(t *testing.T) {
|
||||
changes := &feedModification{}
|
||||
feed := &model.Feed{Category: &model.Category{ID: 42}}
|
||||
changes.Update(feed)
|
||||
|
||||
if feed.Category.ID != 42 {
|
||||
t.Fatal(`The CategoryID should not be modified`)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateUserTheme(t *testing.T) {
|
||||
theme := "Example 2"
|
||||
changes := &userModification{Theme: &theme}
|
||||
user := &model.User{Theme: "Example"}
|
||||
changes.Update(user)
|
||||
|
||||
if user.Theme != theme {
|
||||
t.Fatalf(`Unexpected value, got %q instead of %q`, user.Theme, theme)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUserThemeWhenNotSet(t *testing.T) {
|
||||
changes := &userModification{}
|
||||
user := &model.User{Theme: "Example"}
|
||||
changes.Update(user)
|
||||
|
||||
if user.Theme != "Example" {
|
||||
t.Fatal(`The user Theme should not be modified`)
|
||||
}
|
||||
}
|
||||
+13
-8
@@ -7,29 +7,34 @@ 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) {
|
||||
subscriptionInfo, err := decodeURLPayload(r.Body)
|
||||
if err != nil {
|
||||
response.JSON().BadRequest(err)
|
||||
json.BadRequest(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
subscriptions, err := subscription.FindSubscriptions(websiteURL)
|
||||
subscriptions, err := subscription.FindSubscriptions(
|
||||
subscriptionInfo.URL,
|
||||
subscriptionInfo.Username,
|
||||
subscriptionInfo.Password,
|
||||
)
|
||||
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)
|
||||
}
|
||||
|
||||
+75
-52
@@ -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 := decodeUserCreationPayload(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())
|
||||
userChanges, err := decodeUserModificationPayload(r.Body)
|
||||
if err != nil {
|
||||
response.JSON().BadRequest(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
|
||||
}
|
||||
|
||||
userChanges.Update(originalUser)
|
||||
if err := originalUser.ValidateUserModification(); err != nil {
|
||||
json.BadRequest(w, err)
|
||||
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)
|
||||
}
|
||||
|
||||
+20
-4
@@ -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"
|
||||
)
|
||||
@@ -24,10 +23,16 @@ func Parse() {
|
||||
flagFlushSessions := flag.Bool("flush-sessions", false, "Flush all sessions (disconnect users)")
|
||||
flagCreateAdmin := flag.Bool("create-admin", false, "Create admin user")
|
||||
flagResetPassword := flag.Bool("reset-password", false, "Reset user password")
|
||||
flagResetFeedErrors := flag.Bool("reset-feed-errors", false, "Clear all feed errors for all users")
|
||||
flagDebugMode := flag.Bool("debug", false, "Enable debug mode (more verbose output)")
|
||||
flag.Parse()
|
||||
|
||||
cfg := config.NewConfig()
|
||||
|
||||
if *flagDebugMode || cfg.HasDebugMode() {
|
||||
logger.EnableDebug()
|
||||
}
|
||||
|
||||
store := storage.NewStorage(
|
||||
cfg.DatabaseURL(),
|
||||
cfg.DatabaseMaxConnections(),
|
||||
@@ -48,6 +53,11 @@ func Parse() {
|
||||
return
|
||||
}
|
||||
|
||||
if *flagResetFeedErrors {
|
||||
store.ResetFeedErrors()
|
||||
return
|
||||
}
|
||||
|
||||
if *flagFlushSessions {
|
||||
flushSessions(store)
|
||||
return
|
||||
@@ -63,8 +73,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
@@ -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") != ""}
|
||||
|
||||
+20
-4
@@ -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,21 @@ 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("Sys=%vK, InUse=%vK, HeapInUse=%vK, StackSys=%vK, StackInUse=%vK, GoRoutines=%d, NumCPU=%d",
|
||||
m.Sys/1024, (m.Sys-m.HeapReleased)/1024, m.HeapInuse/1024, m.StackSys/1024, m.StackInuse/1024,
|
||||
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 +51,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
@@ -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
@@ -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
@@ -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}
|
||||
}
|
||||
|
||||
+87
-69
@@ -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))
|
||||
builder.AfterEntryID(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.FormInt64Value(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 client’s most recent items API request
|
||||
*/
|
||||
func (c *Controller) handleWriteFeeds(ctx *handler.Context, request *handler.Request, response *handler.Response) {
|
||||
func (c *Controller) handleWriteFeeds(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := context.New(r)
|
||||
userID := ctx.UserID()
|
||||
logger.Debug("[Fever] Receiving mark=feed call for userID=%d", userID)
|
||||
|
||||
feedID := request.FormIntegerValue("id")
|
||||
feedID := request.FormInt64Value(r, "id")
|
||||
if feedID <= 0 {
|
||||
return
|
||||
}
|
||||
@@ -540,25 +557,25 @@ func (c *Controller) handleWriteFeeds(ctx *handler.Context, request *handler.Req
|
||||
builder.WithStatus(model.EntryStatusUnread)
|
||||
builder.WithFeedID(feedID)
|
||||
|
||||
before := request.FormIntegerValue("before")
|
||||
before := request.FormInt64Value(r, "before")
|
||||
if before > 0 {
|
||||
t := time.Unix(before, 0)
|
||||
builder.Before(&t)
|
||||
builder.BeforeDate(t)
|
||||
}
|
||||
|
||||
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 client’s most recent items API request
|
||||
*/
|
||||
func (c *Controller) handleWriteGroups(ctx *handler.Context, request *handler.Request, response *handler.Response) {
|
||||
func (c *Controller) handleWriteGroups(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := context.New(r)
|
||||
userID := ctx.UserID()
|
||||
logger.Debug("[Fever] Receiving mark=group call for userID=%d", userID)
|
||||
|
||||
groupID := request.FormIntegerValue("id")
|
||||
groupID := request.FormInt64Value(r, "id")
|
||||
if groupID < 0 {
|
||||
return
|
||||
}
|
||||
@@ -580,25 +598,25 @@ func (c *Controller) handleWriteGroups(ctx *handler.Context, request *handler.Re
|
||||
builder.WithStatus(model.EntryStatusUnread)
|
||||
builder.WithCategoryID(groupID)
|
||||
|
||||
before := request.FormIntegerValue("before")
|
||||
before := request.FormInt64Value(r, "before")
|
||||
if before > 0 {
|
||||
t := time.Unix(before, 0)
|
||||
builder.Before(&t)
|
||||
builder.BeforeDate(t)
|
||||
}
|
||||
|
||||
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}
|
||||
}
|
||||
|
||||
@@ -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,28 @@ type Client struct {
|
||||
Insecure bool
|
||||
}
|
||||
|
||||
// WithCredentials defines the username/password for HTTP Basic authentication.
|
||||
func (c *Client) WithCredentials(username, password string) *Client {
|
||||
if username != "" && password != "" {
|
||||
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)
|
||||
@@ -76,7 +114,31 @@ func (c *Client) executeRequest(request *http.Request) (*Response, error) {
|
||||
|
||||
client := c.buildClient()
|
||||
resp, err := client.Do(request)
|
||||
if resp != nil {
|
||||
defer resp.Body.Close()
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
@@ -84,8 +146,13 @@ func (c *Client) executeRequest(request *http.Request) (*Response, error) {
|
||||
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 +161,26 @@ 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, Auth=%v",
|
||||
request.Method,
|
||||
c.url,
|
||||
response.EffectiveURL,
|
||||
response.StatusCode,
|
||||
resp.ContentLength,
|
||||
response.ContentType,
|
||||
response.ETag,
|
||||
response.LastModified,
|
||||
response.EffectiveURL,
|
||||
resp.Header.Get("Expires"),
|
||||
c.username != "",
|
||||
)
|
||||
|
||||
// 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 +190,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
|
||||
}
|
||||
|
||||
@@ -150,25 +227,11 @@ func (c *Client) buildHeaders() http.Header {
|
||||
headers.Add("Authorization", c.authorizationHeader)
|
||||
}
|
||||
|
||||
headers.Add("Connection", "close")
|
||||
return headers
|
||||
}
|
||||
|
||||
// NewClient returns a new HTTP client.
|
||||
func NewClient(url string) *Client {
|
||||
// New returns a new HTTP client.
|
||||
func New(url string) *Client {
|
||||
return &Client{url: url, Insecure: false}
|
||||
}
|
||||
|
||||
// NewClientWithCredentials returns a new HTTP client that requires authentication.
|
||||
func NewClientWithCredentials(url, username, password string) *Client {
|
||||
return &Client{url: url, Insecure: false, username: username, password: password}
|
||||
}
|
||||
|
||||
// NewClientWithAuthorization returns a new client with a custom authorization header.
|
||||
func NewClientWithAuthorization(url, authorization string) *Client {
|
||||
return &Client{url: url, Insecure: false, authorizationHeader: authorization}
|
||||
}
|
||||
|
||||
// NewClientWithCacheHeaders returns a new HTTP client that send cache headers.
|
||||
func NewClientWithCacheHeaders(url, etagHeader, lastModifiedHeader string) *Client {
|
||||
return &Client{url: url, etagHeader: etagHeader, lastModifiedHeader: lastModifiedHeader, Insecure: false}
|
||||
}
|
||||
@@ -2,7 +2,7 @@
|
||||
// Use of this source code is governed by the Apache 2.0
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
package http
|
||||
package client
|
||||
|
||||
import (
|
||||
"io"
|
||||
@@ -24,6 +24,16 @@ type Response struct {
|
||||
ContentLength int64
|
||||
}
|
||||
|
||||
// IsNotFound returns true if the resource doesn't exists anymore.
|
||||
func (r *Response) IsNotFound() bool {
|
||||
return r.StatusCode == 404 || r.StatusCode == 410
|
||||
}
|
||||
|
||||
// IsNotAuthorized returns true if the resource require authentication.
|
||||
func (r *Response) IsNotAuthorized() bool {
|
||||
return r.StatusCode == 401
|
||||
}
|
||||
|
||||
// HasServerFailure returns true if the status code represents a failure.
|
||||
func (r *Response) HasServerFailure() bool {
|
||||
return r.StatusCode >= 400
|
||||
@@ -2,7 +2,7 @@
|
||||
// Use of this source code is governed by the Apache 2.0
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
package http
|
||||
package client
|
||||
|
||||
import "testing"
|
||||
|
||||
@@ -0,0 +1,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
@@ -1,10 +0,0 @@
|
||||
// Copyright 2018 Frédéric Guillot. All rights reserved.
|
||||
// Use of this source code is governed by the MIT license
|
||||
// that can be found in the LICENSE file.
|
||||
|
||||
/*
|
||||
|
||||
Package http implements a set of utilities related to the HTTP protocol.
|
||||
|
||||
*/
|
||||
package http
|
||||
@@ -1,163 +0,0 @@
|
||||
// Copyright 2017 Frédéric Guillot. All rights reserved.
|
||||
// Use of this source code is governed by the Apache 2.0
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/miniflux/miniflux/crypto"
|
||||
"github.com/miniflux/miniflux/http/middleware"
|
||||
"github.com/miniflux/miniflux/http/route"
|
||||
"github.com/miniflux/miniflux/locale"
|
||||
"github.com/miniflux/miniflux/logger"
|
||||
"github.com/miniflux/miniflux/model"
|
||||
"github.com/miniflux/miniflux/storage"
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
)
|
||||
|
||||
// Context contains helper functions related to the current request.
|
||||
type Context struct {
|
||||
writer http.ResponseWriter
|
||||
request *http.Request
|
||||
store *storage.Storage
|
||||
router *mux.Router
|
||||
user *model.User
|
||||
translator *locale.Translator
|
||||
}
|
||||
|
||||
// IsAdminUser checks if the logged user is administrator.
|
||||
func (c *Context) IsAdminUser() bool {
|
||||
if v := c.request.Context().Value(middleware.IsAdminUserContextKey); v != nil {
|
||||
return v.(bool)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// UserTimezone returns the timezone used by the logged user.
|
||||
func (c *Context) UserTimezone() string {
|
||||
value := c.getContextStringValue(middleware.UserTimezoneContextKey)
|
||||
if value == "" {
|
||||
value = "UTC"
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
// IsAuthenticated returns a boolean if the user is authenticated.
|
||||
func (c *Context) IsAuthenticated() bool {
|
||||
if v := c.request.Context().Value(middleware.IsAuthenticatedContextKey); v != nil {
|
||||
return v.(bool)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// UserID returns the UserID of the logged user.
|
||||
func (c *Context) UserID() int64 {
|
||||
if v := c.request.Context().Value(middleware.UserIDContextKey); v != nil {
|
||||
return v.(int64)
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// LoggedUser returns all properties related to the logged user.
|
||||
func (c *Context) LoggedUser() *model.User {
|
||||
if c.user == nil {
|
||||
var err error
|
||||
c.user, err = c.store.UserByID(c.UserID())
|
||||
if err != nil {
|
||||
logger.Fatal("[Context] %v", err)
|
||||
}
|
||||
|
||||
if c.user == nil {
|
||||
logger.Fatal("Unable to find user from context")
|
||||
}
|
||||
}
|
||||
|
||||
return c.user
|
||||
}
|
||||
|
||||
// UserLanguage get the locale used by the current logged user.
|
||||
func (c *Context) UserLanguage() string {
|
||||
if c.IsAuthenticated() {
|
||||
user := c.LoggedUser()
|
||||
return user.Language
|
||||
}
|
||||
|
||||
return c.getContextStringValue(middleware.UserLanguageContextKey)
|
||||
}
|
||||
|
||||
// Translate translates a message in the current language.
|
||||
func (c *Context) Translate(message string, args ...interface{}) string {
|
||||
return c.translator.GetLanguage(c.UserLanguage()).Get(message, args...)
|
||||
}
|
||||
|
||||
// CSRF returns the current CSRF token.
|
||||
func (c *Context) CSRF() string {
|
||||
return c.getContextStringValue(middleware.CSRFContextKey)
|
||||
}
|
||||
|
||||
// SessionID returns the current session ID.
|
||||
func (c *Context) SessionID() string {
|
||||
return c.getContextStringValue(middleware.SessionIDContextKey)
|
||||
}
|
||||
|
||||
// UserSessionToken returns the current user session token.
|
||||
func (c *Context) UserSessionToken() string {
|
||||
return c.getContextStringValue(middleware.UserSessionTokenContextKey)
|
||||
}
|
||||
|
||||
// OAuth2State returns the current OAuth2 state.
|
||||
func (c *Context) OAuth2State() string {
|
||||
return c.getContextStringValue(middleware.OAuth2StateContextKey)
|
||||
}
|
||||
|
||||
// GenerateOAuth2State generate a new OAuth2 state.
|
||||
func (c *Context) GenerateOAuth2State() string {
|
||||
state := crypto.GenerateRandomString(32)
|
||||
c.store.UpdateSessionField(c.SessionID(), "oauth2_state", state)
|
||||
return state
|
||||
}
|
||||
|
||||
// SetFlashMessage defines a new flash message.
|
||||
func (c *Context) SetFlashMessage(message string) {
|
||||
c.store.UpdateSessionField(c.SessionID(), "flash_message", message)
|
||||
}
|
||||
|
||||
// FlashMessage returns the flash message and remove it.
|
||||
func (c *Context) FlashMessage() string {
|
||||
message := c.getContextStringValue(middleware.FlashMessageContextKey)
|
||||
c.store.UpdateSessionField(c.SessionID(), "flash_message", "")
|
||||
return message
|
||||
}
|
||||
|
||||
// SetFlashErrorMessage defines a new flash error message.
|
||||
func (c *Context) SetFlashErrorMessage(message string) {
|
||||
c.store.UpdateSessionField(c.SessionID(), "flash_error_message", message)
|
||||
}
|
||||
|
||||
// FlashErrorMessage returns the error flash message and remove it.
|
||||
func (c *Context) FlashErrorMessage() string {
|
||||
message := c.getContextStringValue(middleware.FlashErrorMessageContextKey)
|
||||
c.store.UpdateSessionField(c.SessionID(), "flash_error_message", "")
|
||||
return message
|
||||
}
|
||||
|
||||
func (c *Context) getContextStringValue(key *middleware.ContextKey) string {
|
||||
if v := c.request.Context().Value(key); v != nil {
|
||||
return v.(string)
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
// Route returns the path for the given arguments.
|
||||
func (c *Context) Route(name string, args ...interface{}) string {
|
||||
return route.Path(c.router, name, args...)
|
||||
}
|
||||
|
||||
// NewContext creates a new Context.
|
||||
func NewContext(r *http.Request, store *storage.Storage, router *mux.Router, translator *locale.Translator) *Context {
|
||||
return &Context{request: r, store: store, router: router, translator: translator}
|
||||
}
|
||||
@@ -1,71 +0,0 @@
|
||||
// Copyright 2017 Frédéric Guillot. All rights reserved.
|
||||
// Use of this source code is governed by the Apache 2.0
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/miniflux/miniflux/config"
|
||||
"github.com/miniflux/miniflux/http/middleware"
|
||||
"github.com/miniflux/miniflux/locale"
|
||||
"github.com/miniflux/miniflux/logger"
|
||||
"github.com/miniflux/miniflux/storage"
|
||||
"github.com/miniflux/miniflux/template"
|
||||
"github.com/miniflux/miniflux/timer"
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
"github.com/tomasen/realip"
|
||||
)
|
||||
|
||||
// ControllerFunc is an application HTTP handler.
|
||||
type ControllerFunc func(ctx *Context, request *Request, response *Response)
|
||||
|
||||
// Handler manages HTTP handlers and middlewares.
|
||||
type Handler struct {
|
||||
cfg *config.Config
|
||||
store *storage.Storage
|
||||
translator *locale.Translator
|
||||
template *template.Engine
|
||||
router *mux.Router
|
||||
middleware *middleware.Chain
|
||||
}
|
||||
|
||||
// Use is a wrapper around an HTTP handler.
|
||||
func (h *Handler) Use(f ControllerFunc) http.Handler {
|
||||
return h.middleware.WrapFunc(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
defer timer.ExecutionTime(time.Now(), r.URL.Path)
|
||||
logger.Debug("[HTTP] %s %s %s", realip.RealIP(r), r.Method, r.URL.Path)
|
||||
|
||||
if r.Header.Get("X-Forwarded-Proto") == "https" {
|
||||
h.cfg.IsHTTPS = true
|
||||
}
|
||||
|
||||
ctx := NewContext(r, h.store, h.router, h.translator)
|
||||
request := NewRequest(r)
|
||||
response := NewResponse(h.cfg, w, r, h.template)
|
||||
language := ctx.UserLanguage()
|
||||
|
||||
if language != "" {
|
||||
h.template.SetLanguage(language)
|
||||
} else {
|
||||
h.template.SetLanguage("en_US")
|
||||
}
|
||||
|
||||
f(ctx, request, response)
|
||||
}))
|
||||
}
|
||||
|
||||
// NewHandler returns a new Handler.
|
||||
func NewHandler(cfg *config.Config, store *storage.Storage, router *mux.Router, template *template.Engine, translator *locale.Translator, middleware *middleware.Chain) *Handler {
|
||||
return &Handler{
|
||||
cfg: cfg,
|
||||
store: store,
|
||||
translator: translator,
|
||||
router: router,
|
||||
template: template,
|
||||
middleware: middleware,
|
||||
}
|
||||
}
|
||||
@@ -1,65 +0,0 @@
|
||||
// Copyright 2017 Frédéric Guillot. All rights reserved.
|
||||
// Use of this source code is governed by the Apache 2.0
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/miniflux/miniflux/logger"
|
||||
"github.com/miniflux/miniflux/template"
|
||||
)
|
||||
|
||||
// HTMLResponse handles HTML responses.
|
||||
type HTMLResponse struct {
|
||||
writer http.ResponseWriter
|
||||
request *http.Request
|
||||
template *template.Engine
|
||||
}
|
||||
|
||||
// Render execute a template and send to the client the generated HTML.
|
||||
func (h *HTMLResponse) Render(template string, args map[string]interface{}) {
|
||||
h.writer.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
h.template.Execute(h.writer, template, args)
|
||||
}
|
||||
|
||||
// ServerError sends a 500 error to the browser.
|
||||
func (h *HTMLResponse) ServerError(err error) {
|
||||
h.writer.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
h.writer.WriteHeader(http.StatusInternalServerError)
|
||||
|
||||
if err != nil {
|
||||
logger.Error("[Internal Server Error] %v", err)
|
||||
h.writer.Write([]byte("Internal Server Error: " + err.Error()))
|
||||
} else {
|
||||
h.writer.Write([]byte("Internal Server Error"))
|
||||
}
|
||||
}
|
||||
|
||||
// BadRequest sends a 400 error to the browser.
|
||||
func (h *HTMLResponse) BadRequest(err error) {
|
||||
h.writer.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
h.writer.WriteHeader(http.StatusBadRequest)
|
||||
|
||||
if err != nil {
|
||||
logger.Error("[Bad Request] %v", err)
|
||||
h.writer.Write([]byte("Bad Request: " + err.Error()))
|
||||
} else {
|
||||
h.writer.Write([]byte("Bad Request"))
|
||||
}
|
||||
}
|
||||
|
||||
// NotFound sends a 404 error to the browser.
|
||||
func (h *HTMLResponse) NotFound() {
|
||||
h.writer.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
h.writer.WriteHeader(http.StatusNotFound)
|
||||
h.writer.Write([]byte("Page Not Found"))
|
||||
}
|
||||
|
||||
// Forbidden sends a 403 error to the browser.
|
||||
func (h *HTMLResponse) Forbidden() {
|
||||
h.writer.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
h.writer.WriteHeader(http.StatusForbidden)
|
||||
h.writer.Write([]byte("Access Forbidden"))
|
||||
}
|
||||
@@ -1,111 +0,0 @@
|
||||
// Copyright 2017 Frédéric Guillot. All rights reserved.
|
||||
// Use of this source code is governed by the Apache 2.0
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
package handler
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"github.com/miniflux/miniflux/logger"
|
||||
)
|
||||
|
||||
// JSONResponse handles JSON responses.
|
||||
type JSONResponse struct {
|
||||
writer http.ResponseWriter
|
||||
request *http.Request
|
||||
}
|
||||
|
||||
// Standard sends a JSON response with the status code 200.
|
||||
func (j *JSONResponse) Standard(v interface{}) {
|
||||
j.commonHeaders()
|
||||
j.writer.WriteHeader(http.StatusOK)
|
||||
j.writer.Write(j.toJSON(v))
|
||||
}
|
||||
|
||||
// Created sends a JSON response with the status code 201.
|
||||
func (j *JSONResponse) Created(v interface{}) {
|
||||
j.commonHeaders()
|
||||
j.writer.WriteHeader(http.StatusCreated)
|
||||
j.writer.Write(j.toJSON(v))
|
||||
}
|
||||
|
||||
// NoContent sends a JSON response with the status code 204.
|
||||
func (j *JSONResponse) NoContent() {
|
||||
j.commonHeaders()
|
||||
j.writer.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// BadRequest sends a JSON response with the status code 400.
|
||||
func (j *JSONResponse) BadRequest(err error) {
|
||||
logger.Error("[Bad Request] %v", err)
|
||||
j.commonHeaders()
|
||||
j.writer.WriteHeader(http.StatusBadRequest)
|
||||
|
||||
if err != nil {
|
||||
j.writer.Write(j.encodeError(err))
|
||||
}
|
||||
}
|
||||
|
||||
// NotFound sends a JSON response with the status code 404.
|
||||
func (j *JSONResponse) NotFound(err error) {
|
||||
logger.Error("[Not Found] %v", err)
|
||||
j.commonHeaders()
|
||||
j.writer.WriteHeader(http.StatusNotFound)
|
||||
j.writer.Write(j.encodeError(err))
|
||||
}
|
||||
|
||||
// ServerError sends a JSON response with the status code 500.
|
||||
func (j *JSONResponse) ServerError(err error) {
|
||||
logger.Error("[Internal Server Error] %v", err)
|
||||
j.commonHeaders()
|
||||
j.writer.WriteHeader(http.StatusInternalServerError)
|
||||
|
||||
if err != nil {
|
||||
j.writer.Write(j.encodeError(err))
|
||||
}
|
||||
}
|
||||
|
||||
// Forbidden sends a JSON response with the status code 403.
|
||||
func (j *JSONResponse) Forbidden() {
|
||||
logger.Info("[API:Forbidden]")
|
||||
j.commonHeaders()
|
||||
j.writer.WriteHeader(http.StatusForbidden)
|
||||
j.writer.Write(j.encodeError(errors.New("Access Forbidden")))
|
||||
}
|
||||
|
||||
func (j *JSONResponse) commonHeaders() {
|
||||
j.writer.Header().Set("Accept", "application/json")
|
||||
j.writer.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
}
|
||||
|
||||
func (j *JSONResponse) encodeError(err error) []byte {
|
||||
type errorMsg struct {
|
||||
ErrorMessage string `json:"error_message"`
|
||||
}
|
||||
|
||||
tmp := errorMsg{ErrorMessage: err.Error()}
|
||||
data, err := json.Marshal(tmp)
|
||||
if err != nil {
|
||||
logger.Error("encoding error: %v", err)
|
||||
}
|
||||
|
||||
return data
|
||||
}
|
||||
|
||||
func (j *JSONResponse) toJSON(v interface{}) []byte {
|
||||
b, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
logger.Error("encoding error: %v", err)
|
||||
return []byte("")
|
||||
}
|
||||
|
||||
return b
|
||||
}
|
||||
|
||||
// NewJSONResponse returns a new JSONResponse.
|
||||
func NewJSONResponse(w http.ResponseWriter, r *http.Request) *JSONResponse {
|
||||
return &JSONResponse{request: r, writer: w}
|
||||
}
|
||||
@@ -1,124 +0,0 @@
|
||||
// Copyright 2017 Frédéric Guillot. All rights reserved.
|
||||
// Use of this source code is governed by the Apache 2.0
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
package handler
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
"github.com/miniflux/miniflux/logger"
|
||||
)
|
||||
|
||||
// Request is a thin wrapper around "http.Request".
|
||||
type Request struct {
|
||||
request *http.Request
|
||||
}
|
||||
|
||||
// Request returns the raw Request struct.
|
||||
func (r *Request) Request() *http.Request {
|
||||
return r.request
|
||||
}
|
||||
|
||||
// Body returns the request body.
|
||||
func (r *Request) Body() io.ReadCloser {
|
||||
return r.request.Body
|
||||
}
|
||||
|
||||
// File returns uploaded file properties.
|
||||
func (r *Request) File(name string) (multipart.File, *multipart.FileHeader, error) {
|
||||
return r.request.FormFile(name)
|
||||
}
|
||||
|
||||
// Cookie returns the cookie value.
|
||||
func (r *Request) Cookie(name string) string {
|
||||
cookie, err := r.request.Cookie(name)
|
||||
if err == http.ErrNoCookie {
|
||||
return ""
|
||||
}
|
||||
|
||||
return cookie.Value
|
||||
}
|
||||
|
||||
// FormValue returns a form value as integer.
|
||||
func (r *Request) FormValue(param string) string {
|
||||
return r.request.FormValue(param)
|
||||
}
|
||||
|
||||
// FormIntegerValue returns a form value as integer.
|
||||
func (r *Request) FormIntegerValue(param string) int64 {
|
||||
value := r.request.FormValue(param)
|
||||
integer, _ := strconv.Atoi(value)
|
||||
return int64(integer)
|
||||
}
|
||||
|
||||
// IntegerParam returns an URL parameter as integer.
|
||||
func (r *Request) IntegerParam(param string) (int64, error) {
|
||||
vars := mux.Vars(r.request)
|
||||
value, err := strconv.Atoi(vars[param])
|
||||
if err != nil {
|
||||
logger.Error("[IntegerParam] %v", err)
|
||||
return 0, fmt.Errorf("%s parameter is not an integer", param)
|
||||
}
|
||||
|
||||
if value < 0 {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
return int64(value), nil
|
||||
}
|
||||
|
||||
// StringParam returns an URL parameter as string.
|
||||
func (r *Request) StringParam(param, defaultValue string) string {
|
||||
vars := mux.Vars(r.request)
|
||||
value := vars[param]
|
||||
if value == "" {
|
||||
value = defaultValue
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
// QueryStringParam returns a querystring parameter as string.
|
||||
func (r *Request) QueryStringParam(param, defaultValue string) string {
|
||||
value := r.request.URL.Query().Get(param)
|
||||
if value == "" {
|
||||
value = defaultValue
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
// QueryIntegerParam returns a querystring parameter as string.
|
||||
func (r *Request) QueryIntegerParam(param string, defaultValue int) int {
|
||||
value := r.request.URL.Query().Get(param)
|
||||
if value == "" {
|
||||
return defaultValue
|
||||
}
|
||||
|
||||
val, err := strconv.Atoi(value)
|
||||
if err != nil {
|
||||
return defaultValue
|
||||
}
|
||||
|
||||
if val < 0 {
|
||||
return defaultValue
|
||||
}
|
||||
|
||||
return val
|
||||
}
|
||||
|
||||
// HasQueryParam checks if the query string contains the given parameter.
|
||||
func (r *Request) HasQueryParam(param string) bool {
|
||||
values := r.request.URL.Query()
|
||||
_, ok := values[param]
|
||||
return ok
|
||||
}
|
||||
|
||||
// NewRequest returns a new Request.
|
||||
func NewRequest(r *http.Request) *Request {
|
||||
return &Request{r}
|
||||
}
|
||||
@@ -1,88 +0,0 @@
|
||||
// Copyright 2017 Frédéric Guillot. All rights reserved.
|
||||
// Use of this source code is governed by the Apache 2.0
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/miniflux/miniflux/config"
|
||||
"github.com/miniflux/miniflux/template"
|
||||
)
|
||||
|
||||
// Response handles HTTP responses.
|
||||
type Response struct {
|
||||
cfg *config.Config
|
||||
writer http.ResponseWriter
|
||||
request *http.Request
|
||||
template *template.Engine
|
||||
}
|
||||
|
||||
// SetCookie send a cookie to the client.
|
||||
func (r *Response) SetCookie(cookie *http.Cookie) {
|
||||
http.SetCookie(r.writer, cookie)
|
||||
}
|
||||
|
||||
// JSON returns a JSONResponse.
|
||||
func (r *Response) JSON() *JSONResponse {
|
||||
r.commonHeaders()
|
||||
return NewJSONResponse(r.writer, r.request)
|
||||
}
|
||||
|
||||
// HTML returns a HTMLResponse.
|
||||
func (r *Response) HTML() *HTMLResponse {
|
||||
r.commonHeaders()
|
||||
return &HTMLResponse{writer: r.writer, request: r.request, template: r.template}
|
||||
}
|
||||
|
||||
// XML returns a XMLResponse.
|
||||
func (r *Response) XML() *XMLResponse {
|
||||
r.commonHeaders()
|
||||
return &XMLResponse{writer: r.writer, request: r.request}
|
||||
}
|
||||
|
||||
// Redirect redirects the user to another location.
|
||||
func (r *Response) Redirect(path string) {
|
||||
http.Redirect(r.writer, r.request, path, http.StatusFound)
|
||||
}
|
||||
|
||||
// NotModified sends a response with a 304 status code.
|
||||
func (r *Response) NotModified() {
|
||||
r.commonHeaders()
|
||||
r.writer.WriteHeader(http.StatusNotModified)
|
||||
}
|
||||
|
||||
// Cache returns a response with caching headers.
|
||||
func (r *Response) Cache(mimeType, etag string, content []byte, duration time.Duration) {
|
||||
r.writer.Header().Set("Content-Type", mimeType)
|
||||
r.writer.Header().Set("ETag", etag)
|
||||
r.writer.Header().Set("Cache-Control", "public")
|
||||
r.writer.Header().Set("Expires", time.Now().Add(duration).Format(time.RFC1123))
|
||||
|
||||
if etag == r.request.Header.Get("If-None-Match") {
|
||||
r.writer.WriteHeader(http.StatusNotModified)
|
||||
} else {
|
||||
r.writer.Write(content)
|
||||
}
|
||||
}
|
||||
|
||||
func (r *Response) commonHeaders() {
|
||||
r.writer.Header().Set("X-XSS-Protection", "1; mode=block")
|
||||
r.writer.Header().Set("X-Content-Type-Options", "nosniff")
|
||||
r.writer.Header().Set("X-Frame-Options", "DENY")
|
||||
|
||||
// Even if the directive "frame-src" has been deprecated in Firefox,
|
||||
// we keep it to stay compatible with other browsers.
|
||||
r.writer.Header().Set("Content-Security-Policy", "default-src 'self'; img-src *; media-src *; frame-src *; child-src *")
|
||||
|
||||
if r.cfg.IsHTTPS && r.cfg.HasHSTS() {
|
||||
r.writer.Header().Set("Strict-Transport-Security", "max-age=31536000")
|
||||
}
|
||||
}
|
||||
|
||||
// NewResponse returns a new Response.
|
||||
func NewResponse(cfg *config.Config, w http.ResponseWriter, r *http.Request, template *template.Engine) *Response {
|
||||
return &Response{cfg: cfg, writer: w, request: r, template: template}
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
// Copyright 2017 Frédéric Guillot. All rights reserved.
|
||||
// Use of this source code is governed by the Apache 2.0
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
package handler
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
// XMLResponse handles XML responses.
|
||||
type XMLResponse struct {
|
||||
writer http.ResponseWriter
|
||||
request *http.Request
|
||||
}
|
||||
|
||||
// Download force the download of a XML document.
|
||||
func (x *XMLResponse) Download(filename, data string) {
|
||||
x.writer.Header().Set("Content-Type", "text/xml")
|
||||
x.writer.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%s", filename))
|
||||
x.writer.Write([]byte(data))
|
||||
}
|
||||
|
||||
// Serve forces the XML to be sent to browser.
|
||||
func (x *XMLResponse) Serve(data string) {
|
||||
x.writer.Header().Set("Content-Type", "text/xml")
|
||||
x.writer.Write([]byte(data))
|
||||
}
|
||||
@@ -1,36 +0,0 @@
|
||||
// Copyright 2017 Frédéric Guillot. All rights reserved.
|
||||
// Use of this source code is governed by the Apache 2.0
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
)
|
||||
|
||||
// Middleware represents a HTTP middleware.
|
||||
type Middleware func(http.Handler) http.Handler
|
||||
|
||||
// Chain handles a list of middlewares.
|
||||
type Chain struct {
|
||||
middlewares []Middleware
|
||||
}
|
||||
|
||||
// Wrap adds a HTTP handler into the chain.
|
||||
func (m *Chain) Wrap(h http.Handler) http.Handler {
|
||||
for i := range m.middlewares {
|
||||
h = m.middlewares[len(m.middlewares)-1-i](h)
|
||||
}
|
||||
|
||||
return h
|
||||
}
|
||||
|
||||
// WrapFunc adds a HTTP handler function into the chain.
|
||||
func (m *Chain) WrapFunc(fn http.HandlerFunc) http.Handler {
|
||||
return m.Wrap(fn)
|
||||
}
|
||||
|
||||
// NewChain returns a new Chain.
|
||||
func NewChain(middlewares ...Middleware) *Chain {
|
||||
return &Chain{append(([]Middleware)(nil), middlewares...)}
|
||||
}
|
||||
@@ -1,85 +0,0 @@
|
||||
// Copyright 2017 Frédéric Guillot. All rights reserved.
|
||||
// Use of this source code is governed by the Apache 2.0
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
|
||||
"github.com/miniflux/miniflux/config"
|
||||
"github.com/miniflux/miniflux/http/cookie"
|
||||
"github.com/miniflux/miniflux/logger"
|
||||
"github.com/miniflux/miniflux/model"
|
||||
"github.com/miniflux/miniflux/storage"
|
||||
)
|
||||
|
||||
// SessionMiddleware represents a session middleware.
|
||||
type SessionMiddleware struct {
|
||||
cfg *config.Config
|
||||
store *storage.Storage
|
||||
}
|
||||
|
||||
// Handler execute the middleware.
|
||||
func (s *SessionMiddleware) Handler(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
var err error
|
||||
session := s.getSessionValueFromCookie(r)
|
||||
|
||||
if session == nil {
|
||||
logger.Debug("[Middleware:Session] Session not found")
|
||||
session, err = s.store.CreateSession()
|
||||
if err != nil {
|
||||
logger.Error("[Middleware:Session] %v", err)
|
||||
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
http.SetCookie(w, cookie.New(cookie.CookieSessionID, session.ID, s.cfg.IsHTTPS, s.cfg.BasePath()))
|
||||
} else {
|
||||
logger.Debug("[Middleware:Session] %s", session)
|
||||
}
|
||||
|
||||
if r.Method == "POST" {
|
||||
formValue := r.FormValue("csrf")
|
||||
headerValue := r.Header.Get("X-Csrf-Token")
|
||||
|
||||
if session.Data.CSRF != formValue && session.Data.CSRF != headerValue {
|
||||
logger.Error(`[Middleware:Session] Invalid or missing CSRF token: Form="%s", Header="%s"`, formValue, headerValue)
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
w.Write([]byte("Invalid or missing CSRF session!"))
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
ctx := r.Context()
|
||||
ctx = context.WithValue(ctx, SessionIDContextKey, session.ID)
|
||||
ctx = context.WithValue(ctx, CSRFContextKey, session.Data.CSRF)
|
||||
ctx = context.WithValue(ctx, OAuth2StateContextKey, session.Data.OAuth2State)
|
||||
ctx = context.WithValue(ctx, FlashMessageContextKey, session.Data.FlashMessage)
|
||||
ctx = context.WithValue(ctx, FlashErrorMessageContextKey, session.Data.FlashErrorMessage)
|
||||
ctx = context.WithValue(ctx, UserLanguageContextKey, session.Data.Language)
|
||||
next.ServeHTTP(w, r.WithContext(ctx))
|
||||
})
|
||||
}
|
||||
|
||||
func (s *SessionMiddleware) getSessionValueFromCookie(r *http.Request) *model.Session {
|
||||
sessionCookie, err := r.Cookie(cookie.CookieSessionID)
|
||||
if err == http.ErrNoCookie {
|
||||
return nil
|
||||
}
|
||||
|
||||
session, err := s.store.Session(sessionCookie.Value)
|
||||
if err != nil {
|
||||
logger.Error("[Middleware:Session] %v", err)
|
||||
return nil
|
||||
}
|
||||
|
||||
return session
|
||||
}
|
||||
|
||||
// NewSessionMiddleware returns a new SessionMiddleware.
|
||||
func NewSessionMiddleware(cfg *config.Config, store *storage.Storage) *SessionMiddleware {
|
||||
return &SessionMiddleware{cfg, store}
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
// 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
|
||||
}
|
||||
|
||||
// FormInt64Value returns a form value as integer.
|
||||
func FormInt64Value(r *http.Request, param string) int64 {
|
||||
value := r.FormValue(param)
|
||||
integer, err := strconv.ParseInt(value, 10, 64)
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
|
||||
return 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 {
|
||||
return int(QueryInt64Param(r, param, int64(defaultValue)))
|
||||
}
|
||||
|
||||
// QueryInt64Param returns a querystring parameter as int64.
|
||||
func QueryInt64Param(r *http.Request, param string, defaultValue int64) int64 {
|
||||
value := r.URL.Query().Get(param)
|
||||
if value == "" {
|
||||
return defaultValue
|
||||
}
|
||||
|
||||
val, err := strconv.ParseInt(value, 10, 64)
|
||||
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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
// Copyright 2018 Frédéric Guillot. All rights reserved.
|
||||
// Use of this source code is governed by the Apache 2.0
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
package html
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/miniflux/miniflux/logger"
|
||||
)
|
||||
|
||||
// OK writes a standard HTML response.
|
||||
func OK(w http.ResponseWriter, b []byte) {
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.Write(b)
|
||||
}
|
||||
|
||||
// ServerError sends a 500 error to the browser.
|
||||
func ServerError(w http.ResponseWriter, err error) {
|
||||
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
|
||||
if err != nil {
|
||||
logger.Error("[Internal Server Error] %v", err)
|
||||
w.Write([]byte("Internal Server Error: " + err.Error()))
|
||||
} else {
|
||||
w.Write([]byte("Internal Server Error"))
|
||||
}
|
||||
}
|
||||
|
||||
// BadRequest sends a 400 error to the browser.
|
||||
func BadRequest(w http.ResponseWriter, err error) {
|
||||
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
|
||||
if err != nil {
|
||||
logger.Error("[Bad Request] %v", err)
|
||||
w.Write([]byte("Bad Request: " + err.Error()))
|
||||
} else {
|
||||
w.Write([]byte("Bad Request"))
|
||||
}
|
||||
}
|
||||
|
||||
// NotFound sends a 404 error to the browser.
|
||||
func NotFound(w http.ResponseWriter) {
|
||||
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
w.Write([]byte("Page Not Found"))
|
||||
}
|
||||
|
||||
// Forbidden sends a 403 error to the browser.
|
||||
func Forbidden(w http.ResponseWriter) {
|
||||
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
||||
w.WriteHeader(http.StatusForbidden)
|
||||
w.Write([]byte("Access Forbidden"))
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
// Copyright 2018 Frédéric Guillot. All rights reserved.
|
||||
// Use of this source code is governed by the Apache 2.0
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
package json
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"github.com/miniflux/miniflux/logger"
|
||||
)
|
||||
|
||||
// OK sends a JSON response with the status code 200.
|
||||
func OK(w http.ResponseWriter, v interface{}) {
|
||||
commonHeaders(w)
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write(toJSON(v))
|
||||
}
|
||||
|
||||
// Created sends a JSON response with the status code 201.
|
||||
func Created(w http.ResponseWriter, v interface{}) {
|
||||
commonHeaders(w)
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
w.Write(toJSON(v))
|
||||
}
|
||||
|
||||
// NoContent sends a JSON response with the status code 204.
|
||||
func NoContent(w http.ResponseWriter) {
|
||||
commonHeaders(w)
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// NotFound sends a JSON response with the status code 404.
|
||||
func NotFound(w http.ResponseWriter, err error) {
|
||||
logger.Error("[Not Found] %v", err)
|
||||
commonHeaders(w)
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
w.Write(encodeError(err))
|
||||
}
|
||||
|
||||
// ServerError sends a JSON response with the status code 500.
|
||||
func ServerError(w http.ResponseWriter, err error) {
|
||||
logger.Error("[Internal Server Error] %v", err)
|
||||
commonHeaders(w)
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
|
||||
if err != nil {
|
||||
w.Write(encodeError(err))
|
||||
}
|
||||
}
|
||||
|
||||
// Forbidden sends a JSON response with the status code 403.
|
||||
func Forbidden(w http.ResponseWriter) {
|
||||
logger.Info("[Forbidden]")
|
||||
commonHeaders(w)
|
||||
w.WriteHeader(http.StatusForbidden)
|
||||
w.Write(encodeError(errors.New("Access Forbidden")))
|
||||
}
|
||||
|
||||
// Unauthorized sends a JSON response with the status code 401.
|
||||
func Unauthorized(w http.ResponseWriter) {
|
||||
commonHeaders(w)
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
w.Write(encodeError(errors.New("Access Unauthorized")))
|
||||
}
|
||||
|
||||
// BadRequest sends a JSON response with the status code 400.
|
||||
func BadRequest(w http.ResponseWriter, err error) {
|
||||
logger.Error("[Bad Request] %v", err)
|
||||
commonHeaders(w)
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
|
||||
if err != nil {
|
||||
w.Write(encodeError(err))
|
||||
}
|
||||
}
|
||||
|
||||
func commonHeaders(w http.ResponseWriter) {
|
||||
w.Header().Set("Accept", "application/json")
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
}
|
||||
|
||||
func encodeError(err error) []byte {
|
||||
type errorMsg struct {
|
||||
ErrorMessage string `json:"error_message"`
|
||||
}
|
||||
|
||||
tmp := errorMsg{ErrorMessage: err.Error()}
|
||||
data, err := json.Marshal(tmp)
|
||||
if err != nil {
|
||||
logger.Error("json encoding error: %v", err)
|
||||
}
|
||||
|
||||
return data
|
||||
}
|
||||
|
||||
func toJSON(v interface{}) []byte {
|
||||
b, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
logger.Error("json encoding error: %v", err)
|
||||
return []byte("")
|
||||
}
|
||||
|
||||
return b
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
// Copyright 2018 Frédéric Guillot. All rights reserved.
|
||||
// Use of this source code is governed by the Apache 2.0
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
package response
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Redirect redirects the user to another location.
|
||||
func Redirect(w http.ResponseWriter, r *http.Request, path string) {
|
||||
http.Redirect(w, r, path, http.StatusFound)
|
||||
}
|
||||
|
||||
// NotModified sends a response with a 304 status code.
|
||||
func NotModified(w http.ResponseWriter) {
|
||||
w.WriteHeader(http.StatusNotModified)
|
||||
}
|
||||
|
||||
// Cache returns a response with caching headers.
|
||||
func Cache(w http.ResponseWriter, r *http.Request, mimeType, etag string, content []byte, duration time.Duration) {
|
||||
w.Header().Set("Content-Type", mimeType)
|
||||
w.Header().Set("ETag", etag)
|
||||
w.Header().Set("Cache-Control", "public")
|
||||
w.Header().Set("Expires", time.Now().Add(duration).Format(time.RFC1123))
|
||||
|
||||
if etag == r.Header.Get("If-None-Match") {
|
||||
w.WriteHeader(http.StatusNotModified)
|
||||
} else {
|
||||
w.Write(content)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
// Copyright 2018 Frédéric Guillot. All rights reserved.
|
||||
// Use of this source code is governed by the Apache 2.0
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
package xml
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
// OK sends a XML document.
|
||||
func OK(w http.ResponseWriter, data string) {
|
||||
w.Header().Set("Content-Type", "text/xml")
|
||||
w.Write([]byte(data))
|
||||
}
|
||||
|
||||
// Attachment forces the download of a XML document.
|
||||
func Attachment(w http.ResponseWriter, filename, data string) {
|
||||
w.Header().Set("Content-Type", "text/xml")
|
||||
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%s", filename))
|
||||
w.Write([]byte(data))
|
||||
}
|
||||
@@ -8,7 +8,7 @@ import (
|
||||
"fmt"
|
||||
"net/url"
|
||||
|
||||
"github.com/miniflux/miniflux/http"
|
||||
"github.com/miniflux/miniflux/http/client"
|
||||
)
|
||||
|
||||
// Client represents an Instapaper client.
|
||||
@@ -19,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.
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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}
|
||||
}
|
||||
@@ -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,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)
|
||||
}
|
||||
|
||||
+385
-313
@@ -7,6 +7,8 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"io/ioutil"
|
||||
"math/rand"
|
||||
"strconv"
|
||||
"strings"
|
||||
@@ -40,6 +42,26 @@ func TestWithWrongCredentials(t *testing.T) {
|
||||
if err == nil {
|
||||
t.Fatal(`Using bad credentials should raise an error`)
|
||||
}
|
||||
|
||||
if err != miniflux.ErrNotAuthorized {
|
||||
t.Fatal(`A "Not Authorized" error should be raised`)
|
||||
}
|
||||
}
|
||||
|
||||
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) {
|
||||
@@ -238,7 +260,7 @@ func TestGetUserByUsername(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateUser(t *testing.T) {
|
||||
func TestUpdateUserTheme(t *testing.T) {
|
||||
username := getRandomUsername()
|
||||
client := miniflux.NewClient(testBaseURL, testAdminUsername, testAdminPassword)
|
||||
user, err := client.CreateUser(username, testStandardPassword, false)
|
||||
@@ -247,18 +269,17 @@ func TestUpdateUser(t *testing.T) {
|
||||
}
|
||||
|
||||
theme := "black"
|
||||
user.Theme = theme
|
||||
user, err = client.UpdateUser(user)
|
||||
user, err = client.UpdateUser(user.ID, &miniflux.UserModification{Theme: &theme})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if user.Theme != theme {
|
||||
t.Fatalf(`Unable to update user: got "%v" instead of "%v"`, user.Theme, theme)
|
||||
t.Fatalf(`Unable to update user Theme: got "%v" instead of "%v"`, user.Theme, theme)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateUserWithInvalidValue(t *testing.T) {
|
||||
func TestUpdateUserThemeWithInvalidValue(t *testing.T) {
|
||||
username := getRandomUsername()
|
||||
client := miniflux.NewClient(testBaseURL, testAdminUsername, testAdminPassword)
|
||||
user, err := client.CreateUser(username, testStandardPassword, false)
|
||||
@@ -267,10 +288,9 @@ func TestUpdateUserWithInvalidValue(t *testing.T) {
|
||||
}
|
||||
|
||||
theme := "something that doesn't exists"
|
||||
user.Theme = theme
|
||||
_, err = client.UpdateUser(user)
|
||||
_, err = client.UpdateUser(user.ID, &miniflux.UserModification{Theme: &theme})
|
||||
if err == nil {
|
||||
t.Fatal(`Updating a user with an invalid value should raise an error`)
|
||||
t.Fatal(`Updating a user Theme with an invalid value should raise an error`)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -301,6 +321,10 @@ func TestCannotListUsersAsNonAdmin(t *testing.T) {
|
||||
if err == nil {
|
||||
t.Fatal(`Standard users should not be able to list any users`)
|
||||
}
|
||||
|
||||
if err != miniflux.ErrForbidden {
|
||||
t.Fatal(`A "Forbidden" error should be raised`)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCannotGetUserAsNonAdmin(t *testing.T) {
|
||||
@@ -316,6 +340,10 @@ func TestCannotGetUserAsNonAdmin(t *testing.T) {
|
||||
if err == nil {
|
||||
t.Fatal(`Standard users should not be able to get any users`)
|
||||
}
|
||||
|
||||
if err != miniflux.ErrForbidden {
|
||||
t.Fatal(`A "Forbidden" error should be raised`)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCannotUpdateUserAsNonAdmin(t *testing.T) {
|
||||
@@ -327,10 +355,14 @@ func TestCannotUpdateUserAsNonAdmin(t *testing.T) {
|
||||
}
|
||||
|
||||
client = miniflux.NewClient(testBaseURL, username, testStandardPassword)
|
||||
_, err = client.UpdateUser(user)
|
||||
_, err = client.UpdateUser(user.ID, &miniflux.UserModification{})
|
||||
if err == nil {
|
||||
t.Fatal(`Standard users should not be able to update any users`)
|
||||
}
|
||||
|
||||
if err != miniflux.ErrForbidden {
|
||||
t.Fatal(`A "Forbidden" error should be raised`)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCannotCreateUserAsNonAdmin(t *testing.T) {
|
||||
@@ -346,6 +378,10 @@ func TestCannotCreateUserAsNonAdmin(t *testing.T) {
|
||||
if err == nil {
|
||||
t.Fatal(`Standard users should not be able to create users`)
|
||||
}
|
||||
|
||||
if err != miniflux.ErrForbidden {
|
||||
t.Fatal(`A "Forbidden" error should be raised`)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCannotDeleteUserAsNonAdmin(t *testing.T) {
|
||||
@@ -357,9 +393,14 @@ func TestCannotDeleteUserAsNonAdmin(t *testing.T) {
|
||||
}
|
||||
|
||||
client = miniflux.NewClient(testBaseURL, username, testStandardPassword)
|
||||
if err := client.DeleteUser(user.ID); err == nil {
|
||||
err = client.DeleteUser(user.ID)
|
||||
if err == nil {
|
||||
t.Fatal(`Standard users should not be able to remove any users`)
|
||||
}
|
||||
|
||||
if err != miniflux.ErrForbidden {
|
||||
t.Fatal(`A "Forbidden" error should be raised`)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateCategory(t *testing.T) {
|
||||
@@ -399,16 +440,10 @@ func TestCreateCategoryWithEmptyTitle(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestCannotCreateDuplicatedCategory(t *testing.T) {
|
||||
username := getRandomUsername()
|
||||
client := miniflux.NewClient(testBaseURL, testAdminUsername, testAdminPassword)
|
||||
_, err := client.CreateUser(username, testStandardPassword, false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
client := createClient(t)
|
||||
|
||||
categoryName := "My category"
|
||||
client = miniflux.NewClient(testBaseURL, username, testStandardPassword)
|
||||
_, err = client.CreateCategory(categoryName)
|
||||
_, err := client.CreateCategory(categoryName)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -503,14 +538,8 @@ func TestListCategories(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestDeleteCategory(t *testing.T) {
|
||||
username := getRandomUsername()
|
||||
client := miniflux.NewClient(testBaseURL, testAdminUsername, testAdminPassword)
|
||||
_, err := client.CreateUser(username, testStandardPassword, false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
client := createClient(t)
|
||||
|
||||
client = miniflux.NewClient(testBaseURL, username, testStandardPassword)
|
||||
category, err := client.CreateCategory("My category")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -567,82 +596,36 @@ func TestDiscoverSubscriptions(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestCreateFeed(t *testing.T) {
|
||||
username := getRandomUsername()
|
||||
client := miniflux.NewClient(testBaseURL, testAdminUsername, testAdminPassword)
|
||||
_, err := client.CreateUser(username, testStandardPassword, false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
client := createClient(t)
|
||||
feed, _ := createFeed(t, client)
|
||||
|
||||
client = miniflux.NewClient(testBaseURL, username, testStandardPassword)
|
||||
categories, err := client.Categories()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
feedID, err := client.CreateFeed(testFeedURL, categories[0].ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if feedID == 0 {
|
||||
t.Fatalf(`Invalid feed ID, got "%v"`, feedID)
|
||||
if feed.ID == 0 {
|
||||
t.Fatalf(`Invalid feed ID, got %q`, feed.ID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCannotCreateDuplicatedFeed(t *testing.T) {
|
||||
username := getRandomUsername()
|
||||
client := miniflux.NewClient(testBaseURL, testAdminUsername, testAdminPassword)
|
||||
_, err := client.CreateUser(username, testStandardPassword, false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
client := createClient(t)
|
||||
feed, category := createFeed(t, client)
|
||||
|
||||
client = miniflux.NewClient(testBaseURL, username, testStandardPassword)
|
||||
categories, err := client.Categories()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
feedID, err := client.CreateFeed(testFeedURL, categories[0].ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if feedID == 0 {
|
||||
t.Fatalf(`Invalid feed ID, got "%v"`, feedID)
|
||||
}
|
||||
|
||||
_, err = client.CreateFeed(testFeedURL, categories[0].ID)
|
||||
_, err := client.CreateFeed(feed.FeedURL, category.ID)
|
||||
if err == nil {
|
||||
t.Fatal(`Duplicated feeds should not be allowed`)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateFeedWithInexistingCategory(t *testing.T) {
|
||||
username := getRandomUsername()
|
||||
client := miniflux.NewClient(testBaseURL, testAdminUsername, testAdminPassword)
|
||||
_, err := client.CreateUser(username, testStandardPassword, false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
client := createClient(t)
|
||||
|
||||
client = miniflux.NewClient(testBaseURL, username, testStandardPassword)
|
||||
_, err = client.CreateFeed(testFeedURL, -1)
|
||||
_, err := client.CreateFeed(testFeedURL, -1)
|
||||
if err == nil {
|
||||
t.Fatal(`Feeds should not be created with inexisting category`)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExport(t *testing.T) {
|
||||
username := getRandomUsername()
|
||||
client := miniflux.NewClient(testBaseURL, testAdminUsername, testAdminPassword)
|
||||
_, err := client.CreateUser(username, testStandardPassword, false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
client := createClient(t)
|
||||
|
||||
client = miniflux.NewClient(testBaseURL, username, testStandardPassword)
|
||||
output, err := client.Export()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -653,119 +636,277 @@ func TestExport(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateFeed(t *testing.T) {
|
||||
username := getRandomUsername()
|
||||
client := miniflux.NewClient(testBaseURL, testAdminUsername, testAdminPassword)
|
||||
_, err := client.CreateUser(username, testStandardPassword, false)
|
||||
func TestImport(t *testing.T) {
|
||||
client := createClient(t)
|
||||
|
||||
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 TestUpdateFeedURL(t *testing.T) {
|
||||
client := createClient(t)
|
||||
feed, _ := createFeed(t, client)
|
||||
|
||||
url := "test"
|
||||
updatedFeed, err := client.UpdateFeed(feed.ID, &miniflux.FeedModification{FeedURL: &url})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
client = miniflux.NewClient(testBaseURL, username, testStandardPassword)
|
||||
categories, err := client.Categories()
|
||||
if updatedFeed.FeedURL != url {
|
||||
t.Fatalf(`Wrong FeedURL, got %q instead of %q`, updatedFeed.FeedURL, url)
|
||||
}
|
||||
|
||||
url = ""
|
||||
updatedFeed, err = client.UpdateFeed(feed.ID, &miniflux.FeedModification{FeedURL: &url})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
feedID, err := client.CreateFeed(testFeedURL, categories[0].ID)
|
||||
if updatedFeed.FeedURL == "" {
|
||||
t.Fatalf(`The FeedURL should not be empty`)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateFeedSiteURL(t *testing.T) {
|
||||
client := createClient(t)
|
||||
feed, _ := createFeed(t, client)
|
||||
|
||||
url := "test"
|
||||
updatedFeed, err := client.UpdateFeed(feed.ID, &miniflux.FeedModification{SiteURL: &url})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if feedID == 0 {
|
||||
t.Fatalf(`Invalid feed ID, got "%v"`, feedID)
|
||||
if updatedFeed.SiteURL != url {
|
||||
t.Fatalf(`Wrong SiteURL, got %q instead of %q`, updatedFeed.SiteURL, url)
|
||||
}
|
||||
|
||||
feed, err := client.Feed(feedID)
|
||||
url = ""
|
||||
updatedFeed, err = client.UpdateFeed(feed.ID, &miniflux.FeedModification{SiteURL: &url})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if updatedFeed.SiteURL == "" {
|
||||
t.Fatalf(`The SiteURL should not be empty`)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateFeedTitle(t *testing.T) {
|
||||
client := createClient(t)
|
||||
feed, _ := createFeed(t, client)
|
||||
|
||||
newTitle := "My new feed"
|
||||
feed.Title = newTitle
|
||||
feed, err = client.UpdateFeed(feed)
|
||||
updatedFeed, err := client.UpdateFeed(feed.ID, &miniflux.FeedModification{Title: &newTitle})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if feed.Title != newTitle {
|
||||
t.Errorf(`Wrong title, got "%v" instead of "%v"`, feed.Title, newTitle)
|
||||
if updatedFeed.Title != newTitle {
|
||||
t.Fatalf(`Wrong title, got %q instead of %q`, updatedFeed.Title, newTitle)
|
||||
}
|
||||
|
||||
newTitle = ""
|
||||
updatedFeed, err = client.UpdateFeed(feed.ID, &miniflux.FeedModification{Title: &newTitle})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if updatedFeed.Title == "" {
|
||||
t.Fatalf(`The Title should not be empty`)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateFeedCrawler(t *testing.T) {
|
||||
client := createClient(t)
|
||||
feed, _ := createFeed(t, client)
|
||||
|
||||
crawler := true
|
||||
updatedFeed, err := client.UpdateFeed(feed.ID, &miniflux.FeedModification{Crawler: &crawler})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if updatedFeed.Crawler != crawler {
|
||||
t.Fatalf(`Wrong crawler value, got "%v" instead of "%v"`, updatedFeed.Crawler, crawler)
|
||||
}
|
||||
|
||||
if updatedFeed.Title != feed.Title {
|
||||
t.Fatalf(`The titles should be the same after update`)
|
||||
}
|
||||
|
||||
crawler = false
|
||||
updatedFeed, err = client.UpdateFeed(feed.ID, &miniflux.FeedModification{Crawler: &crawler})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if updatedFeed.Crawler != crawler {
|
||||
t.Fatalf(`Wrong crawler value, got "%v" instead of "%v"`, updatedFeed.Crawler, crawler)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateFeedScraperRules(t *testing.T) {
|
||||
client := createClient(t)
|
||||
feed, _ := createFeed(t, client)
|
||||
|
||||
scraperRules := "test"
|
||||
updatedFeed, err := client.UpdateFeed(feed.ID, &miniflux.FeedModification{ScraperRules: &scraperRules})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if updatedFeed.ScraperRules != scraperRules {
|
||||
t.Fatalf(`Wrong ScraperRules value, got "%v" instead of "%v"`, updatedFeed.ScraperRules, scraperRules)
|
||||
}
|
||||
|
||||
scraperRules = ""
|
||||
updatedFeed, err = client.UpdateFeed(feed.ID, &miniflux.FeedModification{ScraperRules: &scraperRules})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if updatedFeed.ScraperRules != scraperRules {
|
||||
t.Fatalf(`Wrong ScraperRules value, got "%v" instead of "%v"`, updatedFeed.ScraperRules, scraperRules)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateFeedRewriteRules(t *testing.T) {
|
||||
client := createClient(t)
|
||||
feed, _ := createFeed(t, client)
|
||||
|
||||
rewriteRules := "test"
|
||||
updatedFeed, err := client.UpdateFeed(feed.ID, &miniflux.FeedModification{RewriteRules: &rewriteRules})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if updatedFeed.RewriteRules != rewriteRules {
|
||||
t.Fatalf(`Wrong RewriteRules value, got "%v" instead of "%v"`, updatedFeed.RewriteRules, rewriteRules)
|
||||
}
|
||||
|
||||
rewriteRules = ""
|
||||
updatedFeed, err = client.UpdateFeed(feed.ID, &miniflux.FeedModification{RewriteRules: &rewriteRules})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if updatedFeed.RewriteRules != rewriteRules {
|
||||
t.Fatalf(`Wrong RewriteRules value, got "%v" instead of "%v"`, updatedFeed.RewriteRules, rewriteRules)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateFeedUsername(t *testing.T) {
|
||||
client := createClient(t)
|
||||
feed, _ := createFeed(t, client)
|
||||
|
||||
username := "test"
|
||||
updatedFeed, err := client.UpdateFeed(feed.ID, &miniflux.FeedModification{Username: &username})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if updatedFeed.Username != username {
|
||||
t.Fatalf(`Wrong Username value, got "%v" instead of "%v"`, updatedFeed.Username, username)
|
||||
}
|
||||
|
||||
username = ""
|
||||
updatedFeed, err = client.UpdateFeed(feed.ID, &miniflux.FeedModification{Username: &username})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if updatedFeed.Username != username {
|
||||
t.Fatalf(`Wrong Username value, got "%v" instead of "%v"`, updatedFeed.Username, username)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateFeedPassword(t *testing.T) {
|
||||
client := createClient(t)
|
||||
feed, _ := createFeed(t, client)
|
||||
|
||||
password := "test"
|
||||
updatedFeed, err := client.UpdateFeed(feed.ID, &miniflux.FeedModification{Password: &password})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if updatedFeed.Password != password {
|
||||
t.Fatalf(`Wrong Password value, got "%v" instead of "%v"`, updatedFeed.Password, password)
|
||||
}
|
||||
|
||||
password = ""
|
||||
updatedFeed, err = client.UpdateFeed(feed.ID, &miniflux.FeedModification{Password: &password})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if updatedFeed.Password != password {
|
||||
t.Fatalf(`Wrong Password value, got "%v" instead of "%v"`, updatedFeed.Password, password)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateFeedCategory(t *testing.T) {
|
||||
client := createClient(t)
|
||||
feed, _ := createFeed(t, client)
|
||||
|
||||
newCategory, err := client.CreateCategory("my new category")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
updatedFeed, err := client.UpdateFeed(feed.ID, &miniflux.FeedModification{CategoryID: &newCategory.ID})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if updatedFeed.Category.ID != newCategory.ID {
|
||||
t.Fatalf(`Wrong CategoryID value, got "%v" instead of "%v"`, updatedFeed.Category.ID, newCategory.ID)
|
||||
}
|
||||
|
||||
categoryID := int64(0)
|
||||
updatedFeed, err = client.UpdateFeed(feed.ID, &miniflux.FeedModification{CategoryID: &categoryID})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if updatedFeed.Category.ID == 0 {
|
||||
t.Fatalf(`The CategoryID must defined`)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteFeed(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)
|
||||
categories, err := client.Categories()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
feedID, err := client.CreateFeed(testFeedURL, categories[0].ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
err = client.DeleteFeed(feedID)
|
||||
if err != nil {
|
||||
client := createClient(t)
|
||||
feed, _ := createFeed(t, client)
|
||||
if err := client.DeleteFeed(feed.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRefreshFeed(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)
|
||||
categories, err := client.Categories()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
feedID, err := client.CreateFeed(testFeedURL, categories[0].ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
err = client.RefreshFeed(feedID)
|
||||
if err != nil {
|
||||
client := createClient(t)
|
||||
feed, _ := createFeed(t, client)
|
||||
if err := client.RefreshFeed(feed.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetFeed(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)
|
||||
categories, err := client.Categories()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
feedID, err := client.CreateFeed(testFeedURL, categories[0].ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
feed, err := client.Feed(feedID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
client := createClient(t)
|
||||
feed, category := createFeed(t, client)
|
||||
|
||||
if feed.Title != testFeedTitle {
|
||||
t.Fatalf(`Invalid feed title, got "%v" instead of "%v"`, feed.Title, testFeedTitle)
|
||||
@@ -779,39 +920,23 @@ func TestGetFeed(t *testing.T) {
|
||||
t.Fatalf(`Invalid feed URL, got "%v" instead of "%v"`, feed.FeedURL, testFeedURL)
|
||||
}
|
||||
|
||||
if feed.Category.ID != categories[0].ID {
|
||||
t.Fatalf(`Invalid feed category ID, got "%v" instead of "%v"`, feed.Category.ID, categories[0].ID)
|
||||
if feed.Category.ID != category.ID {
|
||||
t.Fatalf(`Invalid feed category ID, got "%v" instead of "%v"`, feed.Category.ID, category.ID)
|
||||
}
|
||||
|
||||
if feed.Category.UserID != categories[0].UserID {
|
||||
t.Fatalf(`Invalid feed category user ID, got "%v" instead of "%v"`, feed.Category.UserID, categories[0].UserID)
|
||||
if feed.Category.UserID != category.UserID {
|
||||
t.Fatalf(`Invalid feed category user ID, got "%v" instead of "%v"`, feed.Category.UserID, category.UserID)
|
||||
}
|
||||
|
||||
if feed.Category.Title != categories[0].Title {
|
||||
t.Fatalf(`Invalid feed category title, got "%v" instead of "%v"`, feed.Category.Title, categories[0].Title)
|
||||
if feed.Category.Title != category.Title {
|
||||
t.Fatalf(`Invalid feed category title, got "%v" instead of "%v"`, feed.Category.Title, category.Title)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetFeedIcon(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)
|
||||
categories, err := client.Categories()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
feedID, err := client.CreateFeed(testFeedURL, categories[0].ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
feedIcon, err := client.FeedIcon(feedID)
|
||||
client := createClient(t)
|
||||
feed, _ := createFeed(t, client)
|
||||
feedIcon, err := client.FeedIcon(feed.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -844,23 +969,8 @@ func TestGetFeedIconNotFound(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestGetFeeds(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)
|
||||
categories, err := client.Categories()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
feedID, err := client.CreateFeed(testFeedURL, categories[0].ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
client := createClient(t)
|
||||
feed, category := createFeed(t, client)
|
||||
|
||||
feeds, err := client.Feeds()
|
||||
if err != nil {
|
||||
@@ -871,8 +981,8 @@ func TestGetFeeds(t *testing.T) {
|
||||
t.Fatalf(`Invalid number of feeds`)
|
||||
}
|
||||
|
||||
if feeds[0].ID != feedID {
|
||||
t.Fatalf(`Invalid feed ID, got "%v" instead of "%v"`, feeds[0].ID, feedID)
|
||||
if feeds[0].ID != feed.ID {
|
||||
t.Fatalf(`Invalid feed ID, got "%v" instead of "%v"`, feeds[0].ID, feed.ID)
|
||||
}
|
||||
|
||||
if feeds[0].Title != testFeedTitle {
|
||||
@@ -887,39 +997,24 @@ func TestGetFeeds(t *testing.T) {
|
||||
t.Fatalf(`Invalid feed URL, got "%v" instead of "%v"`, feeds[0].FeedURL, testFeedURL)
|
||||
}
|
||||
|
||||
if feeds[0].Category.ID != categories[0].ID {
|
||||
t.Fatalf(`Invalid feed category ID, got "%v" instead of "%v"`, feeds[0].Category.ID, categories[0].ID)
|
||||
if feeds[0].Category.ID != category.ID {
|
||||
t.Fatalf(`Invalid feed category ID, got "%v" instead of "%v"`, feeds[0].Category.ID, category.ID)
|
||||
}
|
||||
|
||||
if feeds[0].Category.UserID != categories[0].UserID {
|
||||
t.Fatalf(`Invalid feed category user ID, got "%v" instead of "%v"`, feeds[0].Category.UserID, categories[0].UserID)
|
||||
if feeds[0].Category.UserID != category.UserID {
|
||||
t.Fatalf(`Invalid feed category user ID, got "%v" instead of "%v"`, feeds[0].Category.UserID, category.UserID)
|
||||
}
|
||||
|
||||
if feeds[0].Category.Title != categories[0].Title {
|
||||
t.Fatalf(`Invalid feed category title, got "%v" instead of "%v"`, feeds[0].Category.Title, categories[0].Title)
|
||||
if feeds[0].Category.Title != category.Title {
|
||||
t.Fatalf(`Invalid feed category title, got "%v" instead of "%v"`, feeds[0].Category.Title, category.Title)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetAllFeedEntries(t *testing.T) {
|
||||
username := getRandomUsername()
|
||||
client := miniflux.NewClient(testBaseURL, testAdminUsername, testAdminPassword)
|
||||
_, err := client.CreateUser(username, testStandardPassword, false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
client := createClient(t)
|
||||
feed, _ := createFeed(t, client)
|
||||
|
||||
client = miniflux.NewClient(testBaseURL, username, testStandardPassword)
|
||||
categories, err := client.Categories()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
feedID, err := client.CreateFeed(testFeedURL, categories[0].ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
allResults, err := client.FeedEntries(feedID, nil)
|
||||
allResults, err := client.FeedEntries(feed.ID, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -932,7 +1027,7 @@ func TestGetAllFeedEntries(t *testing.T) {
|
||||
t.Fatal(`Invalid entry title`)
|
||||
}
|
||||
|
||||
filteredResults, err := client.FeedEntries(feedID, &miniflux.Filter{Limit: 1, Offset: 5})
|
||||
filteredResults, err := client.FeedEntries(feed.ID, &miniflux.Filter{Limit: 1, Offset: 5})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -942,28 +1037,22 @@ func TestGetAllFeedEntries(t *testing.T) {
|
||||
}
|
||||
|
||||
if allResults.Entries[0].ID == filteredResults.Entries[0].ID {
|
||||
t.Fatal(`Filtered entries should be different than previous result`)
|
||||
t.Fatal(`Filtered entries should be different than previous results`)
|
||||
}
|
||||
|
||||
filteredResultsByEntryID, err := client.FeedEntries(feed.ID, &miniflux.Filter{BeforeEntryID: allResults.Entries[0].ID})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if filteredResultsByEntryID.Entries[0].ID == allResults.Entries[0].ID {
|
||||
t.Fatal(`The first entry should filtered out`)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetAllEntries(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)
|
||||
categories, err := client.Categories()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
_, err = client.CreateFeed(testFeedURL, categories[0].ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
client := createClient(t)
|
||||
createFeed(t, client)
|
||||
|
||||
resultWithoutSorting, err := client.Entries(nil)
|
||||
if err != nil {
|
||||
@@ -991,28 +1080,22 @@ func TestGetAllEntries(t *testing.T) {
|
||||
if resultWithDifferentSorting.Entries[0].Title == resultWithoutSorting.Entries[0].Title {
|
||||
t.Fatalf(`The items should be sorted differently "%v" vs "%v"`, resultWithDifferentSorting.Entries[0].Title, resultWithoutSorting.Entries[0].Title)
|
||||
}
|
||||
|
||||
resultWithStarredEntries, err := client.Entries(&miniflux.Filter{Starred: true})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if resultWithStarredEntries.Total != 0 {
|
||||
t.Fatalf(`We are not supposed to have starred entries yet`)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInvalidFilters(t *testing.T) {
|
||||
username := getRandomUsername()
|
||||
client := miniflux.NewClient(testBaseURL, testAdminUsername, testAdminPassword)
|
||||
_, err := client.CreateUser(username, testStandardPassword, false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
client := createClient(t)
|
||||
createFeed(t, client)
|
||||
|
||||
client = miniflux.NewClient(testBaseURL, username, testStandardPassword)
|
||||
categories, err := client.Categories()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
_, err = client.CreateFeed(testFeedURL, categories[0].ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
_, err = client.Entries(&miniflux.Filter{Status: "invalid"})
|
||||
_, err := client.Entries(&miniflux.Filter{Status: "invalid"})
|
||||
if err == nil {
|
||||
t.Fatal(`Using invalid status should raise an error`)
|
||||
}
|
||||
@@ -1029,23 +1112,8 @@ func TestInvalidFilters(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestGetEntry(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)
|
||||
categories, err := client.Categories()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
_, err = client.CreateFeed(testFeedURL, categories[0].ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
client := createClient(t)
|
||||
createFeed(t, client)
|
||||
|
||||
result, err := client.Entries(&miniflux.Filter{Limit: 1})
|
||||
if err != nil {
|
||||
@@ -1072,23 +1140,8 @@ func TestGetEntry(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestUpdateStatus(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)
|
||||
categories, err := client.Categories()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
_, err = client.CreateFeed(testFeedURL, categories[0].ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
client := createClient(t)
|
||||
createFeed(t, client)
|
||||
|
||||
result, err := client.Entries(&miniflux.Filter{Limit: 1})
|
||||
if err != nil {
|
||||
@@ -1116,23 +1169,8 @@ func TestUpdateStatus(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestToggleBookmark(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)
|
||||
categories, err := client.Categories()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
_, err = client.CreateFeed(testFeedURL, categories[0].ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
client := createClient(t)
|
||||
createFeed(t, client)
|
||||
|
||||
result, err := client.Entries(&miniflux.Filter{Limit: 1})
|
||||
if err != nil {
|
||||
@@ -1166,3 +1204,37 @@ func getRandomUsername() string {
|
||||
}
|
||||
return "user" + strings.Join(suffix, "")
|
||||
}
|
||||
|
||||
func createClient(t *testing.T) *miniflux.Client {
|
||||
username := getRandomUsername()
|
||||
client := miniflux.NewClient(testBaseURL, testAdminUsername, testAdminPassword)
|
||||
_, err := client.CreateUser(username, testStandardPassword, false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
return miniflux.NewClient(testBaseURL, username, testStandardPassword)
|
||||
}
|
||||
|
||||
func createFeed(t *testing.T, client *miniflux.Client) (*miniflux.Feed, *miniflux.Category) {
|
||||
categories, err := client.Categories()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
feedID, err := client.CreateFeed(testFeedURL, categories[0].ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if feedID == 0 {
|
||||
t.Fatalf(`Invalid feed ID, got %q`, feedID)
|
||||
}
|
||||
|
||||
feed, err := client.Feed(feedID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
return feed, categories[0]
|
||||
}
|
||||
|
||||
Regular → Executable
+3
@@ -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
+754
-31
@@ -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-06-30 23:41:46.05193077 +0200 CEST m=+0.307244685
|
||||
|
||||
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 (Status Code = %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 Abonnement konnte nicht gelesen werden: %q",
|
||||
"Unable to parse Atom feed: %q": "Atom Abonnement konnte nicht gelesen werden: %q",
|
||||
"Unable to parse JSON feed: %q": "JSON Abonnement konnte nicht gelesen werden: %q",
|
||||
"Unable to parse RDF feed: %q": "RDF Abonnement 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 verknüpft!",
|
||||
"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",
|
||||
@@ -207,7 +210,7 @@ var translations = map[string]string{
|
||||
"This feed is empty": "Dieses Abonnement ist leer",
|
||||
"Flush history": "Verlauf leeren",
|
||||
"Site URL": "Webseite-URL",
|
||||
"Feed URL": "Feed-URL",
|
||||
"Feed URL": "Abonnement-URL",
|
||||
"Logged as %s": "Angemeldet als %s",
|
||||
"Unread Items": "Ungelesen",
|
||||
"Change entry status": "Status des Artikels ändern",
|
||||
@@ -215,7 +218,30 @@ 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",
|
||||
"This file is empty": "Diese Datei ist leer",
|
||||
"Your external account is now dissociated!": "Ihr externer Account ist jetzt getrennt!",
|
||||
"You must define a password otherwise you won't be able to login again.": "Sie müssen ein Passwort festlegen, sonst können Sie sich nicht erneut anmelden.",
|
||||
"Save articles to Pocket": "Artikel in Pocket speichern",
|
||||
"Connect your Pocket account": "Verbinden Sie Ihr Pocket Konto",
|
||||
"Pocket Consumer Key": "« Pocket Consumer Key »",
|
||||
"Pocket Access Token": "« Pocket Access Token »",
|
||||
"Your Pocket account is now linked!": "Ihr Pocket Konto ist jetzt verknüpft!",
|
||||
"Unable to fetch access token from Pocket!": "Zugriffstoken konnte nicht von Pocket abgerufen werden!",
|
||||
"Unable to fetch request token from Pocket!": "Anfrage-Token konnte nicht von Pocket abgerufen werden!",
|
||||
"Advanced Options": "Erweiterte Optionen",
|
||||
"Feed Username": "Benutzername des Abonnements",
|
||||
"Feed Password": "Passwort des Abonnements",
|
||||
"You are not authorized to access this resource (invalid username/password)": "Sie sind nicht berechtigt, auf diese Ressource zuzugreifen (Benutzername/Passwort ungültig)",
|
||||
"Unable to fetch this resource (Status Code = %d)": "Ressource konnte nicht abgerufen werden (code=%d)",
|
||||
"Resource not found (404), this feed doesn't exists anymore, check the feed URL": "Ressource nicht gefunden (404), dieses Abonnement existiert nicht mehr, überprüfen Sie die Abonnement-URL"
|
||||
}
|
||||
`,
|
||||
"en_US": `{
|
||||
@@ -314,8 +340,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 (Status Code = %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 +353,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 +366,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 +389,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 +411,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 +443,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 +468,706 @@ 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 !",
|
||||
"Advanced Options": "Options avancées",
|
||||
"Feed Username": "Nom d'utilisateur du flux",
|
||||
"Feed Password": "Mot de passe du flux",
|
||||
"You are not authorized to access this resource (invalid username/password)": "Vous n'êtes pas autorisé à accéder à cette ressource (nom d'utilisateur / mot de passe incorrect)",
|
||||
"Unable to fetch this resource (Status Code = %d)": "Impossible de récupérer cette ressource (code=%d)",
|
||||
"Resource not found (404), this feed doesn't exists anymore, check the feed URL": "Page introuvable (404), cet abonnement n'existe plus, vérifiez l'adresse du flux"
|
||||
}
|
||||
`,
|
||||
"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 (Status Code = %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 (Status Code = %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 (Status Code = %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": "eddbb2c3224169a6533eed6f917af95b8d9bee58c3b3d61951260face7edd768",
|
||||
"en_US": "6fe95384260941e8a5a3c695a655a932e0a8a6a572c1e45cb2b1ae8baa01b897",
|
||||
"fr_FR": "e6305fd54508a4f54d630e3ef231a8eadc2335ed0ffac7b5266b2ec751824e28",
|
||||
"fr_FR": "7a451a1d09e051a847f554937e07a6af24b92f1da7f46da8c0ef2d4cc31bcec6",
|
||||
"nl_NL": "bec647fdfb325a30050c50bdb9a29fee58b0705109d32c09b5ace77aa0777e7c",
|
||||
"pl_PL": "2295f35a98c8f60cfc6bab241d26b224c06979cc9ca3740bb89c63c7596a0431",
|
||||
"zh_CN": "f5fb0a9b7336c51e74d727a2fb294bab3514e3002376da7fd904e0d7caed1a1c",
|
||||
}
|
||||
|
||||
@@ -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 (Status Code = %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 Abonnement konnte nicht gelesen werden: %q",
|
||||
"Unable to parse Atom feed: %q": "Atom Abonnement konnte nicht gelesen werden: %q",
|
||||
"Unable to parse JSON feed: %q": "JSON Abonnement konnte nicht gelesen werden: %q",
|
||||
"Unable to parse RDF feed: %q": "RDF Abonnement 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 verknüpft!",
|
||||
"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",
|
||||
@@ -201,7 +204,7 @@
|
||||
"This feed is empty": "Dieses Abonnement ist leer",
|
||||
"Flush history": "Verlauf leeren",
|
||||
"Site URL": "Webseite-URL",
|
||||
"Feed URL": "Feed-URL",
|
||||
"Feed URL": "Abonnement-URL",
|
||||
"Logged as %s": "Angemeldet als %s",
|
||||
"Unread Items": "Ungelesen",
|
||||
"Change entry status": "Status des Artikels ändern",
|
||||
@@ -209,5 +212,28 @@
|
||||
"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",
|
||||
"This file is empty": "Diese Datei ist leer",
|
||||
"Your external account is now dissociated!": "Ihr externer Account ist jetzt getrennt!",
|
||||
"You must define a password otherwise you won't be able to login again.": "Sie müssen ein Passwort festlegen, sonst können Sie sich nicht erneut anmelden.",
|
||||
"Save articles to Pocket": "Artikel in Pocket speichern",
|
||||
"Connect your Pocket account": "Verbinden Sie Ihr Pocket Konto",
|
||||
"Pocket Consumer Key": "« Pocket Consumer Key »",
|
||||
"Pocket Access Token": "« Pocket Access Token »",
|
||||
"Your Pocket account is now linked!": "Ihr Pocket Konto ist jetzt verknüpft!",
|
||||
"Unable to fetch access token from Pocket!": "Zugriffstoken konnte nicht von Pocket abgerufen werden!",
|
||||
"Unable to fetch request token from Pocket!": "Anfrage-Token konnte nicht von Pocket abgerufen werden!",
|
||||
"Advanced Options": "Erweiterte Optionen",
|
||||
"Feed Username": "Benutzername des Abonnements",
|
||||
"Feed Password": "Passwort des Abonnements",
|
||||
"You are not authorized to access this resource (invalid username/password)": "Sie sind nicht berechtigt, auf diese Ressource zuzugreifen (Benutzername/Passwort ungültig)",
|
||||
"Unable to fetch this resource (Status Code = %d)": "Ressource konnte nicht abgerufen werden (code=%d)",
|
||||
"Resource not found (404), this feed doesn't exists anymore, check the feed URL": "Ressource nicht gefunden (404), dieses Abonnement existiert nicht mehr, überprüfen Sie die Abonnement-URL"
|
||||
}
|
||||
|
||||
@@ -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 (Status Code = %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,28 @@
|
||||
"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 !",
|
||||
"Advanced Options": "Options avancées",
|
||||
"Feed Username": "Nom d'utilisateur du flux",
|
||||
"Feed Password": "Mot de passe du flux",
|
||||
"You are not authorized to access this resource (invalid username/password)": "Vous n'êtes pas autorisé à accéder à cette ressource (nom d'utilisateur / mot de passe incorrect)",
|
||||
"Unable to fetch this resource (Status Code = %d)": "Impossible de récupérer cette ressource (code=%d)",
|
||||
"Resource not found (404), this feed doesn't exists anymore, check the feed URL": "Page introuvable (404), cet abonnement n'existe plus, vérifiez l'adresse du flux"
|
||||
}
|
||||
|
||||
@@ -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 (Status Code = %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"
|
||||
}
|
||||
Executable
+223
@@ -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 (Status Code = %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"
|
||||
}
|
||||
@@ -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 (Status Code = %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 秒后超时"
|
||||
}
|
||||
@@ -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}
|
||||
}
|
||||
@@ -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}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
// Copyright 2018 Frédéric Guillot. All rights reserved.
|
||||
// Use of this source code is governed by the Apache 2.0
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
)
|
||||
|
||||
// HeaderConfig changes config values according to HTTP headers.
|
||||
func (m *Middleware) HeaderConfig(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Header.Get("X-Forwarded-Proto") == "https" {
|
||||
m.cfg.IsHTTPS = true
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,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)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
// Copyright 2018 Frédéric Guillot. All rights reserved.
|
||||
// Use of this source code is governed by the Apache 2.0
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"github.com/gorilla/mux"
|
||||
"github.com/miniflux/miniflux/config"
|
||||
"github.com/miniflux/miniflux/storage"
|
||||
)
|
||||
|
||||
// Middleware handles different middleware handlers.
|
||||
type Middleware struct {
|
||||
cfg *config.Config
|
||||
store *storage.Storage
|
||||
router *mux.Router
|
||||
}
|
||||
|
||||
// New returns a new middleware.
|
||||
func New(cfg *config.Config, store *storage.Storage, router *mux.Router) *Middleware {
|
||||
return &Middleware{cfg, store, router}
|
||||
}
|
||||
@@ -9,34 +9,30 @@ import (
|
||||
"net/http"
|
||||
|
||||
"github.com/miniflux/miniflux/http/cookie"
|
||||
"github.com/miniflux/miniflux/http/request"
|
||||
"github.com/miniflux/miniflux/http/response"
|
||||
"github.com/miniflux/miniflux/http/route"
|
||||
"github.com/miniflux/miniflux/logger"
|
||||
"github.com/miniflux/miniflux/model"
|
||||
"github.com/miniflux/miniflux/storage"
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
)
|
||||
|
||||
// UserSessionMiddleware represents a user session middleware.
|
||||
type UserSessionMiddleware struct {
|
||||
store *storage.Storage
|
||||
router *mux.Router
|
||||
}
|
||||
|
||||
// Handler execute the middleware.
|
||||
func (s *UserSessionMiddleware) Handler(next http.Handler) http.Handler {
|
||||
// UserSession handles the user session middleware.
|
||||
func (m *Middleware) UserSession(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
session := s.getSessionFromCookie(r)
|
||||
session := m.getUserSessionFromCookie(r)
|
||||
|
||||
if session == nil {
|
||||
logger.Debug("[Middleware:UserSession] Session not found")
|
||||
if s.isPublicRoute(r) {
|
||||
if m.isPublicRoute(r) {
|
||||
next.ServeHTTP(w, r)
|
||||
} else {
|
||||
http.Redirect(w, r, route.Path(s.router, "login"), http.StatusFound)
|
||||
response.Redirect(w, r, route.Path(m.router, "login"))
|
||||
}
|
||||
} else {
|
||||
logger.Debug("[Middleware:UserSession] %s", session)
|
||||
|
||||
ctx := r.Context()
|
||||
ctx = context.WithValue(ctx, UserIDContextKey, session.UserID)
|
||||
ctx = context.WithValue(ctx, IsAuthenticatedContextKey, true)
|
||||
@@ -47,7 +43,7 @@ func (s *UserSessionMiddleware) Handler(next http.Handler) http.Handler {
|
||||
})
|
||||
}
|
||||
|
||||
func (s *UserSessionMiddleware) isPublicRoute(r *http.Request) bool {
|
||||
func (m *Middleware) isPublicRoute(r *http.Request) bool {
|
||||
route := mux.CurrentRoute(r)
|
||||
switch route.GetName() {
|
||||
case "login",
|
||||
@@ -65,13 +61,13 @@ func (s *UserSessionMiddleware) isPublicRoute(r *http.Request) bool {
|
||||
}
|
||||
}
|
||||
|
||||
func (s *UserSessionMiddleware) getSessionFromCookie(r *http.Request) *model.UserSession {
|
||||
sessionCookie, err := r.Cookie(cookie.CookieUserSessionID)
|
||||
if err == http.ErrNoCookie {
|
||||
func (m *Middleware) getUserSessionFromCookie(r *http.Request) *model.UserSession {
|
||||
cookieValue := request.Cookie(r, cookie.CookieUserSessionID)
|
||||
if cookieValue == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
session, err := s.store.UserSessionByToken(sessionCookie.Value)
|
||||
session, err := m.store.UserSessionByToken(cookieValue)
|
||||
if err != nil {
|
||||
logger.Error("[Middleware:UserSession] %v", err)
|
||||
return nil
|
||||
@@ -79,8 +75,3 @@ func (s *UserSessionMiddleware) getSessionFromCookie(r *http.Request) *model.Use
|
||||
|
||||
return session
|
||||
}
|
||||
|
||||
// NewUserSessionMiddleware returns a new UserSessionMiddleware.
|
||||
func NewUserSessionMiddleware(s *storage.Storage, r *mux.Router) *UserSessionMiddleware {
|
||||
return &UserSessionMiddleware{store: s, router: r}
|
||||
}
|
||||
+1
-1
@@ -11,7 +11,7 @@ type Enclosure struct {
|
||||
EntryID int64 `json:"entry_id"`
|
||||
URL string `json:"url"`
|
||||
MimeType string `json:"mime_type"`
|
||||
Size int `json:"size"`
|
||||
Size int64 `json:"size"`
|
||||
}
|
||||
|
||||
// EnclosureList represents a list of attachments.
|
||||
|
||||
+15
-14
@@ -20,20 +20,21 @@ const (
|
||||
|
||||
// Entry represents a feed item in the system.
|
||||
type Entry struct {
|
||||
ID int64 `json:"id"`
|
||||
UserID int64 `json:"user_id"`
|
||||
FeedID int64 `json:"feed_id"`
|
||||
Status string `json:"status"`
|
||||
Hash string `json:"hash"`
|
||||
Title string `json:"title"`
|
||||
URL string `json:"url"`
|
||||
Date time.Time `json:"published_at"`
|
||||
Content string `json:"content"`
|
||||
Author string `json:"author"`
|
||||
Starred bool `json:"starred"`
|
||||
Enclosures EnclosureList `json:"enclosures,omitempty"`
|
||||
Feed *Feed `json:"feed,omitempty"`
|
||||
Category *Category `json:"category,omitempty"`
|
||||
ID int64 `json:"id"`
|
||||
UserID int64 `json:"user_id"`
|
||||
FeedID int64 `json:"feed_id"`
|
||||
Status string `json:"status"`
|
||||
Hash string `json:"hash"`
|
||||
Title string `json:"title"`
|
||||
URL string `json:"url"`
|
||||
CommentsURL string `json:"comments_url"`
|
||||
Date time.Time `json:"published_at"`
|
||||
Content string `json:"content"`
|
||||
Author string `json:"author"`
|
||||
Starred bool `json:"starred"`
|
||||
Enclosures EnclosureList `json:"enclosures,omitempty"`
|
||||
Feed *Feed `json:"feed,omitempty"`
|
||||
Category *Category `json:"category,omitempty"`
|
||||
}
|
||||
|
||||
// Entries represents a list of entries.
|
||||
|
||||
+2
-31
@@ -24,6 +24,8 @@ type Feed struct {
|
||||
ScraperRules string `json:"scraper_rules"`
|
||||
RewriteRules string `json:"rewrite_rules"`
|
||||
Crawler bool `json:"crawler"`
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
Category *Category `json:"category,omitempty"`
|
||||
Entries Entries `json:"entries,omitempty"`
|
||||
Icon *FeedIcon `json:"icon"`
|
||||
@@ -40,36 +42,5 @@ func (f *Feed) String() string {
|
||||
)
|
||||
}
|
||||
|
||||
// Merge combine override to the current struct
|
||||
func (f *Feed) Merge(override *Feed) {
|
||||
if override.Title != "" && override.Title != f.Title {
|
||||
f.Title = override.Title
|
||||
}
|
||||
|
||||
if override.SiteURL != "" && override.SiteURL != f.SiteURL {
|
||||
f.SiteURL = override.SiteURL
|
||||
}
|
||||
|
||||
if override.FeedURL != "" && override.FeedURL != f.FeedURL {
|
||||
f.FeedURL = override.FeedURL
|
||||
}
|
||||
|
||||
if override.ScraperRules != "" && override.ScraperRules != f.ScraperRules {
|
||||
f.ScraperRules = override.ScraperRules
|
||||
}
|
||||
|
||||
if override.RewriteRules != "" && override.RewriteRules != f.RewriteRules {
|
||||
f.RewriteRules = override.RewriteRules
|
||||
}
|
||||
|
||||
if override.Crawler != f.Crawler {
|
||||
f.Crawler = override.Crawler
|
||||
}
|
||||
|
||||
if override.Category != nil && override.Category.ID != 0 && override.Category.ID != f.Category.ID {
|
||||
f.Category.ID = override.Category.ID
|
||||
}
|
||||
}
|
||||
|
||||
// Feeds is a list of feed
|
||||
type Feeds []*Feed
|
||||
|
||||
@@ -1,59 +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 model
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestMergeFeedTitle(t *testing.T) {
|
||||
feed1 := &Feed{Title: "Feed 1"}
|
||||
feed2 := &Feed{Title: "Feed 2"}
|
||||
feed1.Merge(feed2)
|
||||
|
||||
if feed1.Title != "Feed 2" {
|
||||
t.Fatal(`The title of feed1 should be merged`)
|
||||
}
|
||||
|
||||
feed1 = &Feed{Title: "Feed 1"}
|
||||
feed2 = &Feed{}
|
||||
feed1.Merge(feed2)
|
||||
|
||||
if feed1.Title != "Feed 1" {
|
||||
t.Fatal(`The title of feed1 should not be merged`)
|
||||
}
|
||||
|
||||
feed1 = &Feed{Title: "Feed 1"}
|
||||
feed2 = &Feed{Title: "Feed 1"}
|
||||
feed1.Merge(feed2)
|
||||
|
||||
if feed1.Title != "Feed 1" {
|
||||
t.Fatal(`The title of feed1 should not be changed`)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMergeFeedCategory(t *testing.T) {
|
||||
feed1 := &Feed{Category: &Category{ID: 222}}
|
||||
feed2 := &Feed{Category: &Category{ID: 333}}
|
||||
feed1.Merge(feed2)
|
||||
|
||||
if feed1.Category.ID != 333 {
|
||||
t.Fatal(`The category of feed1 should be merged`)
|
||||
}
|
||||
|
||||
feed1 = &Feed{Category: &Category{ID: 222}}
|
||||
feed2 = &Feed{}
|
||||
feed1.Merge(feed2)
|
||||
|
||||
if feed1.Category.ID != 222 {
|
||||
t.Fatal(`The category of feed1 should not be merged`)
|
||||
}
|
||||
|
||||
feed1 = &Feed{Category: &Category{ID: 222}}
|
||||
feed2 = &Feed{Category: &Category{ID: 0}}
|
||||
feed1.Merge(feed2)
|
||||
|
||||
if feed1.Category.ID != 222 {
|
||||
t.Fatal(`The category of feed1 should not be merged`)
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
+3
-2
@@ -9,8 +9,9 @@ import "github.com/miniflux/miniflux/errors"
|
||||
// Themes returns the list of available themes.
|
||||
func Themes() map[string]string {
|
||||
return map[string]string{
|
||||
"default": "Default",
|
||||
"black": "Black",
|
||||
"default": "Default",
|
||||
"black": "Black",
|
||||
"sansserif": "Sans-Serif",
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+13
-24
@@ -7,6 +7,8 @@ package model
|
||||
import (
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"github.com/miniflux/miniflux/timezone"
|
||||
)
|
||||
|
||||
// User represents a user in the system.
|
||||
@@ -72,32 +74,19 @@ func (u User) ValidatePassword() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Merge update the current user with another user.
|
||||
func (u *User) Merge(override *User) {
|
||||
if override.Username != "" && u.Username != override.Username {
|
||||
u.Username = override.Username
|
||||
}
|
||||
|
||||
if override.Password != "" && u.Password != override.Password {
|
||||
u.Password = override.Password
|
||||
}
|
||||
|
||||
if u.IsAdmin != override.IsAdmin {
|
||||
u.IsAdmin = override.IsAdmin
|
||||
}
|
||||
|
||||
if override.Theme != "" && u.Theme != override.Theme {
|
||||
u.Theme = override.Theme
|
||||
}
|
||||
|
||||
if override.Language != "" && u.Language != override.Language {
|
||||
u.Language = override.Language
|
||||
}
|
||||
|
||||
if override.Timezone != "" && u.Timezone != override.Timezone {
|
||||
u.Timezone = override.Timezone
|
||||
// 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
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -59,35 +59,3 @@ func TestValidateUserModification(t *testing.T) {
|
||||
t.Error(`An invalid password should generate an error`)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMergeUsername(t *testing.T) {
|
||||
user1 := &User{ID: 42, Username: "user1", Password: "secret", Theme: "default"}
|
||||
user2 := &User{ID: 42, Username: "user2"}
|
||||
user1.Merge(user2)
|
||||
|
||||
if user1.Username != "user2" {
|
||||
t.Fatal(`The username should be merged into user1`)
|
||||
}
|
||||
|
||||
if user1.Theme != "default" {
|
||||
t.Fatal(`The theme should not be merged into user1`)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMergeIsAdmin(t *testing.T) {
|
||||
user1 := &User{ID: 42, Username: "user1", Password: "secret", Theme: "default"}
|
||||
user2 := &User{ID: 42, IsAdmin: true}
|
||||
user1.Merge(user2)
|
||||
|
||||
if !user1.IsAdmin {
|
||||
t.Fatal(`The is_admin flag should be merged into user1`)
|
||||
}
|
||||
|
||||
user1 = &User{ID: 42, Username: "user1", Password: "secret", Theme: "default"}
|
||||
user2 = &User{ID: 42}
|
||||
user1.Merge(user2)
|
||||
|
||||
if user1.IsAdmin {
|
||||
t.Fatal(`The is_admin flag should not be merged into user1`)
|
||||
}
|
||||
}
|
||||
|
||||
+15
-3
@@ -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})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 “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 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"><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 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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+55
-23
@@ -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,30 +21,36 @@ 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 (Status Code = %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"
|
||||
errResourceNotFound = "Resource not found (404), this feed doesn't exists anymore, check the feed URL"
|
||||
)
|
||||
|
||||
// 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.
|
||||
func (h *Handler) CreateFeed(userID, categoryID int64, url string, crawler bool) (*model.Feed, error) {
|
||||
func (h *Handler) CreateFeed(userID, categoryID int64, url string, crawler bool, username, password string) (*model.Feed, error) {
|
||||
defer timer.ExecutionTime(time.Now(), fmt.Sprintf("[Handler:CreateFeed] feedUrl=%s", url))
|
||||
|
||||
if !h.store.CategoryExists(userID, categoryID) {
|
||||
return nil, errors.NewLocalizedError(errCategoryNotFound)
|
||||
}
|
||||
|
||||
client := http.NewClient(url)
|
||||
response, err := client.Get()
|
||||
clt := client.New(url)
|
||||
clt.WithCredentials(username, password)
|
||||
response, err := clt.Get()
|
||||
if err != nil {
|
||||
if _, ok := err.(*errors.LocalizedError); ok {
|
||||
return nil, err
|
||||
}
|
||||
return nil, errors.NewLocalizedError(errRequestFailed, err)
|
||||
}
|
||||
|
||||
@@ -65,9 +72,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)
|
||||
@@ -80,6 +87,8 @@ func (h *Handler) CreateFeed(userID, categoryID int64, url string, crawler bool)
|
||||
subscription.FeedURL = response.EffectiveURL
|
||||
subscription.UserID = userID
|
||||
subscription.Crawler = crawler
|
||||
subscription.Username = username
|
||||
subscription.Password = password
|
||||
|
||||
if subscription.SiteURL == "" {
|
||||
subscription.SiteURL = subscription.FeedURL
|
||||
@@ -107,6 +116,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,22 +133,38 @@ 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.WithCredentials(originalFeed.Username, originalFeed.Password)
|
||||
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
|
||||
}
|
||||
|
||||
originalFeed.CheckedAt = time.Now()
|
||||
|
||||
if response.IsNotFound() {
|
||||
err := errors.NewLocalizedError(errResourceNotFound)
|
||||
originalFeed.ParsingErrorCount++
|
||||
originalFeed.ParsingErrorMsg = err.Localize(currentLanguage)
|
||||
h.store.UpdateFeed(originalFeed)
|
||||
return err
|
||||
}
|
||||
|
||||
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 +176,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 +186,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 +232,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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ import (
|
||||
"strings"
|
||||
|
||||
"github.com/miniflux/miniflux/crypto"
|
||||
"github.com/miniflux/miniflux/http"
|
||||
"github.com/miniflux/miniflux/http/client"
|
||||
"github.com/miniflux/miniflux/logger"
|
||||
"github.com/miniflux/miniflux/model"
|
||||
"github.com/miniflux/miniflux/url"
|
||||
@@ -23,8 +23,8 @@ import (
|
||||
// FindIcon try to find the website's icon.
|
||||
func FindIcon(websiteURL string) (*model.Icon, error) {
|
||||
rootURL := url.RootURL(websiteURL)
|
||||
client := http.NewClient(rootURL)
|
||||
response, err := client.Get()
|
||||
clt := client.New(rootURL)
|
||||
response, err := clt.Get()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("unable to download website index page: %v", err)
|
||||
}
|
||||
@@ -87,8 +87,8 @@ func parseDocument(websiteURL string, data io.Reader) (string, error) {
|
||||
}
|
||||
|
||||
func downloadIcon(iconURL string) (*model.Icon, error) {
|
||||
client := http.NewClient(iconURL)
|
||||
response, err := client.Get()
|
||||
clt := client.New(iconURL)
|
||||
response, err := clt.Get()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("unable to download iconURL: %v", err)
|
||||
}
|
||||
|
||||
+1
-1
@@ -47,7 +47,7 @@ type jsonAttachment struct {
|
||||
URL string `json:"url"`
|
||||
MimeType string `json:"mime_type"`
|
||||
Title string `json:"title"`
|
||||
Size int `json:"size_in_bytes"`
|
||||
Size int64 `json:"size_in_bytes"`
|
||||
Duration int `json:"duration_in_seconds"`
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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 == "" {
|
||||
|
||||
@@ -22,9 +22,19 @@ func addImageTitle(entryURL, entryContent string) string {
|
||||
return entryContent
|
||||
}
|
||||
|
||||
imgTag := doc.Find("img").First()
|
||||
if titleAttr, found := imgTag.Attr("title"); found {
|
||||
return entryContent + `<blockquote cite="` + entryURL + `">` + titleAttr + "</blockquote>"
|
||||
matches := doc.Find("img[src][title]")
|
||||
|
||||
if matches.Length() > 0 {
|
||||
matches.Each(func(i int, img *goquery.Selection) {
|
||||
altAttr := img.AttrOr("alt", "")
|
||||
srcAttr, _ := img.Attr("src")
|
||||
titleAttr, _ := img.Attr("title")
|
||||
|
||||
img.ReplaceWithHtml(`<figure><img src="` + srcAttr + `" alt="` + altAttr + `"/><figcaption><p>` + titleAttr + `</p></figcaption></figure>`)
|
||||
})
|
||||
|
||||
output, _ := doc.Find("body").First().Html()
|
||||
return output
|
||||
}
|
||||
|
||||
return entryContent
|
||||
|
||||
@@ -35,7 +35,15 @@ func TestRewriteWithInexistingCustomRule(t *testing.T) {
|
||||
func TestRewriteWithXkcdLink(t *testing.T) {
|
||||
description := `<img src="https://imgs.xkcd.com/comics/thermostat.png" title="Your problem is so terrible, I worry that, if I help you, I risk drawing the attention of whatever god of technology inflicted it on you." alt="Your problem is so terrible, I worry that, if I help you, I risk drawing the attention of whatever god of technology inflicted it on you." />`
|
||||
output := Rewriter("https://xkcd.com/1912/", description, ``)
|
||||
expected := description + `<blockquote cite="https://xkcd.com/1912/">Your problem is so terrible, I worry that, if I help you, I risk drawing the attention of whatever god of technology inflicted it on you.</blockquote>`
|
||||
expected := `<figure><img src="https://imgs.xkcd.com/comics/thermostat.png" alt="Your problem is so terrible, I worry that, if I help you, I risk drawing the attention of whatever god of technology inflicted it on you."/><figcaption><p>Your problem is so terrible, I worry that, if I help you, I risk drawing the attention of whatever god of technology inflicted it on you.</p></figcaption></figure>`
|
||||
if expected != output {
|
||||
t.Errorf(`Not expected output: got "%s" instead of "%s"`, output, expected)
|
||||
}
|
||||
}
|
||||
func TestRewriteWithXkcdLinkAndImageNoTitle(t *testing.T) {
|
||||
description := `<img src="https://imgs.xkcd.com/comics/thermostat.png" alt="Your problem is so terrible, I worry that, if I help you, I risk drawing the attention of whatever god of technology inflicted it on you." />`
|
||||
output := Rewriter("https://xkcd.com/1912/", description, ``)
|
||||
expected := description
|
||||
if expected != output {
|
||||
t.Errorf(`Not expected output: got "%s" instead of "%s"`, output, expected)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user