Compare commits

...

41 Commits

Author SHA1 Message Date
Frédéric Guillot da951164d5 docs(changelog): update release notes for version 2.2.3 2024-11-10 15:53:03 -08:00
dependabot[bot] a0988f6c16 build(deps): bump golang.org/x/net from 0.30.0 to 0.31.0
Bumps [golang.org/x/net](https://github.com/golang/net) from 0.30.0 to 0.31.0.
- [Commits](https://github.com/golang/net/compare/v0.30.0...v0.31.0)

---
updated-dependencies:
- dependency-name: golang.org/x/net
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-11-09 21:38:00 -08:00
dependabot[bot] fddc861e41 build(deps): bump golang.org/x/crypto from 0.28.0 to 0.29.0
Bumps [golang.org/x/crypto](https://github.com/golang/crypto) from 0.28.0 to 0.29.0.
- [Commits](https://github.com/golang/crypto/compare/v0.28.0...v0.29.0)

---
updated-dependencies:
- dependency-name: golang.org/x/crypto
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-11-08 20:56:26 -08:00
dependabot[bot] 9288001f09 build(deps): bump golang.org/x/text from 0.19.0 to 0.20.0
Bumps [golang.org/x/text](https://github.com/golang/text) from 0.19.0 to 0.20.0.
- [Release notes](https://github.com/golang/text/releases)
- [Commits](https://github.com/golang/text/compare/v0.19.0...v0.20.0)

---
updated-dependencies:
- dependency-name: golang.org/x/text
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-11-08 20:55:59 -08:00
dependabot[bot] da261a5fc7 build(deps): bump golang.org/x/term from 0.25.0 to 0.26.0
Bumps [golang.org/x/term](https://github.com/golang/term) from 0.25.0 to 0.26.0.
- [Commits](https://github.com/golang/term/compare/v0.25.0...v0.26.0)

---
updated-dependencies:
- dependency-name: golang.org/x/term
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-11-08 19:47:31 -08:00
dependabot[bot] edeb5f0366 build(deps): bump golang.org/x/oauth2 from 0.23.0 to 0.24.0
Bumps [golang.org/x/oauth2](https://github.com/golang/oauth2) from 0.23.0 to 0.24.0.
- [Commits](https://github.com/golang/oauth2/compare/v0.23.0...v0.24.0)

---
updated-dependencies:
- dependency-name: golang.org/x/oauth2
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-11-07 21:50:20 -08:00
Frédéric Guillot 65ff328804 fix: show only one player when there are several audio/video enclosures 2024-11-03 21:28:26 -08:00
John Tiesselune 7759ea1b43 feat(integration-shiori): updated shiori API to new endpoint for login/bookmark 2024-11-02 20:19:14 -07:00
Frédéric Guillot 7c5d6cf35f feat(mediaproxy): pass original filename in Content-Disposition header
When you download/save proxified media, the original filename is lost. That
information could be retained by passing a header `Content-Disposition: inline;
filename="ORIGNAL_FILENAME.EXT"` when serving the media file. The requested URL
would still be obfuscated, but if the client downloads the file it'll use that
original filename.
2024-10-30 20:44:19 -07:00
Frédéric Guillot 39cc1887ea fix: unable to change password due to a typo in SQL parameter 2024-10-30 19:58:37 -07:00
AiraNadih b0a3b4d5d9 style(mediaProxy): format with gofmt to pass linter checks 2024-10-30 19:50:12 -07:00
AiraNadih 469f23968e feat(mediaProxy): implement referer spoofing for restricted media resources 2024-10-30 19:50:12 -07:00
Frédéric Guillot 051bdecabd docs(changelog): update release notes for version 2.2.2 2024-10-29 20:08:17 -07:00
dependabot[bot] fe10d4302d build(deps): bump github.com/tdewolff/minify/v2 from 2.20.37 to 2.21.1
Bumps [github.com/tdewolff/minify/v2](https://github.com/tdewolff/minify) from 2.20.37 to 2.21.1.
- [Release notes](https://github.com/tdewolff/minify/releases)
- [Commits](https://github.com/tdewolff/minify/compare/v2.20.37...v2.21.1)

---
updated-dependencies:
- dependency-name: github.com/tdewolff/minify/v2
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-10-28 17:13:38 -07:00
Matthaiks b9dfd5bf6d feat(locale): update Polish translations 2024-10-28 17:12:48 -07:00
Frédéric Guillot 51030ef1a8 feat(webauthn): show help message regarding username and non-discoverable credentials
The username is required for non-resident keys, but it's not necessary for discoverable credentials like Passkeys.
2024-10-26 21:49:23 -07:00
Frédéric Guillot 2bcc4b8399 fix(webauthn): add backup eligibility flag workaround to avoid a 401
Since go-webauthn v0.11.0, the backup eligibility flag is strictly validated, but Miniflux does not store this flag.

This workaround to set the flag based on the parsed response, and avoid "BackupEligible flag inconsistency detected during login validation" error.

See https://github.com/go-webauthn/webauthn/pull/240
2024-10-26 18:36:38 -07:00
Matthaiks ea4d0a4f72 feat(locale): update Polish translation 2024-10-22 20:16:58 -07:00
Julien Voisin 5c5ad19c43 feat: replace xurls third-party module with an ad-hoc regexp 2024-10-21 20:48:12 -07:00
Frédéric Guillot 191f3a7ad7 feat(rss): calculate hash based on item title/content for feeds without GUID and link 2024-10-18 18:37:38 -07:00
Shaolong Chen 366928b35d feat(integration): add cubox integration
Signed-off-by: Shaolong Chen <shaolong.chen@outlook.it>
2024-10-18 18:06:09 -07:00
Frédéric Guillot 3b654fefa7 feat(api): rename integrations status endpoint 2024-10-17 21:29:44 -07:00
AiraNadih 0adbcc3a04 feat(api): add endpoint for user integration status 2024-10-17 20:59:05 -07:00
Frédéric Guillot 7fdb450446 Update issue templates 2024-10-17 20:27:10 -07:00
dependabot[bot] 86285f5a05 build(deps): bump github.com/yuin/goldmark from 1.7.7 to 1.7.8
Bumps [github.com/yuin/goldmark](https://github.com/yuin/goldmark) from 1.7.7 to 1.7.8.
- [Release notes](https://github.com/yuin/goldmark/releases)
- [Commits](https://github.com/yuin/goldmark/compare/v1.7.7...v1.7.8)

---
updated-dependencies:
- dependency-name: github.com/yuin/goldmark
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-10-16 21:09:35 -07:00
dependabot[bot] 8a4b4e459e build(deps): bump github.com/prometheus/client_golang
Bumps [github.com/prometheus/client_golang](https://github.com/prometheus/client_golang) from 1.20.4 to 1.20.5.
- [Release notes](https://github.com/prometheus/client_golang/releases)
- [Changelog](https://github.com/prometheus/client_golang/blob/main/CHANGELOG.md)
- [Commits](https://github.com/prometheus/client_golang/compare/v1.20.4...v1.20.5)

---
updated-dependencies:
- dependency-name: github.com/prometheus/client_golang
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-10-15 16:42:00 -07:00
dependabot[bot] 879446bdb2 build(deps): bump github.com/yuin/goldmark from 1.7.6 to 1.7.7
Bumps [github.com/yuin/goldmark](https://github.com/yuin/goldmark) from 1.7.6 to 1.7.7.
- [Release notes](https://github.com/yuin/goldmark/releases)
- [Commits](https://github.com/yuin/goldmark/compare/v1.7.6...v1.7.7)

---
updated-dependencies:
- dependency-name: github.com/yuin/goldmark
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-10-15 16:36:56 -07:00
dependabot[bot] ef633dc427 build(deps): bump github.com/yuin/goldmark from 1.7.4 to 1.7.6
Bumps [github.com/yuin/goldmark](https://github.com/yuin/goldmark) from 1.7.4 to 1.7.6.
- [Release notes](https://github.com/yuin/goldmark/releases)
- [Commits](https://github.com/yuin/goldmark/compare/v1.7.4...v1.7.6)

---
updated-dependencies:
- dependency-name: github.com/yuin/goldmark
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-10-14 18:18:38 -07:00
July 86c0cc61ba feat: set entry URL to rewritten URL if a rewrite rule is defined 2024-10-13 21:21:28 -07:00
dependabot[bot] ffe3ed4b9a build(deps): bump github.com/andybalholm/brotli from 1.1.0 to 1.1.1
Bumps [github.com/andybalholm/brotli](https://github.com/andybalholm/brotli) from 1.1.0 to 1.1.1.
- [Commits](https://github.com/andybalholm/brotli/compare/v1.1.0...v1.1.1)

---
updated-dependencies:
- dependency-name: github.com/andybalholm/brotli
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-10-11 20:13:37 -07:00
Frédéric Guillot 5c4df786de fix: avoid panic in IsRateLimited() function 2024-10-06 21:34:23 -07:00
Qeynos ee8c6621e9 feat(locale): update zh_CN translations 2024-10-06 20:12:00 -07:00
Matthaiks f748513df6 feat(locale): update Polish translations 2024-10-06 12:56:49 -07:00
Frédéric Guillot e1050e21b5 feat: take Retry-After header into consideration for rate limited feeds 2024-10-05 22:26:05 -07:00
Frédéric Guillot e555e442fb feat: add new settings option to allow external fonts 2024-10-05 21:21:12 -07:00
Frédéric Guillot 600dea6ce5 feat(client): add custom_js field to Go API client 2024-10-05 18:37:34 -07:00
milhnl e07203ad46 feat: add custom user JavaScript 2024-10-05 16:54:11 -07:00
Frédéric Guillot f16735fd6d feat: update feed icon during force refresh 2024-10-04 20:51:40 -07:00
Scott Leggett 562a7b79a5 fix: update Last-Modified if it changes in a 304 response
When a server returns a 304 response with a strong validator, any other
stored fields must be updated if they are also present in the response.

This behaviour is described in RFC9111, sections 3.2 and 4.3.4.
2024-10-04 17:47:48 -07:00
Scott Leggett cb610230d9 chore: update test case comment
The updated comment reflects a better understanding of the RFCs.
2024-10-04 17:47:48 -07:00
dependabot[bot] 628b2b388d build(deps): bump golang.org/x/net from 0.29.0 to 0.30.0
Bumps [golang.org/x/net](https://github.com/golang/net) from 0.29.0 to 0.30.0.
- [Commits](https://github.com/golang/net/compare/v0.29.0...v0.30.0)

---
updated-dependencies:
- dependency-name: golang.org/x/net
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-10-04 17:19:04 -07:00
69 changed files with 1536 additions and 641 deletions
+1 -1
View File
@@ -2,7 +2,7 @@
name: Feature request
about: Suggest an idea for this project
title: ''
labels: feature request
labels: wishlist
assignees: ''
---
+5 -1
View File
@@ -32,7 +32,11 @@ jobs:
- run: "go vet ./..."
- uses: golangci/golangci-lint-action@v6
with:
args: --timeout 10m --skip-dirs tests --disable errcheck --enable sqlclosecheck --enable misspell --enable gofmt --enable goimports --enable whitespace --enable gocritic
args: >
--timeout 10m
--exclude-dirs=tests
--disable errcheck
--enable sqlclosecheck,misspell,gofmt,goimports,whitespace,gocritic
- uses: dominikh/staticcheck-action@v1.3.1
with:
version: "2024.1.1"
+39
View File
@@ -1,3 +1,42 @@
Version 2.2.3 (November 10, 2024)
---------------------------------
* fix: unable to change password due to a typo in SQL parameter
* fix: show only one player when there are several audio/video enclosures
* feat(mediaproxy): pass original filename in `Content-Disposition` header
* feat(mediaproxy): implement referer spoofing for restricted media resources
* feat(integration): update Shiori integration to use new API endpoints for login/bookmark
* build(deps): bump `golang.org/x/text` from `0.19.0` to `0.20.0`
* build(deps): bump `golang.org/x/term` from `0.25.0` to `0.26.0`
* build(deps): bump `golang.org/x/oauth2` from `0.23.0` to `0.24.0`
* build(deps): bump `golang.org/x/net` from `0.30.0` to `0.31.0`
* build(deps): bump `golang.org/x/crypto` from `0.28.0` to `0.29.0`
Version 2.2.2 (October 29, 2024)
--------------------------------
* fix(webauthn): add backup eligibility flag workaround to avoid a 401 response
* fix: update `Last-Modified` if it changes in a 304 response
* feat(webauthn): show help message regarding username and non-discoverable credentials
* feat(rss): calculate hash based on item title/content for feeds without GUID and link
* feat(locale): update Chinese translations
* feat(locale): update Polish translations
* feat(integration): add Cubox integration
* feat(client): add `custom_js` field to Go API client
* feat(api): add endpoint for user integration status
* feat: update feed icon during force refresh
* feat: take `Retry-After` header into consideration for rate limited feeds
* feat: set entry URL to rewritten URL if a rewrite rule is defined
* feat: replace `xurls` third-party module with an ad-hoc regexp
* feat: add new settings option to allow external fonts
* feat: add custom user JavaScript similar to custom CSS
* chore: update test case comment
* build(deps): bump `golang.org/x/net` from `0.29.0` to `0.30.0`
* build(deps): bump `github.com/yuin/goldmark` from `1.7.4` to `1.7.8`
* build(deps): bump `github.com/tdewolff/minify/v2` from `2.20.37` to `2.21.1`
* build(deps): bump `github.com/prometheus/client_golang`
* build(deps): bump `github.com/andybalholm/brotli` from `1.1.0` to `1.1.1`
Version 2.2.1 (September 28, 2024)
----------------------------------
+19
View File
@@ -185,6 +185,25 @@ func (c *Client) MarkAllAsRead(userID int64) error {
return err
}
// IntegrationsStatus fetches the integrations status for the logged user.
func (c *Client) IntegrationsStatus() (bool, error) {
body, err := c.request.Get("/v1/integrations/status")
if err != nil {
return false, err
}
defer body.Close()
var response struct {
HasIntegrations bool `json:"has_integrations"`
}
if err := json.NewDecoder(body).Decode(&response); err != nil {
return false, fmt.Errorf("miniflux: response error (%v)", err)
}
return response.HasIntegrations, nil
}
// Discover try to find subscriptions from a website.
func (c *Client) Discover(url string) (Subscriptions, error) {
body, err := c.request.Post("/v1/discover", map[string]string{"url": url})
+4
View File
@@ -27,6 +27,7 @@ type User struct {
EntryDirection string `json:"entry_sorting_direction"`
EntryOrder string `json:"entry_sorting_order"`
Stylesheet string `json:"stylesheet"`
CustomJS string `json:"custom_js"`
GoogleID string `json:"google_id"`
OpenIDConnectID string `json:"openid_connect_id"`
EntriesPerPage int `json:"entries_per_page"`
@@ -44,6 +45,7 @@ type User struct {
MediaPlaybackRate float64 `json:"media_playback_rate"`
BlockFilterEntryRules string `json:"block_filter_entry_rules"`
KeepFilterEntryRules string `json:"keep_filter_entry_rules"`
ExternalFontHosts string `json:"external_font_hosts"`
}
func (u User) String() string {
@@ -70,6 +72,7 @@ type UserModificationRequest struct {
EntryDirection *string `json:"entry_sorting_direction"`
EntryOrder *string `json:"entry_sorting_order"`
Stylesheet *string `json:"stylesheet"`
CustomJS *string `json:"custom_js"`
GoogleID *string `json:"google_id"`
OpenIDConnectID *string `json:"openid_connect_id"`
EntriesPerPage *int `json:"entries_per_page"`
@@ -86,6 +89,7 @@ type UserModificationRequest struct {
MediaPlaybackRate *float64 `json:"media_playback_rate"`
BlockFilterEntryRules *string `json:"block_filter_entry_rules"`
KeepFilterEntryRules *string `json:"keep_filter_entry_rules"`
ExternalFontHosts *string `json:"external_font_hosts"`
}
// Users represents a list of users.
+11 -12
View File
@@ -5,20 +5,19 @@ module miniflux.app/v2
require (
github.com/PuerkitoBio/goquery v1.10.0
github.com/abadojack/whatlanggo v1.0.1
github.com/andybalholm/brotli v1.1.0
github.com/andybalholm/brotli v1.1.1
github.com/coreos/go-oidc/v3 v3.11.0
github.com/go-webauthn/webauthn v0.11.2
github.com/gorilla/mux v1.8.1
github.com/lib/pq v1.10.9
github.com/prometheus/client_golang v1.20.4
github.com/tdewolff/minify/v2 v2.20.37
github.com/yuin/goldmark v1.7.4
golang.org/x/crypto v0.27.0
golang.org/x/net v0.29.0
golang.org/x/oauth2 v0.23.0
golang.org/x/term v0.24.0
golang.org/x/text v0.18.0
mvdan.cc/xurls/v2 v2.5.0
github.com/prometheus/client_golang v1.20.5
github.com/tdewolff/minify/v2 v2.21.1
github.com/yuin/goldmark v1.7.8
golang.org/x/crypto v0.29.0
golang.org/x/net v0.31.0
golang.org/x/oauth2 v0.24.0
golang.org/x/term v0.26.0
golang.org/x/text v0.20.0
)
require (
@@ -40,9 +39,9 @@ require (
github.com/prometheus/client_model v0.6.1 // indirect
github.com/prometheus/common v0.55.0 // indirect
github.com/prometheus/procfs v0.15.1 // indirect
github.com/tdewolff/parse/v2 v2.7.15 // indirect
github.com/tdewolff/parse/v2 v2.7.18 // indirect
github.com/x448/float16 v0.8.4 // indirect
golang.org/x/sys v0.25.0 // indirect
golang.org/x/sys v0.27.0 // indirect
google.golang.org/protobuf v1.34.2 // indirect
)
+24 -24
View File
@@ -2,8 +2,8 @@ github.com/PuerkitoBio/goquery v1.10.0 h1:6fiXdLuUvYs2OJSvNRqlNPoBm6YABE226xrbav
github.com/PuerkitoBio/goquery v1.10.0/go.mod h1:TjZZl68Q3eGHNBA8CWaxAN7rOU1EbDz3CWuolcO5Yu4=
github.com/abadojack/whatlanggo v1.0.1 h1:19N6YogDnf71CTHm3Mp2qhYfkRdyvbgwWdd2EPxJRG4=
github.com/abadojack/whatlanggo v1.0.1/go.mod h1:66WiQbSbJBIlOZMsvbKe5m6pzQovxCH9B/K8tQB2uoc=
github.com/andybalholm/brotli v1.1.0 h1:eLKJA0d02Lf0mVpIDgYnqXcUn0GqVmEFny3VuID1U3M=
github.com/andybalholm/brotli v1.1.0/go.mod h1:sms7XGricyQI9K10gOSf56VKKWS4oLer58Q+mhRPtnY=
github.com/andybalholm/brotli v1.1.1 h1:PR2pgnyFznKEugtsUo0xLdDop5SKXd5Qf5ysW+7XdTA=
github.com/andybalholm/brotli v1.1.1/go.mod h1:05ib4cKhjx3OQYUY22hTVd34Bc8upXjOLL2rKwwZBoA=
github.com/andybalholm/cascadia v1.3.2 h1:3Xi6Dw5lHF15JtdcmAHD3i1+T8plmv7BQ/nsViSLyss=
github.com/andybalholm/cascadia v1.3.2/go.mod h1:7gtRlve5FxPPgIgX36uWBX58OdBsSS6lUvCFb+h7KvU=
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
@@ -44,8 +44,8 @@ github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/prometheus/client_golang v1.20.4 h1:Tgh3Yr67PaOv/uTqloMsCEdeuFTatm5zIq5+qNN23vI=
github.com/prometheus/client_golang v1.20.4/go.mod h1:PIEt8X02hGcP8JWbeHyeZ53Y/jReSnHgO035n//V5WE=
github.com/prometheus/client_golang v1.20.5 h1:cxppBPuYhUnsO6yo/aoRol4L7q7UFfdm+bR9r+8l63Y=
github.com/prometheus/client_golang v1.20.5/go.mod h1:PIEt8X02hGcP8JWbeHyeZ53Y/jReSnHgO035n//V5WE=
github.com/prometheus/client_model v0.6.1 h1:ZKSh/rekM+n3CeS952MLRAdFwIKqeY8b62p8ais2e9E=
github.com/prometheus/client_model v0.6.1/go.mod h1:OrxVMOVHjw3lKMa8+x6HeMGkHMQyHDk9E3jmP2AmGiY=
github.com/prometheus/common v0.55.0 h1:KEi6DK7lXW/m7Ig5i47x0vRzuBsHuvJdi5ee6Y3G1dc=
@@ -54,22 +54,24 @@ github.com/prometheus/procfs v0.15.1 h1:YagwOFzUgYfKKHX6Dr+sHT7km/hxC76UB0leargg
github.com/prometheus/procfs v0.15.1/go.mod h1:fB45yRUv8NstnjriLhBQLuOUt+WW4BsoGhij/e3PBqk=
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/tdewolff/minify/v2 v2.20.37 h1:Q97cx4STXCh1dlWDlNHZniE8BJ2EBL0+2b0n92BJQhw=
github.com/tdewolff/minify/v2 v2.20.37/go.mod h1:L1VYef/jwKw6Wwyk5A+T0mBjjn3mMPgmjjA688RNsxU=
github.com/tdewolff/parse/v2 v2.7.15 h1:hysDXtdGZIRF5UZXwpfn3ZWRbm+ru4l53/ajBRGpCTw=
github.com/tdewolff/parse/v2 v2.7.15/go.mod h1:3FbJWZp3XT9OWVN3Hmfp0p/a08v4h8J9W1aghka0soA=
github.com/tdewolff/minify/v2 v2.21.1 h1:AAf5iltw6+KlUvjRNPAPrANIXl3XEJNBBzuZom5iCAM=
github.com/tdewolff/minify/v2 v2.21.1/go.mod h1:PoqFH8ugcuTUvKqVM9vOqXw4msxvuhL/DTmV5ZXhSCI=
github.com/tdewolff/parse/v2 v2.7.18 h1:uSqjEMT2lwCj5oifBHDcWU2kN1pbLrRENgFWDJa57eI=
github.com/tdewolff/parse/v2 v2.7.18/go.mod h1:3FbJWZp3XT9OWVN3Hmfp0p/a08v4h8J9W1aghka0soA=
github.com/tdewolff/test v1.0.11-0.20231101010635-f1265d231d52/go.mod h1:6DAvZliBAAnD7rhVgwaM7DE5/d9NMOAJ09SqYqeK4QE=
github.com/tdewolff/test v1.0.11-0.20240106005702-7de5f7df4739 h1:IkjBCtQOOjIn03u/dMQK9g+Iw9ewps4mCl1nB8Sscbo=
github.com/tdewolff/test v1.0.11-0.20240106005702-7de5f7df4739/go.mod h1:XPuWBzvdUzhCuxWO1ojpXsyzsA5bFoS3tO/Q3kFuTG8=
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
github.com/yuin/goldmark v1.7.4 h1:BDXOHExt+A7gwPCJgPIIq7ENvceR7we7rOS9TNoLZeg=
github.com/yuin/goldmark v1.7.4/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E=
github.com/yuin/goldmark v1.7.8 h1:iERMLn0/QJeHFhxSt3p6PeN9mGnvIKSpG9YYorDMnic=
github.com/yuin/goldmark v1.7.8/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.27.0 h1:GXm2NjJrPaiv/h1tb2UH8QfgC/hOf/+z0p6PT8o1w7A=
golang.org/x/crypto v0.27.0/go.mod h1:1Xngt8kV6Dvbssa53Ziq6Eqn0HqbZi5Z6R0ZpwQzt70=
golang.org/x/crypto v0.29.0 h1:L5SG1JTTXupVV3n6sUqMTeWbjAyfPwoda2DLX8J8FrQ=
golang.org/x/crypto v0.29.0/go.mod h1:+F4F4N5hv6v38hfeYwTdx20oUvLLc+QfrE9Ax9HtgRg=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
@@ -77,10 +79,10 @@ golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.9.0/go.mod h1:d48xBJpPfHeWQsugry2m+kC02ZBRGRgulfHnEXEuWns=
golang.org/x/net v0.29.0 h1:5ORfpBpCs4HzDYoodCDBbwHzdR5UrLBZ3sOnUJmFoHo=
golang.org/x/net v0.29.0/go.mod h1:gLkgy8jTGERgjzMic6DS9+SP0ajcu6Xu3Orq/SpETg0=
golang.org/x/oauth2 v0.23.0 h1:PbgcYx2W7i4LvjJWEbf0ngHV6qJYr86PkAV3bXdLEbs=
golang.org/x/oauth2 v0.23.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI=
golang.org/x/net v0.31.0 h1:68CPQngjLL0r2AlUKiSxtQFKvzRVbnzLwMUn5SzcLHo=
golang.org/x/net v0.31.0/go.mod h1:P4fl1q7dY2hnZFxEk4pPSkDHF+QqjitcnDjUQyMM+pM=
golang.org/x/oauth2 v0.24.0 h1:KTBBxWqUa0ykRPLtV69rRto9TLXcqYkeswu48x/gvNE=
golang.org/x/oauth2 v0.24.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
@@ -91,21 +93,21 @@ golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBc
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.7.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.25.0 h1:r+8e+loiHxRqhXVl6ML1nO3l1+oFoWbnlu2Ehimmi34=
golang.org/x/sys v0.25.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.27.0 h1:wBqf8DvsY9Y/2P8gAfPDEYNuS30J4lPHJxXSb/nJZ+s=
golang.org/x/sys v0.27.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
golang.org/x/term v0.7.0/go.mod h1:P32HKFT3hSsZrRxla30E9HqToFYAQPCMs/zFMBUFqPY=
golang.org/x/term v0.24.0 h1:Mh5cbb+Zk2hqqXNO7S1iTjEphVL+jb8ZWaqh/g+JWkM=
golang.org/x/term v0.24.0/go.mod h1:lOBK/LVxemqiMij05LGJ0tzNr8xlmwBRJ81PX6wVLH8=
golang.org/x/term v0.26.0 h1:WEQa6V3Gja/BhNxg540hBip/kkaYtRg3cxg4oXSw4AU=
golang.org/x/term v0.26.0/go.mod h1:Si5m1o57C5nBNQo5z1iq+XDijt21BDBDp2bK0QI8e3E=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
golang.org/x/text v0.18.0 h1:XvMDiNzPAl0jr17s6W9lcaIhGUfUORdGCNsuLmPG224=
golang.org/x/text v0.18.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY=
golang.org/x/text v0.20.0 h1:gK/Kv2otX8gz+wn7Rmb3vT96ZwuoxnQlY+HlJVj7Qug=
golang.org/x/text v0.20.0/go.mod h1:D4IsuqiFMhST5bX19pQ9ikHC2GsaKyk/oF+pn3ducp4=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
@@ -115,5 +117,3 @@ google.golang.org/protobuf v1.34.2 h1:6xV6lTsCfpGD21XK49h7MhtcApnLqkfYgPcdHftf6h
google.golang.org/protobuf v1.34.2/go.mod h1:qYOHts0dSfpeUzUFpOMr/WGzszTmLH+DiWniOlNbLDw=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
mvdan.cc/xurls/v2 v2.5.0 h1:lyBNOm8Wo71UknhUs4QTFUNNMyxy2JEIaKKo0RWOh+8=
mvdan.cc/xurls/v2 v2.5.0/go.mod h1:yQgaGQ1rFtJUzkmKiHYSSfuQxqfYmd//X6PxvholpeE=
+1
View File
@@ -74,6 +74,7 @@ func Serve(router *mux.Router, store *storage.Storage, pool *worker.Pool) {
sr.HandleFunc("/icons/{iconID}", handler.getIconByIconID).Methods(http.MethodGet)
sr.HandleFunc("/enclosures/{enclosureID}", handler.getEnclosureByID).Methods(http.MethodGet)
sr.HandleFunc("/enclosures/{enclosureID}", handler.updateEnclosureByID).Methods(http.MethodPut)
sr.HandleFunc("/integrations/status", handler.getIntegrationsStatus).Methods(http.MethodGet)
sr.HandleFunc("/version", handler.versionHandler).Methods(http.MethodGet)
}
+108
View File
@@ -592,6 +592,88 @@ func TestUpdateUserEndpointByChangingDefaultTheme(t *testing.T) {
}
}
func TestUpdateUserEndpointByChangingExternalFonts(t *testing.T) {
testConfig := newIntegrationTestConfig()
if !testConfig.isConfigured() {
t.Skip(skipIntegrationTestsMessage)
}
adminClient := miniflux.NewClient(testConfig.testBaseURL, testConfig.testAdminUsername, testConfig.testAdminPassword)
regularTestUser, err := adminClient.CreateUser(testConfig.genRandomUsername(), testConfig.testRegularPassword, false)
if err != nil {
t.Fatal(err)
}
defer adminClient.DeleteUser(regularTestUser.ID)
regularUserClient := miniflux.NewClient(testConfig.testBaseURL, regularTestUser.Username, testConfig.testRegularPassword)
userUpdateRequest := &miniflux.UserModificationRequest{
ExternalFontHosts: miniflux.SetOptionalField(" fonts.example.org "),
}
updatedUser, err := regularUserClient.UpdateUser(regularTestUser.ID, userUpdateRequest)
if err != nil {
t.Fatal(err)
}
if updatedUser.ExternalFontHosts != "fonts.example.org" {
t.Fatalf(`Invalid external font hosts, got "%v"`, updatedUser.ExternalFontHosts)
}
}
func TestUpdateUserEndpointByChangingExternalFontsWithInvalidValue(t *testing.T) {
testConfig := newIntegrationTestConfig()
if !testConfig.isConfigured() {
t.Skip(skipIntegrationTestsMessage)
}
adminClient := miniflux.NewClient(testConfig.testBaseURL, testConfig.testAdminUsername, testConfig.testAdminPassword)
regularTestUser, err := adminClient.CreateUser(testConfig.genRandomUsername(), testConfig.testRegularPassword, false)
if err != nil {
t.Fatal(err)
}
defer adminClient.DeleteUser(regularTestUser.ID)
regularUserClient := miniflux.NewClient(testConfig.testBaseURL, regularTestUser.Username, testConfig.testRegularPassword)
userUpdateRequest := &miniflux.UserModificationRequest{
ExternalFontHosts: miniflux.SetOptionalField("'self' *"),
}
if _, err := regularUserClient.UpdateUser(regularTestUser.ID, userUpdateRequest); err == nil {
t.Fatal(`Updating the user with an invalid external font host should raise an error`)
}
}
func TestUpdateUserEndpointByChangingCustomJS(t *testing.T) {
testConfig := newIntegrationTestConfig()
if !testConfig.isConfigured() {
t.Skip(skipIntegrationTestsMessage)
}
adminClient := miniflux.NewClient(testConfig.testBaseURL, testConfig.testAdminUsername, testConfig.testAdminPassword)
regularTestUser, err := adminClient.CreateUser(testConfig.genRandomUsername(), testConfig.testRegularPassword, false)
if err != nil {
t.Fatal(err)
}
defer adminClient.DeleteUser(regularTestUser.ID)
regularUserClient := miniflux.NewClient(testConfig.testBaseURL, regularTestUser.Username, testConfig.testRegularPassword)
userUpdateRequest := &miniflux.UserModificationRequest{
CustomJS: miniflux.SetOptionalField("alert('Hello, World!');"),
}
updatedUser, err := regularUserClient.UpdateUser(regularTestUser.ID, userUpdateRequest)
if err != nil {
t.Fatal(err)
}
if updatedUser.CustomJS != "alert('Hello, World!');" {
t.Fatalf(`Invalid custom JS, got %q`, updatedUser.CustomJS)
}
}
func TestUpdateUserEndpointByChangingDefaultThemeToInvalidValue(t *testing.T) {
testConfig := newIntegrationTestConfig()
if !testConfig.isConfigured() {
@@ -2401,6 +2483,32 @@ func TestSaveEntryEndpoint(t *testing.T) {
}
}
func TestFetchIntegrationsStatusEndpoint(t *testing.T) {
testConfig := newIntegrationTestConfig()
if !testConfig.isConfigured() {
t.Skip(skipIntegrationTestsMessage)
}
adminClient := miniflux.NewClient(testConfig.testBaseURL, testConfig.testAdminUsername, testConfig.testAdminPassword)
regularTestUser, err := adminClient.CreateUser(testConfig.genRandomUsername(), testConfig.testRegularPassword, false)
if err != nil {
t.Fatal(err)
}
defer adminClient.DeleteUser(regularTestUser.ID)
regularUserClient := miniflux.NewClient(testConfig.testBaseURL, regularTestUser.Username, testConfig.testRegularPassword)
hasIntegrations, err := regularUserClient.IntegrationsStatus()
if err != nil {
t.Fatalf("Failed to fetch integrations status: %v", err)
}
if hasIntegrations {
t.Fatalf("New user should not have integrations configured")
}
}
func TestFetchContentEndpoint(t *testing.T) {
testConfig := newIntegrationTestConfig()
if !testConfig.isConfigured() {
+1 -1
View File
@@ -13,7 +13,7 @@ import (
func (h *handler) getIconByFeedID(w http.ResponseWriter, r *http.Request) {
feedID := request.RouteInt64Param(r, "feedID")
if !h.store.HasIcon(feedID) {
if !h.store.HasFeedIcon(feedID) {
json.NotFound(w, r)
return
}
+19
View File
@@ -130,6 +130,25 @@ func (h *handler) markUserAsRead(w http.ResponseWriter, r *http.Request) {
json.NoContent(w, r)
}
func (h *handler) getIntegrationsStatus(w http.ResponseWriter, r *http.Request) {
userID := request.UserID(r)
if _, err := h.store.UserByID(userID); err != nil {
json.NotFound(w, r)
return
}
hasIntegrations := h.store.HasSaveEntry(userID)
response := struct {
HasIntegrations bool `json:"has_integrations"`
}{
HasIntegrations: hasIntegrations,
}
json.OK(w, r, response)
}
func (h *handler) users(w http.ResponseWriter, r *http.Request) {
if !request.IsAdminUser(r) {
json.Forbidden(w, r)
+18
View File
@@ -942,4 +942,22 @@ var migrations = []func(tx *sql.Tx) error{
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx) (err error) {
sql := `ALTER TABLE users ADD COLUMN custom_js text not null default '';`
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx) (err error) {
sql := `ALTER TABLE users ADD COLUMN external_font_hosts text not null default '';`
_, err = tx.Exec(sql)
return err
},
func(tx *sql.Tx) (err error) {
sql := `
ALTER TABLE integrations ADD COLUMN cubox_enabled bool default 'f';
ALTER TABLE integrations ADD COLUMN cubox_api_link text default '';
`
_, err = tx.Exec(sql)
return err
},
}
+70
View File
@@ -0,0 +1,70 @@
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
// Cubox API documentation: https://help.cubox.cc/save/api/
package cubox // import "miniflux.app/v2/internal/integration/cubox"
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"time"
"miniflux.app/v2/internal/version"
)
const defaultClientTimeout = 10 * time.Second
type Client struct {
apiLink string
}
func NewClient(apiLink string) *Client {
return &Client{apiLink: apiLink}
}
func (c *Client) SaveLink(entryURL string) error {
if c.apiLink == "" {
return errors.New("cubox: missing API link")
}
requestBody, err := json.Marshal(&card{
Type: "url",
Content: entryURL,
})
if err != nil {
return fmt.Errorf("cubox: unable to encode request body: %w", err)
}
ctx, cancel := context.WithTimeout(context.Background(), defaultClientTimeout)
defer cancel()
request, err := http.NewRequestWithContext(ctx, http.MethodPost, c.apiLink, bytes.NewReader(requestBody))
if err != nil {
return fmt.Errorf("cubox: unable to create request: %w", err)
}
request.Header.Set("Content-Type", "application/json")
request.Header.Set("User-Agent", "Miniflux/"+version.Version)
response, err := http.DefaultClient.Do(request)
if err != nil {
return fmt.Errorf("cubox: unable to send request: %w", err)
}
defer response.Body.Close()
if response.StatusCode != 200 {
return fmt.Errorf("cubox: unable to save link: status=%d", response.StatusCode)
}
return nil
}
type card struct {
Type string `json:"type"`
Content string `json:"content"`
}
+20
View File
@@ -9,6 +9,7 @@ import (
"miniflux.app/v2/internal/config"
"miniflux.app/v2/internal/integration/apprise"
"miniflux.app/v2/internal/integration/betula"
"miniflux.app/v2/internal/integration/cubox"
"miniflux.app/v2/internal/integration/espial"
"miniflux.app/v2/internal/integration/instapaper"
"miniflux.app/v2/internal/integration/linkace"
@@ -322,6 +323,25 @@ func SendEntry(entry *model.Entry, userIntegrations *model.Integration) {
}
}
if userIntegrations.CuboxEnabled {
slog.Debug("Sending entry to Cubox",
slog.Int64("user_id", userIntegrations.UserID),
slog.Int64("entry_id", entry.ID),
slog.String("entry_url", entry.URL),
)
client := cubox.NewClient(userIntegrations.CuboxAPILink)
if err := client.SaveLink(entry.URL); err != nil {
slog.Error("Unable to send entry to Cubox",
slog.Int64("user_id", userIntegrations.UserID),
slog.Int64("entry_id", entry.ID),
slog.String("entry_url", entry.URL),
slog.Any("error", err),
)
}
}
if userIntegrations.ShioriEnabled {
slog.Debug("Sending entry to Shiori",
slog.Int64("user_id", userIntegrations.UserID),
+26 -12
View File
@@ -31,7 +31,7 @@ func (c *Client) CreateBookmark(entryURL, entryTitle string) error {
return fmt.Errorf("shiori: missing base URL, username or password")
}
sessionID, err := c.authenticate()
token, err := c.authenticate()
if err != nil {
return fmt.Errorf("shiori: unable to authenticate: %v", err)
}
@@ -44,7 +44,11 @@ func (c *Client) CreateBookmark(entryURL, entryTitle string) error {
requestBody, err := json.Marshal(&addBookmarkRequest{
URL: entryURL,
Title: entryTitle,
Excerpt: "",
CreateArchive: true,
CreateEbook: false,
Public: 0,
Tags: make([]string, 0),
})
if err != nil {
@@ -58,7 +62,7 @@ func (c *Client) CreateBookmark(entryURL, entryTitle string) error {
request.Header.Set("Content-Type", "application/json")
request.Header.Set("User-Agent", "Miniflux/"+version.Version)
request.Header.Set("X-Session-Id", sessionID)
request.Header.Set("Authorization", "Bearer "+token)
httpClient := &http.Client{Timeout: defaultClientTimeout}
@@ -75,13 +79,13 @@ func (c *Client) CreateBookmark(entryURL, entryTitle string) error {
return nil
}
func (c *Client) authenticate() (sessionID string, err error) {
apiEndpoint, err := urllib.JoinBaseURLAndPath(c.baseURL, "/api/login")
func (c *Client) authenticate() (token string, err error) {
apiEndpoint, err := urllib.JoinBaseURLAndPath(c.baseURL, "/api/v1/auth/login")
if err != nil {
return "", fmt.Errorf("shiori: invalid API endpoint: %v", err)
}
requestBody, err := json.Marshal(&authRequest{Username: c.username, Password: c.password})
requestBody, err := json.Marshal(&authRequest{Username: c.username, Password: c.password, RememberMe: false})
if err != nil {
return "", fmt.Errorf("shiori: unable to encode request body: %v", err)
}
@@ -111,21 +115,31 @@ func (c *Client) authenticate() (sessionID string, err error) {
if err := json.NewDecoder(response.Body).Decode(&authResponse); err != nil {
return "", fmt.Errorf("shiori: unable to decode response: %v", err)
}
return authResponse.SessionID, nil
return authResponse.Message.Token, nil
}
type authRequest struct {
Username string `json:"username"`
Password string `json:"password"`
Username string `json:"username"`
Password string `json:"password"`
RememberMe bool `json:"remember_me"`
}
type authResponse struct {
OK bool `json:"ok"`
Message authResponseMessage `json:"message"`
}
type authResponseMessage struct {
SessionID string `json:"session"`
Token string `json:"token"`
}
type addBookmarkRequest struct {
URL string `json:"url"`
Title string `json:"title"`
CreateArchive bool `json:"createArchive"`
URL string `json:"url"`
Title string `json:"title"`
CreateArchive bool `json:"create_archive"`
CreateEbook bool `json:"create_ebook"`
Public int `json:"public"`
Excerpt string `json:"excerpt"`
Tags []string `json:"tags"`
}
+7
View File
@@ -220,6 +220,7 @@
"page.settings.webauthn.last_seen_on": "Zuletzt genutzt",
"page.settings.webauthn.register": "Hauptschlüssel registrieren",
"page.settings.webauthn.register.error": "Hauptschlüssel kann nicht registriert werden",
"page.login.webauthn_login.help": "Please enter your username if you're using a security key. This is not required if you are using a Passkey (discoverable credentials).",
"page.settings.webauthn.delete": [
"Entfernen Sie %d Hauptschlüssel",
"%d Hauptschlüssel entfernen"
@@ -355,6 +356,8 @@
"form.feed.label.ntfy_default_priority": "Ntfy default priority",
"form.feed.label.ntfy_low_priority": "Ntfy low priority",
"form.feed.label.ntfy_min_priority": "Ntfy min priority",
"form.integration.cubox_activate": "Save entries to Cubox",
"form.integration.cubox_api_link": "Cubox API link",
"form.feed.fieldset.general": "Allgemein",
"form.feed.fieldset.rules": "Regeln",
"form.feed.fieldset.network_settings": "Netzwerkeinstellungen",
@@ -391,6 +394,7 @@
"form.prefs.label.gesture_nav": "Geste zum Navigieren zwischen Einträgen",
"form.prefs.label.show_reading_time": "Geschätzte Lesezeit für Artikel anzeigen",
"form.prefs.label.custom_css": "Benutzerdefiniertes CSS",
"form.prefs.label.custom_js": "Benutzerdefiniertes JavaScript",
"form.prefs.label.entry_order": "Artikel-Sortierspalte",
"form.prefs.label.default_home_page": "Standard-Startseite",
"form.prefs.label.categories_sorting_order": "Kategorie-Sortierung",
@@ -402,6 +406,9 @@
"form.prefs.fieldset.authentication_settings": "Authentifizierungseinstellungen",
"form.prefs.fieldset.reader_settings": "Reader-Einstellungen",
"form.prefs.fieldset.global_feed_settings": "Global Feed Settings",
"form.prefs.label.external_font_hosts": "Externe Schriftarten-Hosts",
"form.prefs.help.external_font_hosts": "Space separated list of external font hosts to allow. For example: \"fonts.gstatic.com fonts.googleapis.com\".",
"error.settings_invalid_domain_list": "Invalid domain list. Please provide a space separated list of domains.",
"form.import.label.file": "OPML Datei",
"form.import.label.url": "URL",
"form.integration.betula_activate": "Save entries to Betula",
+7
View File
@@ -220,6 +220,7 @@
"page.settings.webauthn.last_seen_on": "Last Used",
"page.settings.webauthn.register": "Εγγραφή κωδικού πρόσβασης",
"page.settings.webauthn.register.error": "Δεν είναι δυνατή η εγγραφή του κωδικού πρόσβασης",
"page.login.webauthn_login.help": "Please enter your username if you're using a security key. This is not required if you are using a Passkey (discoverable credentials).",
"page.settings.webauthn.delete": [
"Αφαιρέστε %d κωδικό πρόσβασης",
"Καταργήστε %d κωδικούς πρόσβασης"
@@ -359,6 +360,8 @@
"form.feed.label.ntfy_default_priority": "Ntfy default priority",
"form.feed.label.ntfy_low_priority": "Ntfy low priority",
"form.feed.label.ntfy_min_priority": "Ntfy min priority",
"form.integration.cubox_activate": "Save entries to Cubox",
"form.integration.cubox_api_link": "Cubox API link",
"form.category.label.title": "Τίτλος",
"form.category.hide_globally": "Απόκρυψη καταχωρήσεων σε γενική λίστα μη αναγνωσμένων",
"form.user.label.username": "Χρήστης",
@@ -391,6 +394,7 @@
"form.prefs.label.gesture_nav": "Χειρονομία για πλοήγηση μεταξύ των καταχωρήσεων",
"form.prefs.label.show_reading_time": "Εμφάνιση εκτιμώμενου χρόνου ανάγνωσης για άρθρα",
"form.prefs.label.custom_css": "Προσαρμοσμένο CSS",
"form.prefs.label.custom_js": "Προσαρμοσμένο JavaScript",
"form.prefs.label.entry_order": "Στήλη ταξινόμησης εισόδου",
"form.prefs.label.default_home_page": "Προεπιλεγμένη αρχική σελίδα",
"form.prefs.label.categories_sorting_order": "Ταξινόμηση κατηγοριών",
@@ -402,6 +406,9 @@
"form.prefs.fieldset.authentication_settings": "Authentication Settings",
"form.prefs.fieldset.reader_settings": "Reader Settings",
"form.prefs.fieldset.global_feed_settings": "Global Feed Settings",
"form.prefs.label.external_font_hosts": "External font hosts",
"form.prefs.help.external_font_hosts": "Space separated list of external font hosts to allow. For example: \"fonts.gstatic.com fonts.googleapis.com\".",
"error.settings_invalid_domain_list": "Invalid domain list. Please provide a space separated list of domains.",
"form.import.label.file": "Αρχείο OPML",
"form.import.label.url": "URL",
"form.integration.betula_activate": "Save entries to Betula",
+7
View File
@@ -229,6 +229,7 @@
"page.login.oidc_signin": "Sign in with %s",
"page.login.webauthn_login": "Login with passkey",
"page.login.webauthn_login.error": "Unable to login with passkey",
"page.login.webauthn_login.help": "Please enter your username if you're using a security key. This is not required if you are using a Passkey (discoverable credentials).",
"page.integrations.title": "Integrations",
"page.integration.miniflux_api": "Miniflux API",
"page.integration.miniflux_api_endpoint": "API Endpoint",
@@ -391,6 +392,7 @@
"form.prefs.label.gesture_nav": "Gesture to navigate between entries",
"form.prefs.label.show_reading_time": "Show estimated reading time for entries",
"form.prefs.label.custom_css": "Custom CSS",
"form.prefs.label.custom_js": "Custom JavaScript",
"form.prefs.label.entry_order": "Entry sorting column",
"form.prefs.label.default_home_page": "Default home page",
"form.prefs.label.categories_sorting_order": "Categories sorting",
@@ -402,6 +404,9 @@
"form.prefs.fieldset.authentication_settings": "Authentication Settings",
"form.prefs.fieldset.reader_settings": "Reader Settings",
"form.prefs.fieldset.global_feed_settings": "Global Feed Settings",
"form.prefs.label.external_font_hosts": "External font hosts",
"form.prefs.help.external_font_hosts": "Space separated list of external font hosts to allow. For example: \"fonts.gstatic.com fonts.googleapis.com\".",
"error.settings_invalid_domain_list": "Invalid domain list. Please provide a space separated list of domains.",
"form.import.label.file": "OPML file",
"form.import.label.url": "URL",
"form.integration.betula_activate": "Save entries to Betula",
@@ -506,6 +511,8 @@
"form.integration.ntfy_username": "Ntfy Username (optional)",
"form.integration.ntfy_password": "Ntfy Password (optional)",
"form.integration.ntfy_icon_url": "Ntfy Icon URL (optional)",
"form.integration.cubox_activate": "Save entries to Cubox",
"form.integration.cubox_api_link": "Cubox API link",
"form.api_key.label.description": "API Key Label",
"form.submit.loading": "Loading…",
"form.submit.saving": "Saving…",
+7
View File
@@ -220,6 +220,7 @@
"page.settings.webauthn.last_seen_on": "Usado por última vez",
"page.settings.webauthn.register": "Registrar clave de acceso",
"page.settings.webauthn.register.error": "No se puede registrar la clave de acceso",
"page.login.webauthn_login.help": "Please enter your username if you're using a security key. This is not required if you are using a Passkey (discoverable credentials).",
"page.settings.webauthn.delete": [
"Eliminar %d clave de acceso",
"Eliminar %d claves de acceso"
@@ -355,6 +356,8 @@
"form.feed.label.ntfy_default_priority": "Prioridad predeterminada a Ntfy",
"form.feed.label.ntfy_low_priority": "Prioridad baja a Ntfy",
"form.feed.label.ntfy_min_priority": "Prioridad mínima a Ntfy",
"form.integration.cubox_activate": "Save entries to Cubox",
"form.integration.cubox_api_link": "Cubox API link",
"form.feed.fieldset.general": "General",
"form.feed.fieldset.rules": "Reglas",
"form.feed.fieldset.network_settings": "Ajustes de red",
@@ -391,6 +394,7 @@
"form.prefs.label.gesture_nav": "Gesto para navegar entre entradas",
"form.prefs.label.show_reading_time": "Mostrar el tiempo estimado de lectura de los artículos",
"form.prefs.label.custom_css": "CSS personalizado",
"form.prefs.label.custom_js": "JavaScript personalizado",
"form.prefs.label.entry_order": "Columna de clasificación de artículos",
"form.prefs.label.default_home_page": "Página de inicio por defecto",
"form.prefs.label.categories_sorting_order": "Clasificación por categorías",
@@ -402,6 +406,9 @@
"form.prefs.fieldset.authentication_settings": "Ajustes de la autentificación",
"form.prefs.fieldset.reader_settings": "Ajustes del lector",
"form.prefs.fieldset.global_feed_settings": "Ajustes globales del feed",
"form.prefs.label.external_font_hosts": "External font hosts",
"form.prefs.help.external_font_hosts": "Space separated list of external font hosts to allow. For example: \"fonts.gstatic.com fonts.googleapis.com\".",
"error.settings_invalid_domain_list": "Invalid domain list. Please provide a space separated list of domains.",
"form.import.label.file": "Archivo OPML",
"form.import.label.url": "URL",
"form.integration.betula_activate": "Guardar artículos en Betula",
+7
View File
@@ -220,6 +220,7 @@
"page.settings.webauthn.last_seen_on": "Last Used",
"page.settings.webauthn.register": "Rekisteröi salasana",
"page.settings.webauthn.register.error": "Salasanaa ei voi rekisteröidä",
"page.login.webauthn_login.help": "Please enter your username if you're using a security key. This is not required if you are using a Passkey (discoverable credentials).",
"page.settings.webauthn.delete": [
"Poista %d salasana",
"Poista %d salasanaa"
@@ -355,6 +356,8 @@
"form.feed.label.ntfy_default_priority": "Ntfy default priority",
"form.feed.label.ntfy_low_priority": "Ntfy low priority",
"form.feed.label.ntfy_min_priority": "Ntfy min priority",
"form.integration.cubox_activate": "Save entries to Cubox",
"form.integration.cubox_api_link": "Cubox API link",
"form.feed.fieldset.general": "General",
"form.feed.fieldset.rules": "Rules",
"form.feed.fieldset.network_settings": "Network Settings",
@@ -391,6 +394,7 @@
"form.prefs.label.gesture_nav": "Ele siirtyäksesi merkintöjen välillä",
"form.prefs.label.show_reading_time": "Näytä artikkeleiden arvioitu lukuaika",
"form.prefs.label.custom_css": "Mukautettu CSS",
"form.prefs.label.custom_js": "Mukautettu JavaScript",
"form.prefs.label.entry_order": "Lajittele sarakkeen mukaan",
"form.prefs.label.default_home_page": "Oletusarvoinen etusivu",
"form.prefs.label.categories_sorting_order": "Kategorioiden lajittelu",
@@ -402,6 +406,9 @@
"form.prefs.fieldset.authentication_settings": "Authentication Settings",
"form.prefs.fieldset.reader_settings": "Reader Settings",
"form.prefs.fieldset.global_feed_settings": "Global Feed Settings",
"form.prefs.label.external_font_hosts": "External font hosts",
"form.prefs.help.external_font_hosts": "Space separated list of external font hosts to allow. For example: \"fonts.gstatic.com fonts.googleapis.com\".",
"error.settings_invalid_domain_list": "Invalid domain list. Please provide a space separated list of domains.",
"form.import.label.file": "OPML-tiedosto",
"form.import.label.url": "URL",
"form.integration.betula_activate": "Save entries to Betula",
+7
View File
@@ -220,6 +220,7 @@
"page.settings.webauthn.last_seen_on": "Dernière utilisation",
"page.settings.webauthn.register": "Enregister une nouvelle clé daccès",
"page.settings.webauthn.register.error": "Impossible d'enregistrer la clé daccès",
"page.login.webauthn_login.help": "Veuillez saisir votre nom d'utilisateur si vous utilisez une clé de sécurité. Cela n'est pas nécessaire si vous utilisez une clé d'accès (Passkey).",
"page.settings.webauthn.delete": [
"Supprimer %d clé daccès",
"Supprimer %d clés daccès"
@@ -355,6 +356,8 @@
"form.feed.label.ntfy_default_priority": "Priorité par défaut de notification",
"form.feed.label.ntfy_low_priority": "Priorité basse de notification",
"form.feed.label.ntfy_min_priority": "Priorité minimale de notification",
"form.integration.cubox_activate": "Save entries to Cubox",
"form.integration.cubox_api_link": "Cubox API link",
"form.feed.fieldset.general": "Général",
"form.feed.fieldset.rules": "Règles",
"form.feed.fieldset.network_settings": "Paramètres réseau",
@@ -391,6 +394,7 @@
"form.prefs.label.gesture_nav": "Geste pour naviguer entre les entrées",
"form.prefs.label.show_reading_time": "Afficher le temps de lecture estimé des articles",
"form.prefs.label.custom_css": "Feuille de style personnalisée",
"form.prefs.label.custom_js": "Code JavaScript personnalisé",
"form.prefs.label.entry_order": "Colonne de tri des entrées",
"form.prefs.label.default_home_page": "Page d'accueil par défaut",
"form.prefs.label.categories_sorting_order": "Colonne de tri des catégories",
@@ -402,6 +406,9 @@
"form.prefs.fieldset.authentication_settings": "Paramètres d'authentification",
"form.prefs.fieldset.reader_settings": "Paramètres du lecteur",
"form.prefs.fieldset.global_feed_settings": "Paramètres globaux des abonnements",
"form.prefs.label.external_font_hosts": "Polices externes autorisées",
"form.prefs.help.external_font_hosts": "Liste de domaine externes autorisés, séparés par des espaces. Par exemple : « fonts.gstatic.com fonts.googleapis.com ».",
"error.settings_invalid_domain_list": "Liste de domaines invalide. Veuillez fournir une liste de domaines séparés par des espaces.",
"form.import.label.file": "Fichier OPML",
"form.import.label.url": "URL",
"form.integration.betula_activate": "Sauvegarder les entrées vers Betula",
+7
View File
@@ -220,6 +220,7 @@
"page.settings.webauthn.last_seen_on": "Last Used",
"page.settings.webauthn.register": "रजिस्टर पासकी",
"page.settings.webauthn.register.error": "पासकी पंजीकृत करने में असमर्थ",
"page.login.webauthn_login.help": "Please enter your username if you're using a security key. This is not required if you are using a Passkey (discoverable credentials).",
"page.settings.webauthn.delete": [
"%d पासकुंजी निकालें",
"%d पासकी हटाएं"
@@ -355,6 +356,8 @@
"form.feed.label.ntfy_default_priority": "Ntfy default priority",
"form.feed.label.ntfy_low_priority": "Ntfy low priority",
"form.feed.label.ntfy_min_priority": "Ntfy min priority",
"form.integration.cubox_activate": "Save entries to Cubox",
"form.integration.cubox_api_link": "Cubox API link",
"form.feed.fieldset.general": "General",
"form.feed.fieldset.rules": "Rules",
"form.feed.fieldset.network_settings": "Network Settings",
@@ -391,6 +394,7 @@
"form.prefs.label.gesture_nav": "प्रविष्टियों के बीच नेविगेट करने के लिए इशारा",
"form.prefs.label.show_reading_time": "विषय के लिए अनुमानित पढ़ने का समय दिखाएं",
"form.prefs.label.custom_css": "कस्टम सीएसएस",
"form.prefs.label.custom_js": "कस्टम जेएस",
"form.prefs.label.entry_order": "प्रवेश छँटाई कॉलम",
"form.prefs.label.default_home_page": "डिफ़ॉल्ट होमपेज़",
"form.prefs.label.categories_sorting_order": "श्रेणियाँ छँटाई",
@@ -402,6 +406,9 @@
"form.prefs.fieldset.authentication_settings": "Authentication Settings",
"form.prefs.fieldset.reader_settings": "Reader Settings",
"form.prefs.fieldset.global_feed_settings": "Global Feed Settings",
"form.prefs.label.external_font_hosts": "External font hosts",
"form.prefs.help.external_font_hosts": "Space separated list of external font hosts to allow. For example: \"fonts.gstatic.com fonts.googleapis.com\".",
"error.settings_invalid_domain_list": "Invalid domain list. Please provide a space separated list of domains.",
"form.import.label.file": "ओपीएमएल फ़ाइल",
"form.import.label.url": "यूआरएल",
"form.integration.betula_activate": "Save entries to Betula",
+7
View File
@@ -211,6 +211,7 @@
"page.settings.webauthn.last_seen_on": "Last Used",
"page.settings.webauthn.register": "Register passkey",
"page.settings.webauthn.register.error": "Unable to register passkey",
"page.login.webauthn_login.help": "Please enter your username if you're using a security key. This is not required if you are using a Passkey (discoverable credentials).",
"page.settings.webauthn.delete": [
"Remove %d passkey"
],
@@ -345,6 +346,8 @@
"form.feed.label.ntfy_default_priority": "Ntfy default priority",
"form.feed.label.ntfy_low_priority": "Ntfy low priority",
"form.feed.label.ntfy_min_priority": "Ntfy min priority",
"form.integration.cubox_activate": "Save entries to Cubox",
"form.integration.cubox_api_link": "Cubox API link",
"form.feed.fieldset.general": "General",
"form.feed.fieldset.rules": "Rules",
"form.feed.fieldset.network_settings": "Network Settings",
@@ -381,6 +384,7 @@
"form.prefs.label.gesture_nav": "Isyarat untuk menavigasi antar entri",
"form.prefs.label.show_reading_time": "Tampilkan perkiraan waktu baca untuk artikel",
"form.prefs.label.custom_css": "Modifikasi CSS",
"form.prefs.label.custom_js": "Modifikasi JavaScript",
"form.prefs.label.entry_order": "Pengurutan Kolom Entri",
"form.prefs.label.default_home_page": "Beranda Baku",
"form.prefs.label.categories_sorting_order": "Pengurutan Kategori",
@@ -392,6 +396,9 @@
"form.prefs.fieldset.authentication_settings": "Authentication Settings",
"form.prefs.fieldset.reader_settings": "Reader Settings",
"form.prefs.fieldset.global_feed_settings": "Global Feed Settings",
"form.prefs.label.external_font_hosts": "External font hosts",
"form.prefs.help.external_font_hosts": "Space separated list of external font hosts to allow. For example: \"fonts.gstatic.com fonts.googleapis.com\".",
"error.settings_invalid_domain_list": "Invalid domain list. Please provide a space separated list of domains.",
"form.import.label.file": "Berkas OPML",
"form.import.label.url": "URL",
"form.integration.betula_activate": "Save entries to Betula",
+7
View File
@@ -220,6 +220,7 @@
"page.settings.webauthn.last_seen_on": "Last Used",
"page.settings.webauthn.register": "Registra la chiave di accesso",
"page.settings.webauthn.register.error": "Impossibile registrare la passkey",
"page.login.webauthn_login.help": "Please enter your username if you're using a security key. This is not required if you are using a Passkey (discoverable credentials).",
"page.settings.webauthn.delete": [
"Rimuovi %d passkey",
"Rimuovi %d passkey"
@@ -355,6 +356,8 @@
"form.feed.label.ntfy_default_priority": "Ntfy default priority",
"form.feed.label.ntfy_low_priority": "Ntfy low priority",
"form.feed.label.ntfy_min_priority": "Ntfy min priority",
"form.integration.cubox_activate": "Save entries to Cubox",
"form.integration.cubox_api_link": "Cubox API link",
"form.feed.fieldset.general": "General",
"form.feed.fieldset.rules": "Rules",
"form.feed.fieldset.network_settings": "Network Settings",
@@ -391,6 +394,7 @@
"form.prefs.label.gesture_nav": "Gesto per navigare tra le voci",
"form.prefs.label.show_reading_time": "Mostra il tempo di lettura stimato per gli articoli",
"form.prefs.label.custom_css": "CSS personalizzati",
"form.prefs.label.custom_js": "JavaScript personalizzati",
"form.prefs.label.entry_order": "Colonna di ordinamento delle voci",
"form.prefs.label.default_home_page": "Pagina iniziale predefinita",
"form.prefs.label.categories_sorting_order": "Ordinamento delle categorie",
@@ -402,6 +406,9 @@
"form.prefs.fieldset.authentication_settings": "Authentication Settings",
"form.prefs.fieldset.reader_settings": "Reader Settings",
"form.prefs.fieldset.global_feed_settings": "Global Feed Settings",
"form.prefs.label.external_font_hosts": "External font hosts",
"form.prefs.help.external_font_hosts": "Space separated list of external font hosts to allow. For example: \"fonts.gstatic.com fonts.googleapis.com\".",
"error.settings_invalid_domain_list": "Invalid domain list. Please provide a space separated list of domains.",
"form.import.label.file": "File OPML",
"form.import.label.url": "URL",
"form.integration.betula_activate": "Save entries to Betula",
+7
View File
@@ -211,6 +211,7 @@
"page.settings.webauthn.last_seen_on": "Last Used",
"page.settings.webauthn.register": "パスキーを登録する",
"page.settings.webauthn.register.error": "パスキーを登録できません",
"page.login.webauthn_login.help": "Please enter your username if you're using a security key. This is not required if you are using a Passkey (discoverable credentials).",
"page.settings.webauthn.delete": [
"%d 個のパスキーを削除"
],
@@ -345,6 +346,8 @@
"form.feed.label.ntfy_default_priority": "Ntfy default priority",
"form.feed.label.ntfy_low_priority": "Ntfy low priority",
"form.feed.label.ntfy_min_priority": "Ntfy min priority",
"form.integration.cubox_activate": "Save entries to Cubox",
"form.integration.cubox_api_link": "Cubox API link",
"form.feed.fieldset.general": "General",
"form.feed.fieldset.rules": "Rules",
"form.feed.fieldset.network_settings": "Network Settings",
@@ -381,6 +384,7 @@
"form.prefs.label.gesture_nav": "エントリ間を移動するジェスチャー",
"form.prefs.label.show_reading_time": "記事の推定読書時間を表示する",
"form.prefs.label.custom_css": "カスタム CSS",
"form.prefs.label.custom_js": "カスタム JavaScript",
"form.prefs.label.entry_order": "記事の表示順の基準",
"form.prefs.label.default_home_page": "デフォルトのトップページ",
"form.prefs.label.categories_sorting_order": "カテゴリの表示順",
@@ -392,6 +396,9 @@
"form.prefs.fieldset.authentication_settings": "Authentication Settings",
"form.prefs.fieldset.reader_settings": "Reader Settings",
"form.prefs.fieldset.global_feed_settings": "Global Feed Settings",
"form.prefs.label.external_font_hosts": "External font hosts",
"form.prefs.help.external_font_hosts": "Space separated list of external font hosts to allow. For example: \"fonts.gstatic.com fonts.googleapis.com\".",
"error.settings_invalid_domain_list": "Invalid domain list. Please provide a space separated list of domains.",
"form.import.label.file": "OPML ファイル",
"form.import.label.url": "URL",
"form.integration.betula_activate": "Save entries to Betula",
+7
View File
@@ -221,6 +221,7 @@
"page.settings.webauthn.last_seen_on": "Laatst gebruikt",
"page.settings.webauthn.register": "Passkey registreren",
"page.settings.webauthn.register.error": "Kan passkey niet registreren",
"page.login.webauthn_login.help": "Please enter your username if you're using a security key. This is not required if you are using a Passkey (discoverable credentials).",
"page.settings.webauthn.delete": [
"Verwijder %d passkey",
"Verwijder %d passkeys"
@@ -355,6 +356,8 @@
"form.feed.label.ntfy_default_priority": "Ntfy standaard prioriteit",
"form.feed.label.ntfy_low_priority": "Ntfy lage prioriteit",
"form.feed.label.ntfy_min_priority": "Ntfy minimale prioriteit",
"form.integration.cubox_activate": "Save entries to Cubox",
"form.integration.cubox_api_link": "Cubox API link",
"form.feed.fieldset.general": "Algemeen",
"form.feed.fieldset.rules": "Regels",
"form.feed.fieldset.network_settings": "Netwerk Instellingen",
@@ -391,6 +394,7 @@
"form.prefs.label.gesture_nav": "Gebaar om tussen artikelen te navigeren",
"form.prefs.label.show_reading_time": "Toon geschatte leestijd van artikelen",
"form.prefs.label.custom_css": "Aangepaste CSS",
"form.prefs.label.custom_js": "Aangepaste JavaScript",
"form.prefs.label.entry_order": "Artikelen sorteren",
"form.prefs.label.default_home_page": "Startpagina",
"form.prefs.label.categories_sorting_order": "Volgorde categorieën",
@@ -402,6 +406,9 @@
"form.prefs.fieldset.authentication_settings": "Authenticatie Instellingen",
"form.prefs.fieldset.reader_settings": "Lees Instellingen",
"form.prefs.fieldset.global_feed_settings": "Globale Feed Instellingen",
"form.prefs.label.external_font_hosts": "External font hosts",
"form.prefs.help.external_font_hosts": "Space separated list of external font hosts to allow. For example: \"fonts.gstatic.com fonts.googleapis.com\".",
"error.settings_invalid_domain_list": "Invalid domain list. Please provide a space separated list of domains.",
"form.import.label.file": "OPML-bestand",
"form.import.label.url": "URL",
"form.integration.betula_activate": "Artikelen opslaan in Betula",
+316 -309
View File
@@ -1,10 +1,10 @@
{
"skip_to_content": "Skip to content",
"confirm.question": "Czy jesteś pewny?",
"confirm.question.refresh": "Czy chcesz wymusić odświeżenie?",
"skip_to_content": "Przejdź do treści",
"confirm.question": "Czy na pewno?",
"confirm.question.refresh": "Czy na pewno chcesz wymusić odświeżenie?",
"confirm.yes": "tak",
"confirm.no": "nie",
"confirm.loading": "W toku...",
"confirm.loading": "W toku",
"action.subscribe": "Subskrypcja",
"action.save": "Zapisz",
"action.or": "lub",
@@ -20,7 +20,7 @@
"tooltip.keyboard_shortcuts": "Skróty klawiszowe: %s",
"tooltip.logged_user": "Zalogowany jako %s",
"menu.title": "Menu",
"menu.home_page": "Home page",
"menu.home_page": "Strona główna",
"menu.unread": "Nieprzeczytane",
"menu.starred": "Ulubione",
"menu.history": "Historia",
@@ -32,139 +32,139 @@
"menu.integrations": "Usługi",
"menu.sessions": "Sesje",
"menu.users": "Użytkownicy",
"menu.about": "O stronie",
"menu.about": "O czytniku",
"menu.export": "Eksportuj",
"menu.import": "Importuj",
"menu.search": "Szukaj",
"menu.create_category": "Utwórz kategorię",
"menu.mark_page_as_read": "Oznacz jako przeczytane",
"menu.mark_all_as_read": "Oznacz wszystko jako przeczytane",
"menu.show_all_entries": "Pokaż wszystkie artykuły",
"menu.show_only_unread_entries": "Pokaż tylko nieprzeczytane artykuły",
"menu.show_only_starred_entries": "Pokaż tylko ulubione artykuły",
"menu.mark_all_as_read": "Oznacz wszystkie jako przeczytane",
"menu.show_all_entries": "Pokaż wszystkie wpisy",
"menu.show_only_unread_entries": "Pokaż tylko nieprzeczytane wpisy",
"menu.show_only_starred_entries": "Pokaż tylko ulubione wpisy",
"menu.refresh_feed": "Odśwież",
"menu.refresh_all_feeds": "Odśwież wszystkie subskrypcje w tle",
"menu.refresh_all_feeds": "Odśwież w tle wszystkie subskrypcje",
"menu.edit_feed": "Edytuj",
"menu.edit_category": "Edytuj",
"menu.add_feed": "Dodaj subskrypcję",
"menu.add_feed": "Dodaj kanał",
"menu.add_user": "Dodaj użytkownika",
"menu.flush_history": "Usuń historię",
"menu.feed_entries": "Artykuły",
"menu.feed_entries": "Wpisy",
"menu.api_keys": "Klucze API",
"menu.create_api_key": "Utwórz nowy klucz API",
"menu.shared_entries": "Udostępnione wpisy",
"search.label": "Szukaj",
"search.placeholder": "Szukaj...",
"search.submit": "Search",
"pagination.last": "Ostatni",
"pagination.next": "Następny",
"pagination.first": "Pierwszy",
"pagination.previous": "Poprzedni",
"entry.status.unread": "Nieprzeczytane",
"entry.status.read": "Przeczytane",
"entry.status.toast.unread": "Oznaczone jako nieprzeczytane",
"entry.status.toast.read": "Oznaczone jako przeczytane",
"entry.status.title": "Zmień status artykułu",
"entry.bookmark.toggle.on": "Oznacz gwiazdką",
"entry.bookmark.toggle.off": "Usuń gwiazdkę",
"entry.bookmark.toast.on": "Oznaczone gwiazdką",
"entry.bookmark.toast.off": "Bez gwiazdek",
"entry.state.saving": "Zapisywanie...",
"entry.state.loading": "Ładowanie...",
"search.placeholder": "Szukaj",
"search.submit": "Szukaj",
"pagination.last": "Ostatnia",
"pagination.next": "Następna",
"pagination.first": "Pierwsza",
"pagination.previous": "Poprzednia",
"entry.status.unread": "Nieprzeczytany",
"entry.status.read": "Przeczytany",
"entry.status.toast.unread": "Oznaczono jako nieprzeczytany",
"entry.status.toast.read": "Oznaczono jako przeczytany",
"entry.status.title": "Zmień status wpisu",
"entry.bookmark.toggle.on": "Dodaj do ulubionych",
"entry.bookmark.toggle.off": "Usuń z ulubionych",
"entry.bookmark.toast.on": "Dodano do ulubionych",
"entry.bookmark.toast.off": "Usunięto z ulubionych",
"entry.state.saving": "Zapisywanie",
"entry.state.loading": "Ładowanie",
"entry.save.label": "Zapisz",
"entry.save.title": "Zapisz ten artykuł",
"entry.save.title": "Zapisz ten wpis",
"entry.save.completed": "Gotowe!",
"entry.save.toast.completed": "Artykuł zapisany",
"entry.scraper.label": "Ściągnij",
"entry.save.toast.completed": "Zapisano wpis",
"entry.scraper.label": "Pobierz treść",
"entry.scraper.title": "Pobierz oryginalną treść",
"entry.scraper.completed": "Gotowe!",
"entry.external_link.label": "Link zewnętrzny",
"entry.external_link.label": "Łącze zewnętrzne",
"entry.comments.label": "Komentarze",
"entry.comments.title": "Zobacz komentarze",
"entry.share.label": "Podzielić się",
"entry.share.title": "Podzielić się ten artykuł",
"entry.unshare.label": "Unshare",
"entry.shared_entry.title": "Otwórz publiczny link",
"entry.shared_entry.label": "Udostępnianie",
"entry.share.label": "Udostępnij",
"entry.share.title": "Udostępnij ten wpis",
"entry.unshare.label": "Cofnij udostępnianie",
"entry.shared_entry.title": "Otwórz publiczne łącze",
"entry.shared_entry.label": "Udostępnij",
"entry.estimated_reading_time": [
"%d minuta czytania",
"%d minuty czytania",
"%d minut czytania"
],
"entry.tags.label": "Tagi:",
"entry.tags.label": "Znaczniki:",
"page.shared_entries.title": "Udostępnione wpisy",
"page.shared_entries_count": [
"%d shared entry",
"%d shared entry",
"%d shared entries"
"%d udostępniony wpis",
"%d udostępnione wpisy",
"%d udostępnionych wpisów"
],
"page.unread.title": "Nieprzeczytane",
"page.unread_entry_count": [
"%d unread entry",
"%d unread entry",
"%d unread entries"
"%d nieprzeczytany wpis",
"%d nieprzeczytane wpisy",
"%d nieprzeczytanych wpisów"
],
"page.total_entry_count": [
"%d entry in total",
"%d entry in total",
"%d entries in total"
"%d wpis łącznie",
"%d wpisy łącznie",
"%d wpisów łącznie"
],
"page.starred.title": "Oznaczone gwiazdką",
"page.starred.title": "Ulubione",
"page.starred_entry_count": [
"%d starred entry",
"%d starred entry",
"%d starred entries"
"%d ulubiony wpis",
"%d ulubione wpisy",
"%d ulubionych wpisów"
],
"page.categories.title": "Kategorie",
"page.categories.no_feed": "Brak kanałów.",
"page.categories.entries": "Artykuły",
"page.categories.feeds": "Subskrypcje",
"page.categories.entries": "Wpisy",
"page.categories.feeds": "Kanały",
"page.categories.feed_count": [
"Jest %d kanał.",
"Są %d kanały.",
"Jest %d kanałów."
],
"page.categories_count": [
"%d category",
"%d category",
"%d categories"
"%d kategoria",
"%d kategorie",
"%d kategorii"
],
"page.new_category.title": "Nowa kategoria",
"page.new_user.title": "Nowy użytkownik",
"page.edit_category.title": "Edycja Kategorii: %s",
"page.edit_category.title": "Edytuj kategorię: %s",
"page.edit_user.title": "Edytuj użytkownika: %s",
"page.feeds.title": "Kanały",
"page.category_label": "Category: %s",
"page.category_label": "Kategoria: %s",
"page.feeds.last_check": "Ostatnia aktualizacja:",
"page.feeds.next_check": "Next check:",
"page.feeds.next_check": "Następna aktualizacja:",
"page.feeds.read_counter": "Liczba przeczytanych wpisów",
"page.feeds.error_count": [
"%d błąd",
"%d błąd",
"%d błędy",
"%d błędów"
],
"page.history.title": "Historia",
"page.read_entry_count": [
"%d read entry",
"%d read entry",
"%d read entries"
"%d przeczytany wpis",
"%d przeczytane wpisy",
"%d przeczytanych wpisów"
],
"page.import.title": "Importuj",
"page.search.title": "Wyniki wyszukiwania",
"page.about.title": "O",
"page.about.title": "O stronie",
"page.about.credits": "Prawa autorskie",
"page.about.version": "Wersja:",
"page.about.build_date": "Data opracowania:",
"page.about.author": "Autor:",
"page.about.license": "Licencja:",
"page.about.postgres_version": "Postgres wersja:",
"page.about.go_version": "Go wersja:",
"page.about.global_config_options": "globalne opcje konfiguracji",
"page.about.postgres_version": "Wersja PostgreSQL:",
"page.about.go_version": "Wersja Go:",
"page.about.global_config_options": "Globalne opcje konfiguracji",
"page.add_feed.title": "Nowa subskrypcja",
"page.add_feed.no_category": "Nie ma żadnej kategorii. Musisz mieć co najmniej jedną kategorię.",
"page.add_feed.label.url": "URL",
"page.add_feed.label.url": "Adres URL",
"page.add_feed.submit": "Znajdź subskrypcję",
"page.add_feed.legend.advanced_options": "Zaawansowane opcje",
"page.add_feed.legend.advanced_options": "Opcje zaawansowane",
"page.add_feed.choose_feed": "Wybierz subskrypcję",
"page.edit_feed.title": "Edytuj kanał: %s",
"page.edit_feed.last_check": "Ostatnia aktualizacja:",
@@ -175,39 +175,39 @@
"page.entry.attachments": "Załączniki",
"page.keyboard_shortcuts.title": "Skróty klawiszowe",
"page.keyboard_shortcuts.subtitle.sections": "Nawigacja między punktami menu",
"page.keyboard_shortcuts.subtitle.items": "Nawigacja między artykułami",
"page.keyboard_shortcuts.subtitle.items": "Nawigacja między elementami",
"page.keyboard_shortcuts.subtitle.pages": "Nawigacja między stronami",
"page.keyboard_shortcuts.subtitle.actions": "Działania",
"page.keyboard_shortcuts.go_to_unread": "Przejdź do nieprzeczytanych artykułów",
"page.keyboard_shortcuts.go_to_starred": "Przejdź do zakładek",
"page.keyboard_shortcuts.go_to_unread": "Przejdź do nieprzeczytanych",
"page.keyboard_shortcuts.go_to_starred": "Przejdź do ulubionych",
"page.keyboard_shortcuts.go_to_history": "Przejdź do historii",
"page.keyboard_shortcuts.go_to_feeds": "Przejdź do kanałów",
"page.keyboard_shortcuts.go_to_categories": "Przejdź do kategorii",
"page.keyboard_shortcuts.go_to_settings": "Przejdź do ustawień",
"page.keyboard_shortcuts.show_keyboard_shortcuts": "Pokaż listę skrótów klawiszowych",
"page.keyboard_shortcuts.go_to_previous_item": "Przejdź do poprzedniego artykułu",
"page.keyboard_shortcuts.go_to_next_item": "Przejdź do następnego punktu artykułu",
"page.keyboard_shortcuts.go_to_previous_item": "Przejdź do poprzedniego elementu",
"page.keyboard_shortcuts.go_to_next_item": "Przejdź do następnego elementu",
"page.keyboard_shortcuts.go_to_feed": "Przejdź do subskrypcji",
"page.keyboard_shortcuts.go_to_previous_page": "Przejdź do poprzedniej strony",
"page.keyboard_shortcuts.go_to_next_page": "Przejdź do następnej strony",
"page.keyboard_shortcuts.go_to_bottom_item": "Przejdź do dolnego elementu",
"page.keyboard_shortcuts.go_to_top_item": "Przejdź do najwyższego elementu",
"page.keyboard_shortcuts.open_item": "Otwórz zaznaczony artykuł",
"page.keyboard_shortcuts.open_original": "Otwórz oryginalny artykuł",
"page.keyboard_shortcuts.open_original_same_window": "Otwórz oryginalny link w bieżącej karcie",
"page.keyboard_shortcuts.open_comments": "Otwórz link do komentarzy",
"page.keyboard_shortcuts.open_comments_same_window": "Otwórz link do komentarzy w bieżącej karcie",
"page.keyboard_shortcuts.toggle_read_status_next": "Oznacz jako przeczytane/nieprzeczytane, skup się dalej",
"page.keyboard_shortcuts.toggle_read_status_prev": "Oznacz jako przeczytane/nieprzeczytane, skup poprzednie",
"page.keyboard_shortcuts.refresh_all_feeds": "Odśwież wszystkie subskrypcje w tle",
"page.keyboard_shortcuts.go_to_top_item": "Przejdź do górnego elementu",
"page.keyboard_shortcuts.open_item": "Otwórz zaznaczony element",
"page.keyboard_shortcuts.open_original": "Otwórz oryginalne łącze",
"page.keyboard_shortcuts.open_original_same_window": "Otwórz oryginalne łącze w bieżącej karcie",
"page.keyboard_shortcuts.open_comments": "Otwórz łącze do komentarzy",
"page.keyboard_shortcuts.open_comments_same_window": "Otwórz łącze do komentarzy w bieżącej karcie",
"page.keyboard_shortcuts.toggle_read_status_next": "Przełącz przeczytane/nieprzeczytane, przejdź dalej",
"page.keyboard_shortcuts.toggle_read_status_prev": "Przełącz przeczytane/nieprzeczytane, przejdź wstecz",
"page.keyboard_shortcuts.refresh_all_feeds": "Odśwież w tle wszystkie kanały",
"page.keyboard_shortcuts.mark_page_as_read": "Zaznacz aktualną stronę jako przeczytaną",
"page.keyboard_shortcuts.download_content": "Pobierz oryginalną zawartość",
"page.keyboard_shortcuts.toggle_bookmark_status": "Dodaj/usuń zakładki",
"page.keyboard_shortcuts.save_article": "Zapisz artykuł",
"page.keyboard_shortcuts.scroll_item_to_top": "Przewiń artykuł do góry",
"page.keyboard_shortcuts.download_content": "Pobierz oryginalną treść",
"page.keyboard_shortcuts.toggle_bookmark_status": "Przełącz dodanie do ulubionych",
"page.keyboard_shortcuts.save_article": "Zapisz wpis",
"page.keyboard_shortcuts.scroll_item_to_top": "Przewiń element do góry",
"page.keyboard_shortcuts.remove_feed": "Usuń ten kanał",
"page.keyboard_shortcuts.go_to_search": "Ustaw fokus na formularzu wyszukiwania",
"page.keyboard_shortcuts.toggle_entry_attachments": "Toggle open/close entry attachments",
"page.keyboard_shortcuts.toggle_entry_attachments": "Przełącz otwieranie/zamykanie załączników wpisów",
"page.keyboard_shortcuts.close_modal": "Zamknij listę skrótów klawiszowych",
"page.users.title": "Użytkownicy",
"page.users.username": "Nazwa użytkownika",
@@ -222,33 +222,34 @@
"page.settings.unlink_google_account": "Odłącz moje konto Google",
"page.settings.link_oidc_account": "Połącz z moim kontem %s",
"page.settings.unlink_oidc_account": "Odłącz moje konto %s",
"page.settings.webauthn.passkeys": "Passkeys",
"page.settings.webauthn.actions": "Actions",
"page.settings.webauthn.passkey_name": "Passkey Name",
"page.settings.webauthn.added_on": "Added On",
"page.settings.webauthn.last_seen_on": "Last Used",
"page.settings.webauthn.passkeys": "Klucze dostępu",
"page.settings.webauthn.actions": "Działania",
"page.settings.webauthn.passkey_name": "Nazwa klucza dostępu",
"page.settings.webauthn.added_on": "Dodano",
"page.settings.webauthn.last_seen_on": "Ostatnio użyte",
"page.settings.webauthn.register": "Zarejestruj klucz dostępu",
"page.settings.webauthn.register.error": "Nie można zarejestrować klucza dostępu",
"page.login.webauthn_login.help": "Wpisz swoją nazwę użytkownika, jeśli używasz klucza bezpieczeństwa. Nie jest to wymagane, jeśli używasz klucza dostępu (wykrywalnych danych uwierzytelniających).",
"page.settings.webauthn.delete": [
"Usuń %d klucz dostępu",
"Usuń %d klucze dostępu",
"Usuń %d klucze dostępu"
"Usuń %d kluczy dostępu"
],
"page.login.title": "Zaloguj się",
"page.login.google_signin": "Zaloguj przez Google",
"page.login.oidc_signin": "Zaloguj przez %s",
"page.login.webauthn_login": "Zaloguj się za pomocą hasła",
"page.login.google_signin": "Zaloguj się przez Google",
"page.login.oidc_signin": "Zaloguj się przez %s",
"page.login.webauthn_login": "Zaloguj się przez klucz dostępu",
"page.login.webauthn_login.error": "Nie można zalogować się za pomocą klucza dostępu",
"page.integrations.title": "Usługi",
"page.integration.miniflux_api": "Miniflux API",
"page.integration.miniflux_api": "API Miniflux",
"page.integration.miniflux_api_endpoint": "Punkt końcowy API",
"page.integration.miniflux_api_username": "Nazwa Użytkownika",
"page.integration.miniflux_api_username": "Nazwa użytkownika",
"page.integration.miniflux_api_password": "Hasło",
"page.integration.miniflux_api_password_value": "Hasło konta",
"page.integration.bookmarklet": "Bookmarklet",
"page.integration.miniflux_api_password_value": "Hasło do konta",
"page.integration.bookmarklet": "Skryptozakładka",
"page.integration.bookmarklet.name": "Dodaj do Miniflux",
"page.integration.bookmarklet.instructions": "Przeciągnij i upuść to łącze do zakładek.",
"page.integration.bookmarklet.help": "Ten link umożliwia subskrypcję strony internetowej bezpośrednio za pomocą zakładki w przeglądarce internetowej.",
"page.integration.bookmarklet.help": "To łącze umożliwia subskrypcję strony internetowej bezpośrednio za pomocą zakładki w przeglądarce internetowej.",
"page.sessions.title": "Sesje",
"page.sessions.table.date": "Data",
"page.sessions.table.ip": "Adres IP",
@@ -257,7 +258,7 @@
"page.sessions.table.current_session": "Bieżąca sesja",
"page.api_keys.title": "Klucze API",
"page.api_keys.table.description": "Opis",
"page.api_keys.table.token": "Znak",
"page.api_keys.table.token": "Token",
"page.api_keys.table.last_used_at": "Ostatnio używane",
"page.api_keys.table.created_at": "Data utworzenia",
"page.api_keys.table.actions": "Działania",
@@ -266,19 +267,19 @@
"page.offline.title": "Tryb offline",
"page.offline.message": "Jesteś odłączony od sieci",
"page.offline.refresh_page": "Spróbuj odświeżyć stronę",
"page.webauthn_rename.title": "Rename Passkey",
"alert.no_shared_entry": "Brak wspólnego wpisu.",
"alert.no_bookmark": "Obecnie nie ma żadnych zakładek.",
"alert.no_category": "Nie ma żadnej kategorii!",
"alert.no_category_entry": "W tej kategorii nie ma żadnych artykułów",
"alert.no_tag_entry": "Nie ma wpisów pasujących do tego tagu.",
"alert.no_feed_entry": "Nie ma artykułu dla tego kanału.",
"page.webauthn_rename.title": "Zmień nazwę klucza dostępu",
"alert.no_shared_entry": "Brak udostępnionego wpisu.",
"alert.no_bookmark": "Brak ulubionych w tej chwili.",
"alert.no_category": "Brak kategorii!",
"alert.no_category_entry": "Brak wpisów w tej kategorii",
"alert.no_tag_entry": "Brak wpisów pasujących do tego znacznika.",
"alert.no_feed_entry": "Brak wpisów tego kanału.",
"alert.no_feed": "Nie masz żadnej subskrypcji.",
"alert.no_feed_in_category": "Nie ma subskrypcji dla tej kategorii.",
"alert.no_feed_in_category": "Nie ma subskrypcji tej kategorii.",
"alert.no_history": "Obecnie nie ma żadnej historii.",
"alert.feed_error": "Z tym kanałem jest problem",
"alert.no_search_result": "Brak wyników dla tego wyszukiwania.",
"alert.no_unread_entry": "Nie ma żadnych nieprzeczytanych artykułów.",
"alert.no_search_result": "Brak wyników tego wyszukiwania.",
"alert.no_unread_entry": "Nie ma żadnych nieprzeczytanych wpisów.",
"alert.no_user": "Jesteś jedynym użytkownikiem.",
"alert.account_unlinked": "Twoje konto zewnętrzne jest teraz zdysocjowane!",
"alert.account_linked": "Twoje konto zewnętrzne jest teraz połączone!",
@@ -287,7 +288,7 @@
"error.unlink_account_without_password": "Musisz zdefiniować hasło, inaczej nie będziesz mógł się ponownie zalogować.",
"error.duplicate_linked_account": "Już ktoś jest powiązany z tym dostawcą!",
"error.duplicate_fever_username": "Już ktoś inny używa tej nazwy użytkownika Fever!",
"error.duplicate_googlereader_username": "Już ktoś inny używa tej nazwy użytkownika Google Reader!",
"error.duplicate_googlereader_username": "Istnieje już ktoś inny z tą samą nazwą użytkownika Google Reader!",
"error.pocket_request_token": "Nie można pobrać tokena żądania z Pocket!",
"error.pocket_access_token": "Nie można pobrać tokena dostępu z Pocket!",
"error.category_already_exists": "Ta kategoria już istnieje.",
@@ -297,7 +298,7 @@
"error.unable_to_create_user": "Nie można utworzyć tego użytkownika.",
"error.unable_to_update_user": "Nie można zaktualizować tego użytkownika.",
"error.unable_to_update_feed": "Nie można zaktualizować tego kanału.",
"error.subscription_not_found": "Nie znaleziono żadnych subskrypcji.",
"error.subscription_not_found": "Nie znaleziono żadnych kanałów.",
"error.empty_file": "Ten plik jest pusty.",
"error.bad_credentials": "Nieprawidłowa nazwa użytkownika lub hasło.",
"error.fields_mandatory": "Wszystkie pola są obowiązkowe.",
@@ -305,17 +306,17 @@
"error.different_passwords": "Hasła nie są identyczne.",
"error.password_min_length": "Musisz użyć co najmniej 6 znaków.",
"error.settings_mandatory_fields": "Pola nazwy użytkownika, tematu, języka i strefy czasowej są obowiązkowe.",
"error.settings_reading_speed_is_positive": "Prędkości odczytu muszą być dodatnimi liczbami całkowitymi.",
"error.settings_block_rule_fieldname_invalid": "Invalid Block rule: rule #%d is missing a valid field name (Options: %s)",
"error.settings_block_rule_separator_required": "Invalid Block rule: rule #%d's pattern is required to be seperated by a '='",
"error.settings_block_rule_regex_required": "Invalid Block rule: rule #%d's pattern is not provided",
"error.settings_block_rule_invalid_regex": "Invalid Block rule: rule #%d's pattern is not a valid regex",
"error.settings_keep_rule_fieldname_invalid": "Invalid Keep rule: rule #%d is missing a valid field name (Options: %s)",
"error.settings_keep_rule_separator_required": "Invalid Keep rule: rule #%d's pattern is required to be seperated by a '='",
"error.settings_keep_rule_regex_required": "Invalid Keep rule: rule #%d pattern is not provided",
"error.settings_keep_rule_invalid_regex": "Invalid Keep rule: rule #%d's pattern is not a valid regex",
"error.settings_reading_speed_is_positive": "Szybkości czytania muszą być dodatnimi liczbami całkowitymi.",
"error.settings_block_rule_fieldname_invalid": "Nieprawidłowa reguła blokowania: w regule #%d brakuje prawidłowej nazwy pola (opcje: %s)",
"error.settings_block_rule_separator_required": "Nieprawidłowa reguła blokowania: wzór reguły #%d musi być oddzielony znakiem '='",
"error.settings_block_rule_regex_required": "Nieprawidłowa reguła blokowania: nie podano wzorca reguły #%d",
"error.settings_block_rule_invalid_regex": "Nieprawidłowa reguła blokowania: wzór reguły #%d nie jest prawidłowym wyrażeniem regularnym",
"error.settings_keep_rule_fieldname_invalid": "Nieprawidłowa reguła utrzymywania: w regule #%d brakuje prawidłowej nazwy pola (opcje: %s)",
"error.settings_keep_rule_separator_required": "Nieprawidłowa reguła utrzymywania: wzór reguły #%d musi być oddzielony znakiem '='",
"error.settings_keep_rule_regex_required": "Nieprawidłowa reguła utrzymywania nie podano wzorca reguły #%d",
"error.settings_keep_rule_invalid_regex": "Nieprawidłowa reguła utrzymywania: wzór reguły #%d nie jest prawidłowym wyrażeniem regularnym",
"error.entries_per_page_invalid": "Liczba wpisów na stronę jest nieprawidłowa.",
"error.feed_mandatory_fields": "URL i kategoria są obowiązkowe.",
"error.feed_mandatory_fields": "Adres URL i kategoria są obowiązkowe.",
"error.feed_already_exists": "Ten kanał już istnieje.",
"error.invalid_feed_url": "Nieprawidłowy adres URL kanału.",
"error.invalid_site_url": "Nieprawidłowy adres URL witryny.",
@@ -326,49 +327,51 @@
"error.feed_invalid_blocklist_rule": "Reguła listy zablokowanych jest nieprawidłowa.",
"error.feed_invalid_keeplist_rule": "Reguła listy zachowania jest nieprawidłowa.",
"error.user_mandatory_fields": "Nazwa użytkownika jest obowiązkowa.",
"error.api_key_already_exists": "Deze API-sleutel bestaat al.",
"error.api_key_already_exists": "Ten klucz API już istnieje.",
"error.unable_to_create_api_key": "Nie można utworzyć tego klucza API.",
"error.invalid_theme": "Nieprawidłowy motyw.",
"error.invalid_language": "Nieprawidłowy język.",
"error.invalid_timezone": "Nieprawidłowa strefa czasowa.",
"error.invalid_entry_direction": "Nieprawidłowa kolejność sortowania.",
"error.invalid_display_mode": "Nieprawidłowy tryb wyświetlania aplikacji internetowej.",
"error.invalid_display_mode": "Nieprawidłowy tryb wyświetlania aplikacji sieciowej.",
"error.invalid_gesture_nav": "Nieprawidłowa nawigacja gestami.",
"error.invalid_default_home_page": "Nieprawidłowa domyślna strona główna!",
"form.feed.label.title": "Tytuł",
"form.feed.label.site_url": "URL strony",
"form.feed.label.feed_url": "URL kanału",
"form.feed.label.site_url": "Adres URL strony",
"form.feed.label.feed_url": "Adres URL kanału",
"form.feed.label.description": "Opis",
"form.feed.label.category": "Kategoria",
"form.feed.label.crawler": "Pobierz oryginalną treść",
"form.feed.label.feed_username": "Subskrypcję nazwa użytkownika",
"form.feed.label.feed_password": "Subskrypcję Hasło",
"form.feed.label.feed_username": "Nazwa użytkownika subskrypcji",
"form.feed.label.feed_password": "Hasło do subskrypcji",
"form.feed.label.user_agent": "Zastąp domyślny agent użytkownika",
"form.feed.label.cookie": "Ustawianie ciasteczek",
"form.feed.label.scraper_rules": "Zasady ekstrakcji",
"form.feed.label.cookie": "Ustaw ciasteczka",
"form.feed.label.scraper_rules": "Reguły ekstrakcji",
"form.feed.label.rewrite_rules": "Reguły zapisu",
"form.feed.label.blocklist_rules": "Zasady blokowania",
"form.feed.label.keeplist_rules": "Zasady zezwoleń",
"form.feed.label.urlrewrite_rules": "Zasady przepisywania adresów URL",
"form.feed.label.apprise_service_urls": "Comma separated list of Apprise service URLs",
"form.feed.label.ignore_http_cache": "Zignoruj pamięć podręczną HTTP",
"form.feed.label.allow_self_signed_certificates": "Zezwalaj na certyfikaty z podpisem własnym lub nieprawidłowe certyfikaty",
"form.feed.label.disable_http2": "Disable HTTP/2 to avoid fingerprinting",
"form.feed.label.blocklist_rules": "Reguły blokowania",
"form.feed.label.keeplist_rules": "Reguły utrzymywania",
"form.feed.label.urlrewrite_rules": "Reguły przepisywania adresów URL",
"form.feed.label.apprise_service_urls": "Rozdzielana przecinkami lista adresów URL usług Appprise",
"form.feed.label.ignore_http_cache": "Zignoruj pamięć podręczną HTTP",
"form.feed.label.allow_self_signed_certificates": "Zezwalaj na samopodpisane lub nieprawidłowe certyfikaty",
"form.feed.label.disable_http2": "Wyłącz protokół HTTP/2, aby uniknąć identyfikowania",
"form.feed.label.fetch_via_proxy": "Pobierz przez proxy",
"form.feed.label.disabled": "Nie odświeżaj tego kanału",
"form.feed.label.no_media_player": "No media player (audio/video)",
"form.feed.label.disabled": "Nie aktualizuj tego kanału",
"form.feed.label.no_media_player": "Brak odtwarzacza multimedialnego (audio i wideo)",
"form.feed.label.hide_globally": "Ukryj wpisy na globalnej liście nieprzeczytanych",
"form.feed.label.ntfy_activate": "Push entries to ntfy",
"form.feed.label.ntfy_priority": "Ntfy priority",
"form.feed.label.ntfy_max_priority": "Ntfy max priority",
"form.feed.label.ntfy_high_priority": "Ntfy high priority",
"form.feed.label.ntfy_default_priority": "Ntfy default priority",
"form.feed.label.ntfy_low_priority": "Ntfy low priority",
"form.feed.label.ntfy_min_priority": "Ntfy min priority",
"form.feed.fieldset.general": "General",
"form.feed.fieldset.rules": "Rules",
"form.feed.fieldset.network_settings": "Network Settings",
"form.feed.fieldset.integration": "Third-Party Services",
"form.feed.label.ntfy_activate": "Prześlij wpisy do ntfy",
"form.feed.label.ntfy_priority": "Priorytet ntfy",
"form.feed.label.ntfy_max_priority": "Maksymalny priorytet ntfy",
"form.feed.label.ntfy_high_priority": "Wysoki priorytet ntfy",
"form.feed.label.ntfy_default_priority": "Domyślny priorytet ntfy",
"form.feed.label.ntfy_low_priority": "Niski priorytet ntfy",
"form.feed.label.ntfy_min_priority": "Minimalny priorytet ntfy",
"form.integration.cubox_activate": "Zapisuj wpisy w Cubox",
"form.integration.cubox_api_link": "Łącze API Cubox",
"form.feed.fieldset.general": "Ogólne",
"form.feed.fieldset.rules": "Reguły",
"form.feed.fieldset.network_settings": "Ustawienia sieci",
"form.feed.fieldset.integration": "Usługi dostawców zewnętrznych",
"form.category.label.title": "Tytuł",
"form.category.hide_globally": "Ukryj wpisy na globalnej liście nieprzeczytanych",
"form.user.label.username": "Nazwa użytkownika",
@@ -378,147 +381,151 @@
"form.prefs.label.language": "Język",
"form.prefs.label.timezone": "Strefa czasowa",
"form.prefs.label.theme": "Wygląd",
"form.prefs.label.entry_sorting": "Sortowanie artykułów",
"form.prefs.label.entries_per_page": "Wpisy na stronie",
"form.prefs.label.default_reading_speed": "Tryb wyświetlania Progressive Web App (PWA).",
"form.prefs.label.cjk_reading_speed": "Prędkość czytania dla języka chińskiego, koreańskiego i japońskiego (znaki na minutę)",
"form.prefs.label.display_mode": "Tryb wyświetlania aplikacji internetowej (wymaga ponownej instalacji)",
"form.prefs.label.entry_sorting": "Sortowanie wpisów",
"form.prefs.label.entries_per_page": "Wpisy na stronę",
"form.prefs.label.default_reading_speed": "Szybkość czytania w innych językach (słowa na minutę)",
"form.prefs.label.cjk_reading_speed": "Szybkość czytania w języku chińskim, koreańskim i japońskim (znaki na minutę)",
"form.prefs.label.display_mode": "Tryb wyświetlania progresywnej aplikacji sieciowej (PWA)",
"form.prefs.select.older_first": "Najstarsze wpisy jako pierwsze",
"form.prefs.label.keyboard_shortcuts": "Włącz skróty klawiaturowe",
"form.prefs.label.entry_swipe": "Włącz machnięcie wpisu na ekranach dotykowych",
"form.prefs.label.gesture_nav": "Gest, aby poruszać się między wpisami",
"form.prefs.label.show_reading_time": "Pokaż szacowany czas czytania artykułów",
"form.prefs.label.keyboard_shortcuts": "Włącz skróty klawiszowe",
"form.prefs.label.entry_swipe": "Włącz przesuwanie wpisów na ekranach dotykowych",
"form.prefs.label.gesture_nav": "Gest do poruszania się między wpisami",
"form.prefs.label.show_reading_time": "Pokaż szacowany czas czytania wpisów",
"form.prefs.select.recent_first": "Najnowsze wpisy jako pierwsze",
"form.prefs.select.fullscreen": "Pełny ekran",
"form.prefs.select.fullscreen": "Pełnoekranowy",
"form.prefs.select.standalone": "Samodzielny",
"form.prefs.select.minimal_ui": "Minimalny",
"form.prefs.select.browser": "Przeglądarka",
"form.prefs.select.browser": "Przeglądarkowy",
"form.prefs.select.publish_time": "Czas publikacji wpisu",
"form.prefs.select.created_time": "Czas utworzenia wpisu",
"form.prefs.select.alphabetical": "Alfabetycznie",
"form.prefs.select.unread_count": "Liczba nieprzeczytanych",
"form.prefs.select.none": "Nic",
"form.prefs.select.tap": "Podwójne wciśnięcie",
"form.prefs.select.swipe": "Trzepnąć",
"form.prefs.select.none": "Brak",
"form.prefs.select.tap": "Podwójne stuknięcie",
"form.prefs.select.swipe": "Przesuwanie",
"form.prefs.label.custom_css": "Niestandardowy CSS",
"form.prefs.label.custom_js": "Niestandardowy JavaScript",
"form.prefs.label.entry_order": "Kolumna sortowania wpisów",
"form.prefs.label.default_home_page": "Domyślna strona główna",
"form.prefs.label.categories_sorting_order": "Sortowanie kategorii",
"form.prefs.label.mark_read_on_view": "Automatycznie oznaczaj wpisy jako przeczytane podczas przeglądania",
"form.prefs.label.mark_read_on_view_or_media_completion": "Mark entries as read when viewed. For audio/video, mark as read at 90%% completion",
"form.prefs.label.mark_read_on_media_completion": "Only mark as read when audio/video playback reaches 90%% completion",
"form.prefs.label.mark_read_manually": "Mark entries as read manually",
"form.prefs.fieldset.application_settings": "Application Settings",
"form.prefs.fieldset.authentication_settings": "Authentication Settings",
"form.prefs.fieldset.reader_settings": "Reader Settings",
"form.prefs.fieldset.global_feed_settings": "Global Feed Settings",
"form.prefs.label.mark_read_on_view": "Automatycznie oznacz wpisy jako przeczytane podczas przeglądania",
"form.prefs.label.mark_read_on_view_or_media_completion": "Oznacz wpisy jako przeczytane po wyświetleniu. W przypadku audio i wideo oznacz jako przeczytane po ukończeniu 90%%",
"form.prefs.label.mark_read_on_media_completion": "Oznacz jako przeczytane dopiero wtedy, gdy odtwarzanie audio i wideo osiągnie 90%% ukończenia",
"form.prefs.label.mark_read_manually": "Oznacz wpisy jako przeczytane ręcznie",
"form.prefs.fieldset.application_settings": "Ustawienia aplikacji",
"form.prefs.fieldset.authentication_settings": "Ustawienia uwierzytelniania",
"form.prefs.fieldset.reader_settings": "Ustawienia czytnika",
"form.prefs.fieldset.global_feed_settings": "Globalne ustawienia kanałów",
"form.prefs.label.external_font_hosts": "Hosty zewnętrznych czcionek",
"form.prefs.help.external_font_hosts": "Lista hostów zewnętrznych czcionek, na które należy zezwolić, rozdzielona spacjami. Na przykład: „fonts.gstatic.com fonts.googleapis.com”.",
"error.settings_invalid_domain_list": "Nieprawidłowa lista domen. Podaj listę domen rozdzielonych spacjami.",
"form.import.label.file": "Plik OPML",
"form.import.label.url": "URL",
"form.integration.betula_activate": "Save entries to Betula",
"form.integration.betula_url": "Betula server URL",
"form.integration.betula_token": "Betula Token",
"form.integration.fever_activate": "Aktywuj Fever API",
"form.import.label.url": "Adres URL",
"form.integration.betula_activate": "Zapisuj wpisy w Betula",
"form.integration.betula_url": "Adres URL serwera Betula",
"form.integration.betula_token": "Token do Betula",
"form.integration.fever_activate": "Aktywuj API Fever",
"form.integration.fever_username": "Login do Fever",
"form.integration.fever_password": "Hasło do Fever",
"form.integration.fever_endpoint": "Punkt końcowy API gorączka:",
"form.integration.googlereader_activate": "Aktywuj Google Reader API",
"form.integration.fever_endpoint": "Punkt końcowy API Fever:",
"form.integration.googlereader_activate": "Aktywuj API Google Reader",
"form.integration.googlereader_username": "Login do Google Reader",
"form.integration.googlereader_password": "Hasło do Google Reader",
"form.integration.googlereader_endpoint": "Punkt końcowy API gorączka:",
"form.integration.pinboard_activate": "Zapisz artykuł w Pinboard",
"form.integration.pinboard_token": "Token Pinboard API",
"form.integration.pinboard_tags": "Pinboard Tags",
"form.integration.googlereader_endpoint": "Punkt końcowy API Google Reader:",
"form.integration.pinboard_activate": "Zapisuj wpisy w Pinboard",
"form.integration.pinboard_token": "Token API do Pinboard",
"form.integration.pinboard_tags": "Znaczniki Pinboard",
"form.integration.pinboard_bookmark": "Zaznacz zakładkę jako nieprzeczytaną",
"form.integration.instapaper_activate": "Zapisz artykuł w Instapaper",
"form.integration.instapaper_activate": "Zapisuj wpisy w Instapaper",
"form.integration.instapaper_username": "Login do Instapaper",
"form.integration.instapaper_password": "Hasło do Instapaper",
"form.integration.pocket_activate": "Zapisz artykuły w Pocket",
"form.integration.pocket_consumer_key": "Pocket Consumer Key",
"form.integration.pocket_access_token": "Token dostępu kieszeń",
"form.integration.pocket_activate": "Zapisuj wpisy w Pocket",
"form.integration.pocket_consumer_key": "Klucz klienta do Pocket",
"form.integration.pocket_access_token": "Token dostępu do Pocket",
"form.integration.pocket_connect_link": "Połącz swoje konto Pocket",
"form.integration.wallabag_activate": "Zapisz artykuły do Wallabag",
"form.integration.wallabag_only_url": "Wyślij tylko adres URL (zamiast pełnej treści)",
"form.integration.wallabag_endpoint": "Wallabag URL",
"form.integration.wallabag_client_id": "Wallabag Client-ID",
"form.integration.wallabag_client_secret": "Wallabag Client Secret",
"form.integration.wallabag_activate": "Zapisuj wpisy w Wallabag",
"form.integration.wallabag_only_url": "Przesyłaj tylko adres URL (zamiast pełnej treści)",
"form.integration.wallabag_endpoint": "Punkt końcowy API Wallabag",
"form.integration.wallabag_client_id": "Identyfikator klienta Wallabag",
"form.integration.wallabag_client_secret": "Tajny klucz klienta Wallabag",
"form.integration.wallabag_username": "Login do Wallabag",
"form.integration.wallabag_password": "Hasło do Wallabag",
"form.integration.notion_activate": "Save entries to Notion",
"form.integration.notion_page_id": "Notion Page ID",
"form.integration.notion_token": "Notion Secret Token",
"form.integration.apprise_activate": "Push entries to Apprise",
"form.integration.apprise_url": "Apprise API URL",
"form.integration.apprise_services_url": "Comma separated list of Apprise service URLs",
"form.integration.nunux_keeper_activate": "Zapisz artykuly do Nunux Keeper",
"form.integration.nunux_keeper_endpoint": "Nunux Keeper URL",
"form.integration.nunux_keeper_api_key": "Nunux Keeper API key",
"form.integration.omnivore_activate": "Zapisz artykuly do Omnivore",
"form.integration.omnivore_url": "Omnivore URL",
"form.integration.omnivore_api_key": "Omnivore API key",
"form.integration.espial_activate": "Zapisz artykuly do Espial",
"form.integration.espial_endpoint": "Espial URL",
"form.integration.espial_api_key": "Espial API key",
"form.integration.espial_tags": "Espial Tags",
"form.integration.readwise_activate": "Save entries to Readwise Reader",
"form.integration.readwise_api_key": "Readwise Reader Access Token",
"form.integration.readwise_api_key_link": "Get your Readwise Access Token",
"form.integration.telegram_bot_activate": "Przesyłaj nowe artykuły do czatu Telegram",
"form.integration.telegram_bot_token": "Token bota",
"form.integration.notion_activate": "Zapisuj wpisy w Notion",
"form.integration.notion_page_id": "Identyfikator strony Notion",
"form.integration.notion_token": "Tajny token do Notion",
"form.integration.apprise_activate": "Przesyłaj wpisy do Apprise",
"form.integration.apprise_url": "Adres URL API Apprise",
"form.integration.apprise_services_url": "Oddzielona przecinkami lista adresów URL usługi Apprise",
"form.integration.nunux_keeper_activate": "Zapisuj wpisy w Nunux Keeper",
"form.integration.nunux_keeper_endpoint": "Punkt końcowy API Nunux Keeper",
"form.integration.nunux_keeper_api_key": "Klucz API do Nunux Keeper",
"form.integration.omnivore_activate": "Zapisuj wpisy w Omnivore",
"form.integration.omnivore_url": "Punkt końcowy API Omnivore",
"form.integration.omnivore_api_key": "Klucz API do Omnivore",
"form.integration.espial_activate": "Zapisuj wpisy w Espial",
"form.integration.espial_endpoint": "Punkt końcowy API Espial",
"form.integration.espial_api_key": "Klucz API do Espial",
"form.integration.espial_tags": "Znaczniki Espial",
"form.integration.readwise_activate": "Zapisuj wpisy w czytniku Readwise",
"form.integration.readwise_api_key": "Token dostępu do czytnika Readwise",
"form.integration.readwise_api_key_link": "Zdobądź token dostępu Readwise",
"form.integration.telegram_bot_activate": "Przesyłaj nowe wpisy do czatu Telegram",
"form.integration.telegram_bot_token": "Token do bota",
"form.integration.telegram_chat_id": "Identyfikator czatu",
"form.integration.telegram_topic_id": "Topic ID",
"form.integration.telegram_bot_disable_web_page_preview": "Disable web page preview",
"form.integration.telegram_bot_disable_notification": "Disable notification",
"form.integration.telegram_bot_disable_buttons": "Disable buttons",
"form.integration.linkace_activate": "Save entries to LinkAce",
"form.integration.linkace_endpoint": "LinkAce API Endpoint",
"form.integration.linkace_api_key": "LinkAce API key",
"form.integration.linkace_tags": "LinkAce Tags",
"form.integration.linkace_is_private": "Mark link as private",
"form.integration.linkace_check_disabled": "Disable link check",
"form.integration.linkding_activate": "Zapisz artykuły do Linkding",
"form.integration.linkding_endpoint": "Linkding URL",
"form.integration.linkding_api_key": "Linkding API key",
"form.integration.linkding_tags": "Linkding Tags",
"form.integration.linkding_bookmark": "Zaznacz zakładkę jako nieprzeczytaną",
"form.integration.linkwarden_activate": "Zapisz artykuły do Linkwarden",
"form.integration.linkwarden_endpoint": "Linkwarden URL",
"form.integration.linkwarden_api_key": "Linkwarden API key",
"form.integration.matrix_bot_activate": "Przenieś nowe artykuły do Matrix",
"form.integration.matrix_bot_user": "Nazwa użytkownika dla Matrix",
"form.integration.matrix_bot_password": "Hasło dla użytkownika Matrix",
"form.integration.matrix_bot_url": "URL serwera Matrix",
"form.integration.telegram_topic_id": "Identyfikator tematu",
"form.integration.telegram_bot_disable_web_page_preview": "Wyłącz podgląd strony internetowej",
"form.integration.telegram_bot_disable_notification": "Wyłącz powiadomienie",
"form.integration.telegram_bot_disable_buttons": "Wyłącz przyciski",
"form.integration.linkace_activate": "Zapisuj wpisy w LinkAce",
"form.integration.linkace_endpoint": "Punkt końcowy API LinkAce",
"form.integration.linkace_api_key": "Klucz API do LinkAce",
"form.integration.linkace_tags": "Znaczniki LinkAce",
"form.integration.linkace_is_private": "Oznacz łącze jako prywatne",
"form.integration.linkace_check_disabled": "Wyłącz sprawdzanie łączy",
"form.integration.linkding_activate": "Zapisuj wpisy w Linkding",
"form.integration.linkding_endpoint": "Punkt końcowy API Linkding",
"form.integration.linkding_api_key": "Klucz API do Linkding",
"form.integration.linkding_tags": "Znaczniki Linkding",
"form.integration.linkding_bookmark": "Oznacz zakładkę jako nieprzeczytaną",
"form.integration.linkwarden_activate": "Zapisuj wpisy w Linkwarden",
"form.integration.linkwarden_endpoint": "Punkt końcowy API Linkwarden",
"form.integration.linkwarden_api_key": "Klucz API do Linkwarden",
"form.integration.matrix_bot_activate": "Przesyłaj nowe wpisy do Matrix",
"form.integration.matrix_bot_user": "Login do Matrix",
"form.integration.matrix_bot_password": "Hasło do Matrix",
"form.integration.matrix_bot_url": "Adres URL serwera Matrix",
"form.integration.matrix_bot_chat_id": "Identyfikator pokoju Matrix",
"form.integration.raindrop_activate": "Save entries to Raindrop",
"form.integration.raindrop_token": "(Test) Token",
"form.integration.raindrop_collection_id": "Collection ID",
"form.integration.raindrop_tags": "Tags (comma-separated)",
"form.integration.readeck_activate": "Zapisz artykuły do Readeck",
"form.integration.readeck_endpoint": "Readeck URL",
"form.integration.readeck_api_key": "Readeck API key",
"form.integration.readeck_labels": "Readeck Labels",
"form.integration.readeck_only_url": "Wyślij tylko adres URL (zamiast pełnej treści)",
"form.integration.shiori_activate": "Zapisz artykuły do Shiori",
"form.integration.shiori_endpoint": "Shiori URL",
"form.integration.raindrop_activate": "Zapisuj wpisy do Raindrop",
"form.integration.raindrop_token": "Token (testowy)",
"form.integration.raindrop_collection_id": "Identyfikator kolekcji",
"form.integration.raindrop_tags": "Znaczniki (oddzielone przecinkami)",
"form.integration.readeck_activate": "Zapisuj wpisy do Readeck",
"form.integration.readeck_endpoint": "Punkt końcowy API Readeck",
"form.integration.readeck_api_key": "Tajny klucz API Readeck",
"form.integration.readeck_labels": "Etykiety Readeck",
"form.integration.readeck_only_url": "Wysyłaj tylko adres URL (zamiast pełnej treści)",
"form.integration.shiori_activate": "Zapisuj artykuły w Shiori",
"form.integration.shiori_endpoint": "Punkt końcowy API Shiori",
"form.integration.shiori_username": "Login do Shiori",
"form.integration.shiori_password": "Hasło do Shiori",
"form.integration.shaarli_activate": "Save articles to Shaarli",
"form.integration.shaarli_endpoint": "Shaarli URL",
"form.integration.shaarli_api_secret": "Shaarli API Secret",
"form.integration.webhook_activate": "Enable Webhook",
"form.integration.webhook_url": "Webhook URL",
"form.integration.webhook_secret": "Webhook Secret",
"form.integration.rssbridge_activate": "Check RSS-Bridge when adding subscriptions",
"form.integration.rssbridge_url": "RSS-Bridge server URL",
"form.integration.ntfy_activate": "Push entries to ntfy",
"form.integration.ntfy_topic": "Ntfy topic",
"form.integration.ntfy_url": "Ntfy URL (optional, default is ntfy.sh)",
"form.integration.ntfy_api_token": "Ntfy API Token (optional)",
"form.integration.ntfy_username": "Ntfy Username (optional)",
"form.integration.ntfy_password": "Ntfy Password (optional)",
"form.integration.ntfy_icon_url": "Ntfy Icon URL (optional)",
"form.integration.shaarli_activate": "Zapisuj artykuły w Shaarli",
"form.integration.shaarli_endpoint": "Adres URL Shaarli",
"form.integration.shaarli_api_secret": "Tajny klucz API do Shaarli",
"form.integration.webhook_activate": "Włącz Webhook",
"form.integration.webhook_url": "Adres URL Webhook",
"form.integration.webhook_secret": "Tajny klucz do Webhook",
"form.integration.rssbridge_activate": "Sprawdź RSS-Bridge podczas dodawania subskrypcji",
"form.integration.rssbridge_url": "Adres URL serwera RSS-Bridge",
"form.integration.ntfy_activate": "Przesyłaj wpisy do ntfy",
"form.integration.ntfy_topic": "Temay ntfy",
"form.integration.ntfy_url": "Adres URL ntfy (opcjonalny, domyślny to ntfy.sh)",
"form.integration.ntfy_api_token": "Token API ntfy (opcjonalny)",
"form.integration.ntfy_username": "Login do ntfy (opcjonalny)",
"form.integration.ntfy_password": "Hasło do ntfy (opcjonalne)",
"form.integration.ntfy_icon_url": "Adres URL ikony ntfy (opcjonalny)",
"form.api_key.label.description": "Etykieta klucza API",
"form.submit.loading": "Ładowanie...",
"form.submit.saving": "Zapisywanie...",
"form.submit.loading": "Ładowanie",
"form.submit.saving": "Zapisywanie",
"time_elapsed.not_yet": "jeszcze nie",
"time_elapsed.yesterday": "wczoraj",
"time_elapsed.now": "przed chwilą",
@@ -539,12 +546,12 @@
],
"time_elapsed.weeks": [
"%d tydzień temu",
"%d tygodni temu",
"%d tygodnie temu",
"%d tygodni temu"
],
"time_elapsed.months": [
"%d miesiąc temu",
"%d miesięcy temu",
"%d miesiące temu",
"%d miesięcy temu"
],
"time_elapsed.years": [
@@ -553,44 +560,44 @@
"%d lat temu"
],
"alert.too_many_feeds_refresh": [
"You have triggered too many feed refreshes. Please wait %d minute before trying again.",
"You have triggered too many feed refreshes. Please wait %d minutes before trying again.",
"You have triggered too many feed refreshes. Please wait %d minutes before trying again."
"Wykonano zbyt wiele odświeżeń kanału. Poczekaj %d minutę przed ponowną próbą.",
"Wykonano zbyt wiele odświeżeń kanału. Poczekaj %d minuty przed ponowną próbą.",
"Wykonano zbyt wiele odświeżeń kanału. Poczekaj %d minut przed ponowną próbą."
],
"alert.background_feed_refresh": "All feeds are being refreshed in the background. You can continue to use Miniflux while this process is running.",
"error.http_response_too_large": "The HTTP response is too large. You could increase the HTTP response size limit in the global settings (requires a server restart).",
"error.http_body_read": "Unable to read the HTTP body: %v.",
"error.http_empty_response_body": "The HTTP response body is empty.",
"error.http_empty_response": "The HTTP response is empty. Perhaps, this website is using a bot protection mechanism?",
"error.tls_error": "TLS error: %q. You could disable TLS verification in the feed settings if you would like.",
"error.network_operation": "Miniflux is not able to reach this website due to a network error: %v.",
"error.network_timeout": "This website is too slow and the request timed out: %v",
"error.http_client_error": "HTTP client error: %v.",
"error.http_not_authorized": "Access to this website is not authorized. It could be a bad username or password.",
"error.http_too_many_requests": "Miniflux generated too many requests to this website. Please, try again later or change the application configuration.",
"error.http_forbidden": "Access to this website is forbidden. Perhaps, this website has a bot protection mechanism?",
"error.http_resource_not_found": "The requested resource is not found. Please, verify the URL.",
"error.http_internal_server_error": "The website is not available at the moment due to a server error. The problem is not on Miniflux side. Please, try again later.",
"error.http_bad_gateway": "The website is not available at the moment due to a bad gateway error. The problem is not on Miniflux side. Please, try again later.",
"error.http_service_unavailable": "The website is not available at the moment due to an internal server error. The problem is not on Miniflux side. Please, try again later.",
"error.http_gateway_timeout": "The website is not available at the moment due to a gateway timeout error. The problem is not on Miniflux side. Please, try again later.",
"error.http_unexpected_status_code": "The website is not available at the moment due to an unexpected HTTP status code: %d. The problem is not on Miniflux side. Please, try again later.",
"error.database_error": "Database error: %v.",
"error.category_not_found": "This category does not exist or does not belong to this user.",
"error.duplicated_feed": "This feed already exists.",
"error.unable_to_parse_feed": "Unable to parse this feed: %v.",
"error.feed_not_found": "This feed does not exist or does not belong to this user.",
"error.unable_to_detect_rssbridge": "Unable to detect feed using RSS-Bridge: %v.",
"error.feed_format_not_detected": "Unable to detect feed format: %v.",
"form.prefs.label.media_playback_rate": "Prędkość odtwarzania audio/wideo",
"error.settings_media_playback_rate_range": "Prędkość odtwarzania jest poza zakresem",
"enclosure_media_controls.seek" : "Seek:",
"enclosure_media_controls.seek.title" : "Seek %s seconds",
"enclosure_media_controls.speed" : "Speed:",
"enclosure_media_controls.speed.faster" : "Faster",
"enclosure_media_controls.speed.faster.title" : "Faster by %sx",
"enclosure_media_controls.speed.slower" : "Slower",
"enclosure_media_controls.speed.slower.title" : "Slower by %sx",
"enclosure_media_controls.speed.reset" : "Reset",
"enclosure_media_controls.speed.reset.title" : "Reset speed to 1x"
"alert.background_feed_refresh": "Wszystkie kanały są odświeżane w tle. Możesz kontynuować korzystanie z Miniflux podczas trwania tego procesu.",
"error.http_response_too_large": "Odpowiedź HTTP jest za duża. Możesz zwiększyć limit rozmiaru odpowiedzi HTTP w ustawieniach globalnych (wymaga ponownego uruchomienia serwera).",
"error.http_body_read": "Nie można odczytać treści HTTP: %v.",
"error.http_empty_response_body": "Treść odpowiedzi HTTP jest pusta.",
"error.http_empty_response": "Odpowiedź HTTP jest pusta. Być może ta witryna korzysta z mechanizmu ochrony przed botami?",
"error.tls_error": "Błąd TLS: %q. Jeśli chcesz, możesz wyłączyć weryfikację TLS w ustawieniach kanału.",
"error.network_operation": "Miniflux nie może połączyć się z tą witryną z powodu błędu sieci: %v.",
"error.network_timeout": "Ta witryna internetowa jest zbyt wolna i upłynął limit czasu żądania: %v",
"error.http_client_error": "Błąd klienta HTTP: %v.",
"error.http_not_authorized": "Dostęp do tej witryny nie jest autoryzowany. Może to być błędna nazwa użytkownika lub hasło.",
"error.http_too_many_requests": "Miniflux wygenerował zbyt wiele żądań do tej witryny. Spróbuj ponownie później lub zmień konfigurację aplikacji.",
"error.http_forbidden": "Dostęp do tej strony jest zabroniony. Być może ta strona ma mechanizm zabezpieczający przed botami?",
"error.http_resource_not_found": "Nie znaleziono żądanego zasobu. Sprawdź adres URL.",
"error.http_internal_server_error": "Strona jest w tej chwili niedostępna z powodu błędu serwera. Problem nie leży po stronie Miniflux. Spróbuj ponownie później.",
"error.http_bad_gateway": "Strona jest w tej chwili niedostępna z powodu błędu nieprawidłowej bramy. Problem nie leży po stronie Miniflux. Spróbuj ponownie później.",
"error.http_service_unavailable": "Strona jest w tej chwili niedostępna z powodu wewnętrznego błędu serwera. Problem nie leży po stronie Miniflux. Spróbuj ponownie później.",
"error.http_gateway_timeout": "Strona internetowa jest w tej chwili niedostępna z powodu błędu przekroczenia limitu czasu bramy. Problem nie leży po stronie Miniflux. Spróbuj ponownie później.",
"error.http_unexpected_status_code": "Strona jest w tej chwili niedostępna z powodu nieoczekiwanego kodu stanu HTTP: %d. Problem nie leży po stronie Miniflux. Spróbuj ponownie później.",
"error.database_error": "Błąd bazy danych: %v.",
"error.category_not_found": "Ta kategoria nie istnieje lub nie należy do tego użytkownika.",
"error.duplicated_feed": "Ten kanał już istnieje.",
"error.unable_to_parse_feed": "Nie można przeanalizować tego kanału: %v.",
"error.feed_not_found": "Ten kanał nie istnieje lub nie należy do tego użytkownika.",
"error.unable_to_detect_rssbridge": "Nie można wykryć kanału za pomocą RSS-Bridge: %v.",
"error.feed_format_not_detected": "Nie można wykryć formatu kanału: %v.",
"form.prefs.label.media_playback_rate": "Szybkość odtwarzania audio i wideo",
"error.settings_media_playback_rate_range": "Szybkość odtwarzania jest poza zakresem",
"enclosure_media_controls.seek" : "Przewiń:",
"enclosure_media_controls.seek.title" : "Przewiń o %s sek.",
"enclosure_media_controls.speed" : "Szybkość:",
"enclosure_media_controls.speed.faster" : "Szybciej",
"enclosure_media_controls.speed.faster.title" : "Szybciej o %sx",
"enclosure_media_controls.speed.slower" : "Wolniej",
"enclosure_media_controls.speed.slower.title" : "Wolniej o %sx",
"enclosure_media_controls.speed.reset" : "Przywróć",
"enclosure_media_controls.speed.reset.title" : "Przywróć szybkość do 1x"
}
+7
View File
@@ -220,6 +220,7 @@
"page.settings.webauthn.last_seen_on": "Last Used",
"page.settings.webauthn.register": "Registrar senha",
"page.settings.webauthn.register.error": "Não foi possível registrar a senha",
"page.login.webauthn_login.help": "Please enter your username if you're using a security key. This is not required if you are using a Passkey (discoverable credentials).",
"page.settings.webauthn.delete": [
"Remover %d senha",
"Remover %d senhas"
@@ -355,6 +356,8 @@
"form.feed.label.ntfy_default_priority": "Ntfy default priority",
"form.feed.label.ntfy_low_priority": "Ntfy low priority",
"form.feed.label.ntfy_min_priority": "Ntfy min priority",
"form.integration.cubox_activate": "Save entries to Cubox",
"form.integration.cubox_api_link": "Cubox API link",
"form.feed.fieldset.general": "General",
"form.feed.fieldset.rules": "Rules",
"form.feed.fieldset.network_settings": "Network Settings",
@@ -391,6 +394,7 @@
"form.prefs.label.gesture_nav": "Gesto para navegar entre as entradas",
"form.prefs.label.show_reading_time": "Mostrar tempo estimado de leitura de artigos",
"form.prefs.label.custom_css": "CSS customizado",
"form.prefs.label.custom_js": "JavaScript customizado",
"form.prefs.label.entry_order": "Coluna de Ordenação de Entrada",
"form.prefs.label.default_home_page": "Página inicial predefinida",
"form.prefs.label.categories_sorting_order": "Classificação das categorias",
@@ -402,6 +406,9 @@
"form.prefs.fieldset.authentication_settings": "Authentication Settings",
"form.prefs.fieldset.reader_settings": "Reader Settings",
"form.prefs.fieldset.global_feed_settings": "Global Feed Settings",
"form.prefs.label.external_font_hosts": "External font hosts",
"form.prefs.help.external_font_hosts": "Space separated list of external font hosts to allow. For example: \"fonts.gstatic.com fonts.googleapis.com\".",
"error.settings_invalid_domain_list": "Invalid domain list. Please provide a space separated list of domains.",
"form.import.label.file": "Arquivo OPML",
"form.import.label.url": "URL",
"form.integration.betula_activate": "Save entries to Betula",
+7
View File
@@ -229,6 +229,7 @@
"page.settings.webauthn.last_seen_on": "Last Used",
"page.settings.webauthn.register": "Зарегистрировать пароль",
"page.settings.webauthn.register.error": "Не удается зарегистрировать пароль",
"page.login.webauthn_login.help": "Please enter your username if you're using a security key. This is not required if you are using a Passkey (discoverable credentials).",
"page.settings.webauthn.delete": [
"Удалить %d пароль",
"Удалить %d пароля",
@@ -365,6 +366,8 @@
"form.feed.label.ntfy_default_priority": "Ntfy default priority",
"form.feed.label.ntfy_low_priority": "Ntfy low priority",
"form.feed.label.ntfy_min_priority": "Ntfy min priority",
"form.integration.cubox_activate": "Save entries to Cubox",
"form.integration.cubox_api_link": "Cubox API link",
"form.feed.fieldset.general": "General",
"form.feed.fieldset.rules": "Rules",
"form.feed.fieldset.network_settings": "Network Settings",
@@ -401,6 +404,7 @@
"form.prefs.label.gesture_nav": "Жест для перехода между статьями",
"form.prefs.label.show_reading_time": "Показать примерное время чтения статей",
"form.prefs.label.custom_css": "Пользовательский CSS",
"form.prefs.label.custom_js": "Пользовательский JavaScript",
"form.prefs.label.entry_order": "Столбец сортировки статей",
"form.prefs.label.default_home_page": "Домашняя страница по умолчанию",
"form.prefs.label.categories_sorting_order": "Сортировка категорий",
@@ -412,6 +416,9 @@
"form.prefs.fieldset.authentication_settings": "Authentication Settings",
"form.prefs.fieldset.reader_settings": "Reader Settings",
"form.prefs.fieldset.global_feed_settings": "Global Feed Settings",
"form.prefs.label.external_font_hosts": "External font hosts",
"form.prefs.help.external_font_hosts": "Space separated list of external font hosts to allow. For example: \"fonts.gstatic.com fonts.googleapis.com\".",
"error.settings_invalid_domain_list": "Invalid domain list. Please provide a space separated list of domains.",
"form.import.label.file": "OPML файл",
"form.import.label.url": "Ссылка",
"form.integration.betula_activate": "Сохранять статьи в Бетулу",
+7
View File
@@ -286,6 +286,8 @@
"form.feed.label.ntfy_default_priority": "Ntfy default priority",
"form.feed.label.ntfy_low_priority": "Ntfy low priority",
"form.feed.label.ntfy_min_priority": "Ntfy min priority",
"form.integration.cubox_activate": "Save entries to Cubox",
"form.integration.cubox_api_link": "Cubox API link",
"form.prefs.fieldset.application_settings": "Uygulama Ayarları",
"form.prefs.fieldset.authentication_settings": "Kimlik Doğrulama Ayarları",
"form.prefs.fieldset.reader_settings": "Okuyucu Ayarları",
@@ -293,6 +295,7 @@
"form.prefs.label.categories_sorting_order": "Kategori sıralaması",
"form.prefs.label.cjk_reading_speed": "Çince, Korece ve Japonca için okuma hızı (dakika başına karakter)",
"form.prefs.label.custom_css": "Özel CSS",
"form.prefs.label.custom_js": "Özel JavaScript",
"form.prefs.label.default_home_page": "Varsayılan ana sayfa",
"form.prefs.label.default_reading_speed": "Diğer diller için okuma hızı (dakika başına kelime)",
"form.prefs.label.display_mode": "Progressive Web App (PWA) görüntüleme modu",
@@ -324,6 +327,9 @@
"form.prefs.select.swipe": "Kaydırma",
"form.prefs.select.tap": "Çift dokunma",
"form.prefs.select.unread_count": "Okunmamış sayısı",
"form.prefs.label.external_font_hosts": "External font hosts",
"form.prefs.help.external_font_hosts": "Space separated list of external font hosts to allow. For example: \"fonts.gstatic.com fonts.googleapis.com\".",
"error.settings_invalid_domain_list": "Invalid domain list. Please provide a space separated list of domains.",
"form.submit.loading": "Yükleniyor...",
"form.submit.saving": "Kaydediliyor...",
"form.user.label.admin": "Yönetici",
@@ -460,6 +466,7 @@
"page.login.title": "Oturum aç",
"page.login.webauthn_login": "Passkey ile giriş yap",
"page.login.webauthn_login.error": "Passkey ile giriş yapılamıyor",
"page.login.webauthn_login.help": "Please enter your username if you're using a security key. This is not required if you are using a Passkey (discoverable credentials).",
"page.new_api_key.title": "Yeni API Anahtarı",
"page.new_category.title": "Yeni Kategori",
"page.new_user.title": "Yeni Kullanıcı",
+7
View File
@@ -229,6 +229,7 @@
"page.settings.webauthn.last_seen_on": "Last Used",
"page.settings.webauthn.register": "Зареєструвати пароль",
"page.settings.webauthn.register.error": "Не вдалося зареєструвати ключ доступу",
"page.login.webauthn_login.help": "Please enter your username if you're using a security key. This is not required if you are using a Passkey (discoverable credentials).",
"page.settings.webauthn.delete": [
"Видалити %d ключ доступу",
"Видаліть %d ключа доступу",
@@ -365,6 +366,8 @@
"form.feed.label.ntfy_default_priority": "Ntfy default priority",
"form.feed.label.ntfy_low_priority": "Ntfy low priority",
"form.feed.label.ntfy_min_priority": "Ntfy min priority",
"form.integration.cubox_activate": "Save entries to Cubox",
"form.integration.cubox_api_link": "Cubox API link",
"form.category.label.title": "Назва",
"form.category.hide_globally": "Приховати записи в глобальному списку непрочитаного",
"form.feed.fieldset.general": "General",
@@ -401,6 +404,7 @@
"form.prefs.label.gesture_nav": "Жест для переходу між записами",
"form.prefs.label.show_reading_time": "Показувати приблизний час читання для записів",
"form.prefs.label.custom_css": "Спеціальний CSS",
"form.prefs.label.custom_js": "Спеціальний JavaScript",
"form.prefs.label.entry_order": "Стовпець сортування записів",
"form.prefs.label.default_home_page": "Домашня сторінка за умовчанням",
"form.prefs.label.categories_sorting_order": "Сортування за категоріями",
@@ -412,6 +416,9 @@
"form.prefs.fieldset.authentication_settings": "Authentication Settings",
"form.prefs.fieldset.reader_settings": "Reader Settings",
"form.prefs.fieldset.global_feed_settings": "Global Feed Settings",
"form.prefs.label.external_font_hosts": "External font hosts",
"form.prefs.help.external_font_hosts": "Space separated list of external font hosts to allow. For example: \"fonts.gstatic.com fonts.googleapis.com\".",
"error.settings_invalid_domain_list": "Invalid domain list. Please provide a space separated list of domains.",
"form.import.label.file": "Файл OPML",
"form.import.label.url": "URL-адреса",
"form.integration.betula_activate": "Save entries to Betula",
+7
View File
@@ -211,6 +211,7 @@
"page.settings.webauthn.last_seen_on": "最后使用时间",
"page.settings.webauthn.register": "注册 Passkey",
"page.settings.webauthn.register.error": "无法注册 Passkey",
"page.login.webauthn_login.help": "Please enter your username if you're using a security key. This is not required if you are using a Passkey (discoverable credentials).",
"page.settings.webauthn.delete": [
"删除 %d 个 Passkey"
],
@@ -345,6 +346,8 @@
"form.feed.label.ntfy_default_priority": "Ntfy默认优先级",
"form.feed.label.ntfy_low_priority": "Ntfy低优先级",
"form.feed.label.ntfy_min_priority": "Ntfy最低优先级",
"form.integration.cubox_activate": "保存文章到 Cubox",
"form.integration.cubox_api_link": "Cubox API 链接",
"form.feed.fieldset.general": "通用",
"form.feed.fieldset.rules": "规则",
"form.feed.fieldset.network_settings": "网络设置",
@@ -381,6 +384,7 @@
"form.prefs.label.gesture_nav": "在条目之间导航的手势",
"form.prefs.label.show_reading_time": "显示文章的预计阅读时间",
"form.prefs.label.custom_css": "自定义 CSS",
"form.prefs.label.custom_js": "自定义 JavaScript",
"form.prefs.label.entry_order": "文章排序依据",
"form.prefs.label.default_home_page": "默认主页",
"form.prefs.label.categories_sorting_order": "分类排序",
@@ -392,6 +396,9 @@
"form.prefs.fieldset.authentication_settings": "用户认证设置",
"form.prefs.fieldset.reader_settings": "阅读器设置",
"form.prefs.fieldset.global_feed_settings": "全局订阅源设置",
"form.prefs.label.external_font_hosts": "外部字体托管",
"form.prefs.help.external_font_hosts": "允许外部字体托管的空格分隔列表。例如:\"fonts.gstatic.com fonts.googleapis.com\"。",
"error.settings_invalid_domain_list": "域名列表无效。请提供一个用空格分隔的域名列表。",
"form.import.label.file": "OPML 文件",
"form.import.label.url": "URL",
"form.integration.betula_activate": "保存文章到 Betula",
+7
View File
@@ -211,6 +211,7 @@
"page.settings.webauthn.last_seen_on": "最後使用時間",
"page.settings.webauthn.register": "註冊 Passkey",
"page.settings.webauthn.register.error": "無法註冊 Passkey",
"page.login.webauthn_login.help": "Please enter your username if you're using a security key. This is not required if you are using a Passkey (discoverable credentials).",
"page.settings.webauthn.delete": [
"刪除 %d 個 Passkey"
],
@@ -345,6 +346,8 @@
"form.feed.label.ntfy_default_priority": "Ntfy default priority",
"form.feed.label.ntfy_low_priority": "Ntfy low priority",
"form.feed.label.ntfy_min_priority": "Ntfy min priority",
"form.integration.cubox_activate": "Save entries to Cubox",
"form.integration.cubox_api_link": "Cubox API link",
"form.feed.fieldset.general": "通用",
"form.feed.fieldset.rules": "規則",
"form.feed.fieldset.network_settings": "網路設定",
@@ -381,6 +384,7 @@
"form.prefs.label.gesture_nav": "在條目之間導航的手勢",
"form.prefs.label.show_reading_time": "顯示文章的預計閱讀時間",
"form.prefs.label.custom_css": "自定義 CSS",
"form.prefs.label.custom_js": "自定義 JavaScript",
"form.prefs.label.entry_order": "文章排序依據",
"form.prefs.label.default_home_page": "預設主頁",
"form.prefs.label.categories_sorting_order": "分類排序",
@@ -392,6 +396,9 @@
"form.prefs.fieldset.authentication_settings": "使用者認證設定",
"form.prefs.fieldset.reader_settings": "閱讀器設定",
"form.prefs.fieldset.global_feed_settings": "Global Feed Settings",
"form.prefs.label.external_font_hosts": "External font hosts",
"form.prefs.help.external_font_hosts": "Space separated list of external font hosts to allow. For example: \"fonts.gstatic.com fonts.googleapis.com\".",
"error.settings_invalid_domain_list": "Invalid domain list. Please provide a space separated list of domains.",
"form.import.label.file": "OPML 檔案",
"form.import.label.url": "URL",
"form.integration.betula_activate": "Save entries to Betula",
+26
View File
@@ -2,6 +2,7 @@
// SPDX-License-Identifier: Apache-2.0
package model // import "miniflux.app/v2/internal/model"
import (
"strings"
@@ -34,9 +35,34 @@ func (e Enclosure) Html5MimeType() string {
return e.MimeType
}
func (e *Enclosure) IsAudio() bool {
return strings.HasPrefix(strings.ToLower(e.MimeType), "audio/")
}
func (e *Enclosure) IsVideo() bool {
return strings.HasPrefix(strings.ToLower(e.MimeType), "video/")
}
func (e *Enclosure) IsImage() bool {
mimeType := strings.ToLower(e.MimeType)
mediaURL := strings.ToLower(e.URL)
return strings.HasPrefix(mimeType, "image/") || strings.HasSuffix(mediaURL, ".jpg") || strings.HasSuffix(mediaURL, ".jpeg") || strings.HasSuffix(mediaURL, ".png") || strings.HasSuffix(mediaURL, ".gif")
}
// EnclosureList represents a list of attachments.
type EnclosureList []*Enclosure
// FindMediaPlayerEnclosure returns the first enclosure that can be played by a media player.
func (el EnclosureList) FindMediaPlayerEnclosure() *Enclosure {
for _, enclosure := range el {
if enclosure.URL != "" && strings.Contains(enclosure.MimeType, "audio/") || strings.Contains(enclosure.MimeType, "video/") {
return enclosure
}
}
return nil
}
func (el EnclosureList) ContainsAudioOrVideo() bool {
for _, enclosure := range el {
if strings.Contains(enclosure.MimeType, "audio/") || strings.Contains(enclosure.MimeType, "video/") {
+13 -11
View File
@@ -56,11 +56,12 @@ type Feed struct {
NtfyEnabled bool `json:"ntfy_enabled"`
NtfyPriority int `json:"ntfy_priority"`
// Non persisted attributes
// Non-persisted attributes
Category *Category `json:"category,omitempty"`
Icon *FeedIcon `json:"icon"`
Entries Entries `json:"entries,omitempty"`
// Internal attributes (not exposed in the API and not persisted in the database)
TTL int `json:"-"`
IconURL string `json:"-"`
UnreadCount int `json:"-"`
@@ -111,12 +112,13 @@ func (f *Feed) CheckedNow() {
}
// ScheduleNextCheck set "next_check_at" of a feed based on the scheduler selected from the configuration.
func (f *Feed) ScheduleNextCheck(weeklyCount int, newTTL int) {
f.TTL = newTTL
func (f *Feed) ScheduleNextCheck(weeklyCount int, refreshDelayInMinutes int) {
f.TTL = refreshDelayInMinutes
// Default to the global config Polling Frequency.
var intervalMinutes int
switch config.Opts.PollingScheduler() {
case SchedulerEntryFrequency:
intervalMinutes := config.Opts.SchedulerRoundRobinMinInterval()
if config.Opts.PollingScheduler() == SchedulerEntryFrequency {
if weeklyCount <= 0 {
intervalMinutes = config.Opts.SchedulerEntryFrequencyMaxInterval()
} else {
@@ -124,13 +126,13 @@ func (f *Feed) ScheduleNextCheck(weeklyCount int, newTTL int) {
intervalMinutes = int(math.Min(float64(intervalMinutes), float64(config.Opts.SchedulerEntryFrequencyMaxInterval())))
intervalMinutes = int(math.Max(float64(intervalMinutes), float64(config.Opts.SchedulerEntryFrequencyMinInterval())))
}
default:
intervalMinutes = config.Opts.SchedulerRoundRobinMinInterval()
}
// If the feed has a TTL defined, we use it to make sure we don't check it too often.
if newTTL > intervalMinutes && newTTL > 0 {
intervalMinutes = newTTL
// If the feed has a TTL or a Retry-After defined, we use it to make sure we don't check it too often.
if refreshDelayInMinutes > 0 && refreshDelayInMinutes > intervalMinutes {
intervalMinutes = refreshDelayInMinutes
}
f.NextCheckAt = time.Now().Add(time.Minute * time.Duration(intervalMinutes))
}
+57 -13
View File
@@ -14,7 +14,7 @@ import (
const (
largeWeeklyCount = 10080
noNewTTL = 0
noRefreshDelay = 0
)
func TestFeedCategorySetter(t *testing.T) {
@@ -76,7 +76,7 @@ func checkTargetInterval(t *testing.T, feed *Feed, targetInterval int, timeBefor
}
}
func TestFeedScheduleNextCheckDefault(t *testing.T) {
func TestFeedScheduleNextCheckRoundRobinDefault(t *testing.T) {
os.Clearenv()
var err error
@@ -88,15 +88,60 @@ func TestFeedScheduleNextCheckDefault(t *testing.T) {
timeBefore := time.Now()
feed := &Feed{}
weeklyCount := 10
feed.ScheduleNextCheck(weeklyCount, noNewTTL)
feed.ScheduleNextCheck(0, noRefreshDelay)
if feed.NextCheckAt.IsZero() {
t.Error(`The next_check_at must be set`)
}
targetInterval := config.Opts.SchedulerRoundRobinMinInterval()
checkTargetInterval(t, feed, targetInterval, timeBefore, "default SchedulerRoundRobinMinInterval")
checkTargetInterval(t, feed, targetInterval, timeBefore, "TestFeedScheduleNextCheckRoundRobinDefault")
}
func TestFeedScheduleNextCheckRoundRobinWithRefreshDelayAboveMinInterval(t *testing.T) {
os.Clearenv()
var err error
parser := config.NewParser()
config.Opts, err = parser.ParseEnvironmentVariables()
if err != nil {
t.Fatalf(`Parsing failure: %v`, err)
}
timeBefore := time.Now()
feed := &Feed{}
feed.ScheduleNextCheck(0, config.Opts.SchedulerRoundRobinMinInterval()+30)
if feed.NextCheckAt.IsZero() {
t.Error(`The next_check_at must be set`)
}
expectedInterval := config.Opts.SchedulerRoundRobinMinInterval() + 30
checkTargetInterval(t, feed, expectedInterval, timeBefore, "TestFeedScheduleNextCheckRoundRobinWithRefreshDelayAboveMinInterval")
}
func TestFeedScheduleNextCheckRoundRobinWithRefreshDelayBelowMinInterval(t *testing.T) {
os.Clearenv()
var err error
parser := config.NewParser()
config.Opts, err = parser.ParseEnvironmentVariables()
if err != nil {
t.Fatalf(`Parsing failure: %v`, err)
}
timeBefore := time.Now()
feed := &Feed{}
feed.ScheduleNextCheck(0, config.Opts.SchedulerRoundRobinMinInterval()-30)
if feed.NextCheckAt.IsZero() {
t.Error(`The next_check_at must be set`)
}
expectedInterval := config.Opts.SchedulerRoundRobinMinInterval()
checkTargetInterval(t, feed, expectedInterval, timeBefore, "TestFeedScheduleNextCheckRoundRobinWithRefreshDelayBelowMinInterval")
}
func TestFeedScheduleNextCheckRoundRobinMinInterval(t *testing.T) {
@@ -114,15 +159,14 @@ func TestFeedScheduleNextCheckRoundRobinMinInterval(t *testing.T) {
timeBefore := time.Now()
feed := &Feed{}
weeklyCount := 100
feed.ScheduleNextCheck(weeklyCount, noNewTTL)
feed.ScheduleNextCheck(0, noRefreshDelay)
if feed.NextCheckAt.IsZero() {
t.Error(`The next_check_at must be set`)
}
targetInterval := minInterval
checkTargetInterval(t, feed, targetInterval, timeBefore, "round robin min interval")
expectedInterval := minInterval
checkTargetInterval(t, feed, expectedInterval, timeBefore, "TestFeedScheduleNextCheckRoundRobinMinInterval")
}
func TestFeedScheduleNextCheckEntryFrequencyMaxInterval(t *testing.T) {
@@ -144,7 +188,7 @@ func TestFeedScheduleNextCheckEntryFrequencyMaxInterval(t *testing.T) {
feed := &Feed{}
// Use a very small weekly count to trigger the max interval
weeklyCount := 1
feed.ScheduleNextCheck(weeklyCount, noNewTTL)
feed.ScheduleNextCheck(weeklyCount, noRefreshDelay)
if feed.NextCheckAt.IsZero() {
t.Error(`The next_check_at must be set`)
@@ -173,7 +217,7 @@ func TestFeedScheduleNextCheckEntryFrequencyMaxIntervalZeroWeeklyCount(t *testin
feed := &Feed{}
// Use a very small weekly count to trigger the max interval
weeklyCount := 0
feed.ScheduleNextCheck(weeklyCount, noNewTTL)
feed.ScheduleNextCheck(weeklyCount, noRefreshDelay)
if feed.NextCheckAt.IsZero() {
t.Error(`The next_check_at must be set`)
@@ -202,7 +246,7 @@ func TestFeedScheduleNextCheckEntryFrequencyMinInterval(t *testing.T) {
feed := &Feed{}
// Use a very large weekly count to trigger the min interval
weeklyCount := largeWeeklyCount
feed.ScheduleNextCheck(weeklyCount, noNewTTL)
feed.ScheduleNextCheck(weeklyCount, noRefreshDelay)
if feed.NextCheckAt.IsZero() {
t.Error(`The next_check_at must be set`)
@@ -228,7 +272,7 @@ func TestFeedScheduleNextCheckEntryFrequencyFactor(t *testing.T) {
timeBefore := time.Now()
feed := &Feed{}
weeklyCount := 7
feed.ScheduleNextCheck(weeklyCount, noNewTTL)
feed.ScheduleNextCheck(weeklyCount, noRefreshDelay)
if feed.NextCheckAt.IsZero() {
t.Error(`The next_check_at must be set`)
+2
View File
@@ -104,4 +104,6 @@ type Integration struct {
NtfyUsername string
NtfyPassword string
NtfyIconURL string
CuboxEnabled bool
CuboxAPILink string
}
+12
View File
@@ -21,6 +21,8 @@ type User struct {
EntryDirection string `json:"entry_sorting_direction"`
EntryOrder string `json:"entry_sorting_order"`
Stylesheet string `json:"stylesheet"`
CustomJS string `json:"custom_js"`
ExternalFontHosts string `json:"external_font_hosts"`
GoogleID string `json:"google_id"`
OpenIDConnectID string `json:"openid_connect_id"`
EntriesPerPage int `json:"entries_per_page"`
@@ -60,6 +62,8 @@ type UserModificationRequest struct {
EntryDirection *string `json:"entry_sorting_direction"`
EntryOrder *string `json:"entry_sorting_order"`
Stylesheet *string `json:"stylesheet"`
CustomJS *string `json:"custom_js"`
ExternalFontHosts *string `json:"external_font_hosts"`
GoogleID *string `json:"google_id"`
OpenIDConnectID *string `json:"openid_connect_id"`
EntriesPerPage *int `json:"entries_per_page"`
@@ -118,6 +122,14 @@ func (u *UserModificationRequest) Patch(user *User) {
user.Stylesheet = *u.Stylesheet
}
if u.CustomJS != nil {
user.CustomJS = *u.CustomJS
}
if u.ExternalFontHosts != nil {
user.ExternalFontHosts = *u.ExternalFontHosts
}
if u.GoogleID != nil {
user.GoogleID = *u.GoogleID
}
@@ -13,7 +13,9 @@ import (
"net/http"
"net/url"
"os"
"strconv"
"strings"
"time"
"miniflux.app/v2/internal/locale"
)
@@ -51,6 +53,26 @@ func (r *ResponseHandler) ETag() string {
return r.httpResponse.Header.Get("ETag")
}
func (r *ResponseHandler) ParseRetryDelay() int {
retryAfterHeaderValue := r.httpResponse.Header.Get("Retry-After")
if retryAfterHeaderValue != "" {
// First, try to parse as an integer (number of seconds)
if seconds, err := strconv.Atoi(retryAfterHeaderValue); err == nil {
return seconds
}
// If not an integer, try to parse as an HTTP-date
if t, err := time.Parse(time.RFC1123, retryAfterHeaderValue); err == nil {
return int(time.Until(t).Seconds())
}
}
return 0
}
func (r *ResponseHandler) IsRateLimited() bool {
return r.httpResponse != nil && r.httpResponse.StatusCode == http.StatusTooManyRequests
}
func (r *ResponseHandler) IsModified(lastEtagValue, lastModifiedValue string) bool {
if r.httpResponse.StatusCode == http.StatusNotModified {
return false
@@ -6,6 +6,7 @@ package fetcher // import "miniflux.app/v2/internal/reader/fetcher"
import (
"net/http"
"testing"
"time"
)
func TestIsModified(t *testing.T) {
@@ -30,7 +31,7 @@ func TestIsModified(t *testing.T) {
ETag: cachedEtag,
IsModified: false,
},
// This case is invalid per RFC9110 8.8.1, so ETag takes precedence.
// ETag takes precedence per RFC9110 8.8.1.
"Last-Modified changed only": {
Status: 200,
LastModified: "Thu, 22 Oct 2015 07:28:00 GMT",
@@ -67,3 +68,37 @@ func TestIsModified(t *testing.T) {
})
}
}
func TestRetryDelay(t *testing.T) {
var testCases = map[string]struct {
RetryAfterHeader string
ExpectedDelay int
}{
"Empty header": {
RetryAfterHeader: "",
ExpectedDelay: 0,
},
"Integer value": {
RetryAfterHeader: "42",
ExpectedDelay: 42,
},
"HTTP-date": {
RetryAfterHeader: time.Now().Add(42 * time.Second).Format(time.RFC1123),
ExpectedDelay: 41,
},
}
for name, tc := range testCases {
t.Run(name, func(tt *testing.T) {
header := http.Header{}
header.Add("Retry-After", tc.RetryAfterHeader)
rh := ResponseHandler{
httpResponse: &http.Response{
Header: header,
},
}
if tc.ExpectedDelay != rh.ParseRetryDelay() {
tt.Errorf("Expected %d, got %d for scenario %q", tc.ExpectedDelay, rh.ParseRetryDelay(), name)
}
})
}
}
+37 -57
View File
@@ -93,13 +93,7 @@ func CreateFeedFromSubscriptionDiscovery(store *storage.Storage, userID int64, f
requestBuilder.IgnoreTLSErrors(feedCreationRequest.AllowSelfSignedCertificates)
requestBuilder.DisableHTTP2(feedCreationRequest.DisableHTTP2)
checkFeedIcon(
store,
requestBuilder,
subscription.ID,
subscription.SiteURL,
subscription.IconURL,
)
icon.NewIconChecker(store, subscription).UpdateOrCreateFeedIcon()
return subscription, nil
}
@@ -188,13 +182,8 @@ func CreateFeed(store *storage.Storage, userID int64, feedCreationRequest *model
slog.String("feed_url", subscription.FeedURL),
)
checkFeedIcon(
store,
requestBuilder,
subscription.ID,
subscription.SiteURL,
subscription.IconURL,
)
icon.NewIconChecker(store, subscription).UpdateOrCreateFeedIcon()
return subscription, nil
}
@@ -221,7 +210,7 @@ func RefreshFeed(store *storage.Storage, userID, feedID int64, forceRefresh bool
}
weeklyEntryCount := 0
newTTL := 0
refreshDelayInMinutes := 0
if config.Opts.PollingScheduler() == model.SchedulerEntryFrequency {
var weeklyCountErr error
weeklyEntryCount, weeklyCountErr = store.WeeklyFeedEntryCount(userID, feedID)
@@ -231,7 +220,7 @@ func RefreshFeed(store *storage.Storage, userID, feedID int64, forceRefresh bool
}
originalFeed.CheckedNow()
originalFeed.ScheduleNextCheck(weeklyEntryCount, newTTL)
originalFeed.ScheduleNextCheck(weeklyEntryCount, refreshDelayInMinutes)
requestBuilder := fetcher.NewRequestBuilder()
requestBuilder.WithUsernameAndPassword(originalFeed.Username, originalFeed.Password)
@@ -252,6 +241,19 @@ func RefreshFeed(store *storage.Storage, userID, feedID int64, forceRefresh bool
responseHandler := fetcher.NewResponseHandler(requestBuilder.ExecuteRequest(originalFeed.FeedURL))
defer responseHandler.Close()
if responseHandler.IsRateLimited() {
retryDelayInSeconds := responseHandler.ParseRetryDelay()
refreshDelayInMinutes = retryDelayInSeconds / 60
originalFeed.ScheduleNextCheck(weeklyEntryCount, refreshDelayInMinutes)
slog.Warn("Feed is rate limited",
slog.String("feed_url", originalFeed.FeedURL),
slog.Int("retry_delay_in_seconds", retryDelayInSeconds),
slog.Int("refresh_delay_in_minutes", refreshDelayInMinutes),
slog.Time("new_next_check_at", originalFeed.NextCheckAt),
)
}
if localizedError := responseHandler.LocalizedError(); localizedError != nil {
slog.Warn("Unable to fetch feed", slog.String("feed_url", originalFeed.FeedURL), slog.Any("error", localizedError.Error()))
originalFeed.WithTranslatedErrorMessage(localizedError.Translate(user.Language))
@@ -270,6 +272,8 @@ func RefreshFeed(store *storage.Storage, userID, feedID int64, forceRefresh bool
slog.Debug("Feed modified",
slog.Int64("user_id", userID),
slog.Int64("feed_id", feedID),
slog.String("etag_header", originalFeed.EtagHeader),
slog.String("last_modified_header", originalFeed.LastModifiedHeader),
)
responseBody, localizedError := responseHandler.ReadBody(config.Opts.HTTPClientMaxBodySize())
@@ -292,13 +296,15 @@ func RefreshFeed(store *storage.Storage, userID, feedID int64, forceRefresh bool
}
// If the feed has a TTL defined, we use it to make sure we don't check it too often.
newTTL = updatedFeed.TTL
refreshDelayInMinutes = updatedFeed.TTL
// Set the next check at with updated arguments.
originalFeed.ScheduleNextCheck(weeklyEntryCount, newTTL)
originalFeed.ScheduleNextCheck(weeklyEntryCount, refreshDelayInMinutes)
slog.Debug("Updated next check date",
slog.Int64("user_id", userID),
slog.Int64("feed_id", feedID),
slog.Int("ttl", newTTL),
slog.Int("refresh_delay_in_minutes", refreshDelayInMinutes),
slog.Time("new_next_check_at", originalFeed.NextCheckAt),
)
@@ -326,23 +332,26 @@ func RefreshFeed(store *storage.Storage, userID, feedID int64, forceRefresh bool
go integration.PushEntries(originalFeed, newEntries, userIntegrations)
}
// We update caching headers only if the feed has been modified,
// because some websites don't return the same headers when replying with a 304.
originalFeed.EtagHeader = responseHandler.ETag()
originalFeed.LastModifiedHeader = responseHandler.LastModified()
checkFeedIcon(
store,
requestBuilder,
originalFeed.ID,
originalFeed.SiteURL,
updatedFeed.IconURL,
)
iconChecker := icon.NewIconChecker(store, originalFeed)
if forceRefresh {
iconChecker.UpdateOrCreateFeedIcon()
} else {
iconChecker.CreateFeedIconIfMissing()
}
} else {
slog.Debug("Feed not modified",
slog.Int64("user_id", userID),
slog.Int64("feed_id", feedID),
)
// Last-Modified may be updated even if ETag is not. In this case, per
// RFC9111 sections 3.2 and 4.3.4, the stored response must be updated.
if responseHandler.LastModified() != "" {
originalFeed.LastModifiedHeader = responseHandler.LastModified()
}
}
originalFeed.ResetErrorCounter()
@@ -356,32 +365,3 @@ func RefreshFeed(store *storage.Storage, userID, feedID int64, forceRefresh bool
return nil
}
func checkFeedIcon(store *storage.Storage, requestBuilder *fetcher.RequestBuilder, feedID int64, websiteURL, feedIconURL string) {
if !store.HasIcon(feedID) {
iconFinder := icon.NewIconFinder(requestBuilder, websiteURL, feedIconURL)
if icon, err := iconFinder.FindIcon(); err != nil {
slog.Debug("Unable to find feed icon",
slog.Int64("feed_id", feedID),
slog.String("website_url", websiteURL),
slog.String("feed_icon_url", feedIconURL),
slog.Any("error", err),
)
} else if icon == nil {
slog.Debug("No icon found",
slog.Int64("feed_id", feedID),
slog.String("website_url", websiteURL),
slog.String("feed_icon_url", feedIconURL),
)
} else {
if err := store.CreateFeedIcon(feedID, icon); err != nil {
slog.Error("Unable to store feed icon",
slog.Int64("feed_id", feedID),
slog.String("website_url", websiteURL),
slog.String("feed_icon_url", feedIconURL),
slog.Any("error", err),
)
}
}
}
}
+84
View File
@@ -0,0 +1,84 @@
// SPDX-FileCopyrightText: Copyright The Miniflux Authors. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package icon // import "miniflux.app/v2/internal/reader/icon"
import (
"log/slog"
"miniflux.app/v2/internal/config"
"miniflux.app/v2/internal/model"
"miniflux.app/v2/internal/reader/fetcher"
"miniflux.app/v2/internal/storage"
)
type IconChecker struct {
store *storage.Storage
feed *model.Feed
}
func NewIconChecker(store *storage.Storage, feed *model.Feed) *IconChecker {
return &IconChecker{
store: store,
feed: feed,
}
}
func (c *IconChecker) fetchAndStoreIcon() {
requestBuilder := fetcher.NewRequestBuilder()
requestBuilder.WithUserAgent(c.feed.UserAgent, config.Opts.HTTPClientUserAgent())
requestBuilder.WithCookie(c.feed.Cookie)
requestBuilder.WithTimeout(config.Opts.HTTPClientTimeout())
requestBuilder.WithProxy(config.Opts.HTTPClientProxy())
requestBuilder.UseProxy(c.feed.FetchViaProxy)
requestBuilder.IgnoreTLSErrors(c.feed.AllowSelfSignedCertificates)
requestBuilder.DisableHTTP2(c.feed.DisableHTTP2)
iconFinder := NewIconFinder(requestBuilder, c.feed.FeedURL, c.feed.IconURL)
if icon, err := iconFinder.FindIcon(); err != nil {
slog.Debug("Unable to find feed icon",
slog.Int64("feed_id", c.feed.ID),
slog.String("website_url", c.feed.FeedURL),
slog.String("feed_icon_url", c.feed.IconURL),
slog.Any("error", err),
)
} else if icon == nil {
slog.Debug("No icon found",
slog.Int64("feed_id", c.feed.ID),
slog.String("website_url", c.feed.FeedURL),
slog.String("feed_icon_url", c.feed.IconURL),
)
} else {
if err := c.store.StoreFeedIcon(c.feed.ID, icon); err != nil {
slog.Error("Unable to store feed icon",
slog.Int64("feed_id", c.feed.ID),
slog.String("website_url", c.feed.FeedURL),
slog.String("feed_icon_url", c.feed.IconURL),
slog.Any("error", err),
)
} else {
slog.Debug("Feed icon stored",
slog.Int64("feed_id", c.feed.ID),
slog.String("website_url", c.feed.FeedURL),
slog.String("feed_icon_url", c.feed.IconURL),
slog.Int64("icon_id", icon.ID),
slog.String("icon_hash", icon.Hash),
)
}
}
}
func (c *IconChecker) CreateFeedIconIfMissing() {
if c.store.HasFeedIcon(c.feed.ID) {
slog.Debug("Feed icon already exists",
slog.Int64("feed_id", c.feed.ID),
)
return
}
c.fetchAndStoreIcon()
}
func (c *IconChecker) UpdateOrCreateFeedIcon() {
c.fetchAndStoreIcon()
}
+1
View File
@@ -53,6 +53,7 @@ func ProcessFeedEntries(store *storage.Storage, feed *model.Feed, user *model.Us
pageBaseURL := ""
rewrittenURL := rewriteEntryURL(feed, entry)
entry.URL = rewrittenURL
entryIsNew := store.IsNewEntry(feed.ID, entry.Hash)
if feed.Crawler && (entryIsNew || forceRefresh) {
slog.Debug("Scraping entry",
+41
View File
@@ -3,6 +3,8 @@
package rewrite // import "miniflux.app/v2/internal/reader/rewrite"
import "regexp"
// List of predefined rewrite rules (alphabetically sorted)
// Available rules: "add_image_title", "add_youtube_video"
// domain => rule name
@@ -38,3 +40,42 @@ var predefinedRules = map[string]string{
"xkcd.com": "add_image_title",
"youtube.com": "add_youtube_video",
}
type RefererRule struct {
URLPattern *regexp.Regexp
Referer string
}
// List of predefined referer rules
var PredefinedRefererRules = []RefererRule{
{
URLPattern: regexp.MustCompile(`^https://\w+\.sinaimg\.cn`),
Referer: "https://weibo.com",
},
{
URLPattern: regexp.MustCompile(`^https://i\.pximg\.net`),
Referer: "https://www.pixiv.net",
},
{
URLPattern: regexp.MustCompile(`^https://cdnfile\.sspai\.com`),
Referer: "https://sspai.com",
},
{
URLPattern: regexp.MustCompile(`^https://(?:\w|-)+\.cdninstagram\.com`),
Referer: "https://www.instagram.com",
},
{
URLPattern: regexp.MustCompile(`^https://sp1\.piokok\.com`),
Referer: "https://sp1.piokok.com",
},
}
// GetRefererForURL returns the referer for the given URL if it exists, otherwise an empty string.
func GetRefererForURL(url string) string {
for _, rule := range PredefinedRefererRules {
if rule.URLPattern.MatchString(url) {
return rule.Referer
}
}
return ""
}
+5 -2
View File
@@ -101,10 +101,13 @@ func (r *RSSAdapter) BuildFeed(baseURL string) *model.Feed {
}
// Generate the entry hash.
if item.GUID.Data != "" {
switch {
case item.GUID.Data != "":
entry.Hash = crypto.Hash(item.GUID.Data)
} else if entryURL != "" {
case entryURL != "":
entry.Hash = crypto.Hash(entryURL)
default:
entry.Hash = crypto.Hash(entry.Title + entry.Content)
}
// Find CommentsURL if defined.
+33
View File
@@ -336,6 +336,39 @@ func TestParseEntryWithoutLink(t *testing.T) {
}
}
func TestParseEntryWithoutLinkAndWithoutGUID(t *testing.T) {
data := `<?xml version="1.0" encoding="utf-8"?>
<rss version="2.0">
<channel>
<link>https://example.org/</link>
<item>
<title>Item 1</title>
</item>
<item>
<title>Item 2</title>
<pubDate>Wed, 02 Oct 2002 08:00:00 GMT</pubDate>
</item>
</channel>
</rss>`
feed, err := Parse("https://example.org/", bytes.NewReader([]byte(data)))
if err != nil {
t.Fatal(err)
}
if len(feed.Entries) != 2 {
t.Errorf("Incorrect number of entries, got: %d", len(feed.Entries))
}
if feed.Entries[0].Hash != "c5ddfeffb275254140796b8c080f372d65ebb1b0590e238b191f595d5fcd32ca" {
t.Errorf("Incorrect entry hash, got: %s", feed.Entries[0].Hash)
}
if feed.Entries[1].Hash != "0a937478f9bdbfca2de5cdeeb5ee7b09678a3330fc7cc5b05169a50d4516c9a3" {
t.Errorf("Incorrect entry hash, got: %s", feed.Entries[1].Hash)
}
}
func TestParseEntryWithOnlyGuidPermalink(t *testing.T) {
data := `<?xml version="1.0" encoding="utf-8"?>
<rss version="2.0">
+38 -47
View File
@@ -11,8 +11,8 @@ import (
"miniflux.app/v2/internal/model"
)
// HasIcon checks if the given feed has an icon.
func (s *Storage) HasIcon(feedID int64) bool {
// HasFeedIcon checks if the given feed has an icon.
func (s *Storage) HasFeedIcon(feedID int64) bool {
var result bool
query := `SELECT true FROM feed_icons WHERE feed_id=$1`
s.db.QueryRow(query, feedID).Scan(&result)
@@ -57,59 +57,50 @@ func (s *Storage) IconByFeedID(userID, feedID int64) (*model.Icon, error) {
return &icon, nil
}
// IconByHash returns an icon by the hash (checksum).
func (s *Storage) IconByHash(icon *model.Icon) error {
err := s.db.QueryRow(`SELECT id FROM icons WHERE hash=$1`, icon.Hash).Scan(&icon.ID)
if err == sql.ErrNoRows {
return nil
// StoreFeedIcon creates or updates a feed icon.
func (s *Storage) StoreFeedIcon(feedID int64, icon *model.Icon) error {
tx, err := s.db.Begin()
if err != nil {
return fmt.Errorf(`store: unable to start transaction: %v`, err)
}
if err := tx.QueryRow(`SELECT id FROM icons WHERE hash=$1`, icon.Hash).Scan(&icon.ID); err == sql.ErrNoRows {
query := `
INSERT INTO icons
(hash, mime_type, content)
VALUES
($1, $2, $3)
RETURNING
id
`
err := tx.QueryRow(
query,
icon.Hash,
normalizeMimeType(icon.MimeType),
icon.Content,
).Scan(&icon.ID)
if err != nil {
tx.Rollback()
return fmt.Errorf(`store: unable to create icon: %v`, err)
}
} else if err != nil {
tx.Rollback()
return fmt.Errorf(`store: unable to fetch icon by hash %q: %v`, icon.Hash, err)
}
return nil
}
// CreateIcon creates a new icon.
func (s *Storage) CreateIcon(icon *model.Icon) error {
query := `
INSERT INTO icons
(hash, mime_type, content)
VALUES
($1, $2, $3)
RETURNING
id
`
err := s.db.QueryRow(
query,
icon.Hash,
normalizeMimeType(icon.MimeType),
icon.Content,
).Scan(&icon.ID)
if err != nil {
return fmt.Errorf(`store: unable to create icon: %v`, err)
if _, err := tx.Exec(`DELETE FROM feed_icons WHERE feed_id=$1`, feedID); err != nil {
tx.Rollback()
return fmt.Errorf(`store: unable to delete feed icon: %v`, err)
}
return nil
}
// CreateFeedIcon creates an icon and associate the icon to the given feed.
func (s *Storage) CreateFeedIcon(feedID int64, icon *model.Icon) error {
err := s.IconByHash(icon)
if err != nil {
return err
if _, err := tx.Exec(`INSERT INTO feed_icons (feed_id, icon_id) VALUES ($1, $2)`, feedID, icon.ID); err != nil {
tx.Rollback()
return fmt.Errorf(`store: unable to associate feed and icon: %v`, err)
}
if icon.ID == 0 {
err := s.CreateIcon(icon)
if err != nil {
return err
}
}
_, err = s.db.Exec(`INSERT INTO feed_icons (feed_id, icon_id) VALUES ($1, $2)`, feedID, icon.ID)
if err != nil {
return fmt.Errorf(`store: unable to create feed icon: %v`, err)
if err := tx.Commit(); err != nil {
return fmt.Errorf(`store: unable to commit transaction: %v`, err)
}
return nil
+13 -4
View File
@@ -207,7 +207,9 @@ func (s *Storage) Integration(userID int64) (*model.Integration, error) {
ntfy_api_token,
ntfy_username,
ntfy_password,
ntfy_icon_url
ntfy_icon_url,
cubox_enabled,
cubox_api_link
FROM
integrations
WHERE
@@ -314,6 +316,8 @@ func (s *Storage) Integration(userID int64) (*model.Integration, error) {
&integration.NtfyUsername,
&integration.NtfyPassword,
&integration.NtfyIconURL,
&integration.CuboxEnabled,
&integration.CuboxAPILink,
)
switch {
case err == sql.ErrNoRows:
@@ -428,9 +432,11 @@ func (s *Storage) UpdateIntegration(integration *model.Integration) error {
ntfy_api_token=$95,
ntfy_username=$96,
ntfy_password=$97,
ntfy_icon_url=$98
ntfy_icon_url=$98,
cubox_enabled=$99,
cubox_api_link=$100
WHERE
user_id=$99
user_id=$101
`
_, err := s.db.Exec(
query,
@@ -532,6 +538,8 @@ func (s *Storage) UpdateIntegration(integration *model.Integration) error {
integration.NtfyUsername,
integration.NtfyPassword,
integration.NtfyIconURL,
integration.CuboxEnabled,
integration.CuboxAPILink,
integration.UserID,
)
@@ -571,7 +579,8 @@ func (s *Storage) HasSaveEntry(userID int64) (result bool) {
webhook_enabled='t' OR
omnivore_enabled='t' OR
raindrop_enabled='t' OR
betula_enabled='t'
betula_enabled='t' OR
cubox_enabled='t'
)
`
if err := s.db.QueryRow(query, userID).Scan(&result); err != nil {
+56 -28
View File
@@ -83,6 +83,8 @@ func (s *Storage) CreateUser(userCreationRequest *model.UserCreationRequest) (*m
entry_swipe,
gesture_nav,
stylesheet,
custom_js,
external_font_hosts,
google_id,
openid_connect_id,
display_mode,
@@ -124,6 +126,8 @@ func (s *Storage) CreateUser(userCreationRequest *model.UserCreationRequest) (*m
&user.EntrySwipe,
&user.GestureNav,
&user.Stylesheet,
&user.CustomJS,
&user.ExternalFontHosts,
&user.GoogleID,
&user.OpenIDConnectID,
&user.DisplayMode,
@@ -163,6 +167,8 @@ func (s *Storage) CreateUser(userCreationRequest *model.UserCreationRequest) (*m
// UpdateUser updates a user.
func (s *Storage) UpdateUser(user *model.User) error {
user.ExternalFontHosts = strings.TrimSpace(user.ExternalFontHosts)
if user.Password != "" {
hashedPassword, err := crypto.HashPassword(user.Password)
if err != nil {
@@ -184,21 +190,23 @@ func (s *Storage) UpdateUser(user *model.User) error {
entry_swipe=$11,
gesture_nav=$12,
stylesheet=$13,
google_id=$14,
openid_connect_id=$15,
display_mode=$16,
entry_order=$17,
default_reading_speed=$18,
cjk_reading_speed=$19,
default_home_page=$20,
categories_sorting_order=$21,
mark_read_on_view=$22,
mark_read_on_media_player_completion=$23,
media_playback_rate=$24,
block_filter_entry_rules=$25,
keep_filter_entry_rules=$26
custom_js=$14,
external_font_hosts=$15,
google_id=$16,
openid_connect_id=$17,
display_mode=$18,
entry_order=$19,
default_reading_speed=$20,
cjk_reading_speed=$21,
default_home_page=$22,
categories_sorting_order=$23,
mark_read_on_view=$24,
mark_read_on_media_player_completion=$25,
media_playback_rate=$26,
block_filter_entry_rules=$27,
keep_filter_entry_rules=$28
WHERE
id=$27
id=$29
`
_, err = s.db.Exec(
@@ -216,6 +224,8 @@ func (s *Storage) UpdateUser(user *model.User) error {
user.EntrySwipe,
user.GestureNav,
user.Stylesheet,
user.CustomJS,
user.ExternalFontHosts,
user.GoogleID,
user.OpenIDConnectID,
user.DisplayMode,
@@ -249,21 +259,23 @@ func (s *Storage) UpdateUser(user *model.User) error {
entry_swipe=$10,
gesture_nav=$11,
stylesheet=$12,
google_id=$13,
openid_connect_id=$14,
display_mode=$15,
entry_order=$16,
default_reading_speed=$17,
cjk_reading_speed=$18,
default_home_page=$19,
categories_sorting_order=$20,
mark_read_on_view=$21,
mark_read_on_media_player_completion=$22,
media_playback_rate=$23,
block_filter_entry_rules=$24,
keep_filter_entry_rules=$25
custom_js=$13,
external_font_hosts=$14,
google_id=$15,
openid_connect_id=$16,
display_mode=$17,
entry_order=$18,
default_reading_speed=$19,
cjk_reading_speed=$20,
default_home_page=$21,
categories_sorting_order=$22,
mark_read_on_view=$23,
mark_read_on_media_player_completion=$24,
media_playback_rate=$25,
block_filter_entry_rules=$26,
keep_filter_entry_rules=$27
WHERE
id=$26
id=$28
`
_, err := s.db.Exec(
@@ -280,6 +292,8 @@ func (s *Storage) UpdateUser(user *model.User) error {
user.EntrySwipe,
user.GestureNav,
user.Stylesheet,
user.CustomJS,
user.ExternalFontHosts,
user.GoogleID,
user.OpenIDConnectID,
user.DisplayMode,
@@ -332,6 +346,8 @@ func (s *Storage) UserByID(userID int64) (*model.User, error) {
gesture_nav,
last_login_at,
stylesheet,
custom_js,
external_font_hosts,
google_id,
openid_connect_id,
display_mode,
@@ -371,6 +387,8 @@ func (s *Storage) UserByUsername(username string) (*model.User, error) {
gesture_nav,
last_login_at,
stylesheet,
custom_js,
external_font_hosts,
google_id,
openid_connect_id,
display_mode,
@@ -410,6 +428,8 @@ func (s *Storage) UserByField(field, value string) (*model.User, error) {
gesture_nav,
last_login_at,
stylesheet,
custom_js,
external_font_hosts,
google_id,
openid_connect_id,
display_mode,
@@ -456,6 +476,8 @@ func (s *Storage) UserByAPIKey(token string) (*model.User, error) {
u.gesture_nav,
u.last_login_at,
u.stylesheet,
u.custom_js,
u.external_font_hosts,
u.google_id,
u.openid_connect_id,
u.display_mode,
@@ -496,6 +518,8 @@ func (s *Storage) fetchUser(query string, args ...interface{}) (*model.User, err
&user.GestureNav,
&user.LastLoginAt,
&user.Stylesheet,
&user.CustomJS,
&user.ExternalFontHosts,
&user.GoogleID,
&user.OpenIDConnectID,
&user.DisplayMode,
@@ -608,6 +632,8 @@ func (s *Storage) Users() (model.Users, error) {
gesture_nav,
last_login_at,
stylesheet,
custom_js,
external_font_hosts,
google_id,
openid_connect_id,
display_mode,
@@ -649,6 +675,8 @@ func (s *Storage) Users() (model.Users, error) {
&user.GestureNav,
&user.LastLoginAt,
&user.Stylesheet,
&user.CustomJS,
&user.ExternalFontHosts,
&user.GoogleID,
&user.OpenIDConnectID,
&user.DisplayMode,
+3
View File
@@ -56,6 +56,9 @@ func (f *funcMap) Map() template.FuncMap {
"safeCSS": func(str string) template.CSS {
return template.CSS(str)
},
"safeJS": func(str string) template.JS {
return template.JS(str)
},
"noescape": func(str string) template.HTML {
return template.HTML(str)
},
+12 -5
View File
@@ -34,12 +34,19 @@
<link rel="stylesheet" type="text/css" href="{{ route "stylesheet" "name" .theme "checksum" .theme_checksum }}">
{{ if and .user .user.Stylesheet }}
{{ $stylesheetNonce := nonce }}
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; img-src * data:; media-src *; frame-src *; style-src 'self' 'nonce-{{ $stylesheetNonce }}'; require-trusted-types-for 'script'; trusted-types ttpolicy;">
<style nonce="{{ $stylesheetNonce }}">{{ .user.Stylesheet | safeCSS }}</style>
{{ if .user }}
{{ $cspNonce := nonce }}
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; img-src * data:; media-src *; frame-src *; {{ if .user.ExternalFontHosts }}font-src {{ .user.ExternalFontHosts }}; {{ end }}style-src 'self'{{ if .user.Stylesheet }}{{ if .user.ExternalFontHosts }} {{ .user.ExternalFontHosts }}{{ end }} 'nonce-{{ $cspNonce }}'{{ end }}{{ if .user.CustomJS }}; script-src 'self' 'nonce-{{ $cspNonce }}'{{ end }}; require-trusted-types-for 'script'; trusted-types ttpolicy;">
{{ if .user.Stylesheet }}
<style nonce="{{ $cspNonce }}">{{ .user.Stylesheet | safeCSS }}</style>
{{ end }}
{{ if .user.CustomJS }}
<script type="module" nonce="{{ $cspNonce }}">{{ .user.CustomJS | safeJS }}</script>
{{ end }}
{{ else }}
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; img-src * data:; media-src *; frame-src *; require-trusted-types-for 'script'; trusted-types ttpolicy;">
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; img-src * data:; media-src *; frame-src *; require-trusted-types-for 'script'; trusted-types ttpolicy;">
{{ end }}
<script src="{{ route "javascript" "name" "app" "checksum" .app_js_checksum }}" defer></script>
+47 -86
View File
@@ -165,56 +165,55 @@
{{ end }}
{{ end }}
<article class="entry-content gesture-nav-{{ $.user.GestureNav }}" dir="auto">
{{ if (and .entry.Enclosures (not .entry.Feed.NoMediaPlayer)) }}
{{ range .entry.Enclosures }}
{{ if ne .URL "" }}
{{ if hasPrefix .MimeType "audio/" }}
<div class="enclosure-audio" >
<audio controls preload="metadata"
{{ if $.user }}data-last-position="{{ .MediaProgression }}"{{ end }}
{{ if $.user.MediaPlaybackRate }}data-playback-rate="{{ $.user.MediaPlaybackRate }}"{{ end }}
{{ if $.user.MarkReadOnMediaPlayerCompletion }}
data-mark-read-on-completion="0.9"
{{ end }}
{{ if $.user }}data-save-url="{{ route "saveEnclosureProgression" "enclosureID" .ID }}"{{ end }}
data-enclosure-id="{{.ID}}"
>
{{ if (and $.user (mustBeProxyfied "audio")) }}
<source src="{{ proxyURL .URL }}" type="{{ .Html5MimeType }}">
{{ else }}
<source src="{{ .URL | safeURL }}" type="{{ .Html5MimeType }}">
{{ end }}
</audio>
{{ template "enclosure_media_controls" . }}
</div>
{{ else if hasPrefix .MimeType "video/" }}
<div class="enclosure-video">
<video controls preload="metadata"
{{ if $.user }}data-last-position="{{ .MediaProgression }}"{{ end }}
{{ if $.user.MediaPlaybackRate }}data-playback-rate="{{ $.user.MediaPlaybackRate }}"{{ end }}
{{ if $.user.MarkReadOnMediaPlayerCompletion }}
data-mark-read-on-completion="0.9"
{{ if not .entry.Feed.NoMediaPlayer }}
{{ $mediaPlayerEnclosure := .entry.Enclosures.FindMediaPlayerEnclosure }}
{{ if $mediaPlayerEnclosure }}
{{ with $mediaPlayerEnclosure }}
{{ if .IsAudio }}
<div class="enclosure-audio" >
<audio controls preload="metadata"
{{ if $.user }}data-last-position="{{ .MediaProgression }}"{{ end }}
{{ if $.user.MediaPlaybackRate }}data-playback-rate="{{ $.user.MediaPlaybackRate }}"{{ end }}
{{ if $.user.MarkReadOnMediaPlayerCompletion }}data-mark-read-on-completion="0.9"{{ end }}
{{ if $.user }}data-save-url="{{ route "saveEnclosureProgression" "enclosureID" .ID }}"{{ end }}
data-enclosure-id="{{ .ID }}"
>
{{ if (and $.user (mustBeProxyfied "audio")) }}
<source src="{{ proxyURL .URL }}" type="{{ .Html5MimeType }}">
{{ else }}
<source src="{{ .URL | safeURL }}" type="{{ .Html5MimeType }}">
{{ end }}
</audio>
{{ template "enclosure_media_controls" . }}
</div>
{{ else if .IsVideo }}
<div class="enclosure-video">
<video controls preload="metadata"
{{ if $.user }}data-last-position="{{ .MediaProgression }}"{{ end }}
{{ if $.user.MediaPlaybackRate }}data-playback-rate="{{ $.user.MediaPlaybackRate }}"{{ end }}
{{ if $.user.MarkReadOnMediaPlayerCompletion }}data-mark-read-on-completion="0.9"{{ end }}
{{ if $.user }}data-save-url="{{ route "saveEnclosureProgression" "enclosureID" .ID }}"{{ end }}
data-enclosure-id="{{ .ID }}"
>
{{ if (and $.user (mustBeProxyfied "video")) }}
<source src="{{ proxyURL .URL }}" type="{{ .Html5MimeType }}">
{{ else }}
<source src="{{ .URL | safeURL }}" type="{{ .Html5MimeType }}">
{{ end }}
</video>
{{ template "enclosure_media_controls" . }}
</div>
{{ end }}
{{ if $.user }}data-save-url="{{ route "saveEnclosureProgression" "enclosureID" .ID }}"{{ end }}
data-enclosure-id="{{.ID}}"
>
{{ if (and $.user (mustBeProxyfied "video")) }}
<source src="{{ proxyURL .URL }}" type="{{ .Html5MimeType }}">
{{ else }}
<source src="{{ .URL | safeURL }}" type="{{ .Html5MimeType }}">
{{ end }}
</video>
{{ template "enclosure_media_controls" . }}
</div>
{{ end }}
{{ end }}
{{ end }}
{{ end }}
{{end}}
{{ if .user }}
{{ end }}
{{ if .user }}
{{ noescape (proxyFilter .entry.Content) }}
{{ else }}
{{ else }}
{{ noescape .entry.Content }}
{{ end }}
{{ end }}
</article>
{{ if .entry.Enclosures }}
<details class="entry-enclosures">
@@ -222,45 +221,7 @@
{{ range .entry.Enclosures }}
{{ if ne .URL "" }}
<div class="entry-enclosure">
{{ if hasPrefix .MimeType "audio/" }}
<div class="enclosure-audio">
<audio controls preload="metadata"
{{ if $.user }}data-last-position="{{ .MediaProgression }}"{{ end }}
{{ if $.user.MediaPlaybackRate }}data-playback-rate="{{ $.user.MediaPlaybackRate }}"{{ end }}
{{ if $.user.MarkReadOnMediaPlayerCompletion }}
data-mark-read-on-completion="0.9"
{{ end }}
{{ if $.user }}data-save-url="{{ route "saveEnclosureProgression" "enclosureID" .ID }}"{{ end }}
data-enclosure-id="{{.ID}}"
>
{{ if (and $.user (mustBeProxyfied "audio")) }}
<source src="{{ proxyURL .URL }}" type="{{ .Html5MimeType }}">
{{ else }}
<source src="{{ .URL | safeURL }}" type="{{ .Html5MimeType }}">
{{ end }}
</audio>
{{ template "enclosure_media_controls" . }}
</div>
{{ else if hasPrefix .MimeType "video/" }}
<div class="enclosure-video">
<video controls preload="metadata"
{{ if $.user }}data-last-position="{{ .MediaProgression }}"{{ end }}
{{ if $.user.MediaPlaybackRate }}data-playback-rate="{{ $.user.MediaPlaybackRate }}"{{ end }}
{{ if $.user.MarkReadOnMediaPlayerCompletion }}
data-mark-read-on-completion="0.9"
{{ end }}
{{ if $.user }}data-save-url="{{ route "saveEnclosureProgression" "enclosureID" .ID }}"{{ end }}
data-enclosure-id="{{.ID}}"
>
{{ if (and $.user (mustBeProxyfied "video")) }}
<source src="{{ proxyURL .URL }}" type="{{ .Html5MimeType }}">
{{ else }}
<source src="{{ .URL | safeURL }}" type="{{ .Html5MimeType }}">
{{ end }}
</video>
{{ template "enclosure_media_controls" . }}
</div>
{{ else if hasPrefix .MimeType "image/" }}
{{ if .IsImage }}
<div class="enclosure-image">
{{ if (and $.user (mustBeProxyfied "image")) }}
<img src="{{ proxyURL .URL }}" title="{{ .URL }} ({{ .MimeType }})" loading="lazy" alt="{{ .URL }} ({{ .MimeType }})">
@@ -271,7 +232,7 @@
{{ end }}
<div class="entry-enclosure-download">
<a href="{{ .URL | safeURL }}" title="{{ t "action.download" }}{{ if gt .Size 0 }} - {{ formatFileSize .Size }}{{ end }} ({{ .MimeType }})" target="_blank" rel="noopener noreferrer" referrerpolicy="no-referrer">{{ .URL | safeURL }}</a>
<a href="{{ .URL | safeURL }}" title="{{ t "action.download" }}{{ if gt .Size 0 }} - {{ formatFileSize .Size }}{{ end }}" target="_blank" rel="noopener noreferrer" referrerpolicy="no-referrer">{{ .URL | safeURL }}</a>
<small>{{ if gt .Size 0 }} - <strong>{{ formatFileSize .Size }}</strong>{{ end }}</small>
</div>
</div>
@@ -57,6 +57,22 @@
</div>
</details>
<details {{ if .form.CuboxEnabled }}open{{ end }}>
<summary>Cubox</summary>
<div class="form-section">
<label>
<input type="checkbox" name="cubox_enabled" value="1" {{ if .form.CuboxEnabled }}checked{{ end }}> {{ t "form.integration.cubox_activate" }}
</label>
<label for="form-cubox-api-link">{{ t "form.integration.cubox_api_link" }}</label>
<input type="url" name="cubox_api_link" id="form-cubox-api-link" value="{{ .form.CuboxAPILink }}" placeholder="https://cubox.pro/c/api/save/xxx" spellcheck="false">
<div class="buttons">
<button type="submit" class="button button-primary" data-label-loading="{{ t "form.submit.saving" }}">{{ t "action.update" }}</button>
</div>
</div>
</details>
<details {{ if .form.EspialEnabled }}open{{ end }}>
<summary>Espial</summary>
<div class="form-section">
+15 -3
View File
@@ -24,16 +24,28 @@
</div>
</form>
{{ end }}
{{ if and (not disableLocalAuth) (.webAuthnEnabled) }}
<hr>
{{ end }}
{{ if .webAuthnEnabled }}
<div class="webauthn">
<div role="alert" class="alert alert-error hidden" id="webauthn-error">
{{ t "page.login.webauthn_login.error" }}
</div>
<template id="webauthn-error">
<div role="alert" class="alert alert-error" id="webauthn-error-alert">
<h4>{{ t "page.login.webauthn_login.error" }}</h4>
<p id="webauthn-error-message"></p>
</div>
</template>
<div class="buttons">
<button class="button button-primary" id="webauthn-login" disabled>{{ t "page.login.webauthn_login" }}</button>
</div>
<div class="form-help">
<p>{{ t "page.login.webauthn_login.help" }}</p>
</div>
</div>
{{ end }}
{{ if and (.webAuthnEnabled) (or (hasOAuth2Provider "google") (hasOAuth2Provider "oidc")) }}
<hr>
{{ end }}
{{ if hasOAuth2Provider "google" }}
<div class="oauth2">
<a href="{{ route "oauth2Redirect" "provider" "google" }}">{{ t "page.login.google_signin" }}</a>
@@ -210,6 +210,13 @@
<label for="form-custom-css">{{t "form.prefs.label.custom_css" }}</label>
<textarea id="form-custom-css" name="custom_css" cols="40" rows="10" spellcheck="false">{{ .form.CustomCSS }}</textarea>
<label for="form-external-font-hosts">{{t "form.prefs.label.external_font_hosts" }}</label>
<input type="text" id="form-external-font-hosts" name="external_font_hosts" spellcheck="false" value="{{ .form.ExternalFontHosts }}">
<div class="form-help">{{t "form.prefs.help.external_font_hosts" }}</div>
<label for="form-custom-js">{{t "form.prefs.label.custom_js" }}</label>
<textarea id="form-custom-js" name="custom_js" cols="40" rows="10" spellcheck="false">{{ .form.CustomJS }}</textarea>
<div class="buttons">
<button type="submit" class="button button-primary" data-label-loading="{{ t "form.submit.saving" }}">{{ t "action.update" }}</button>
</div>
+6
View File
@@ -110,6 +110,8 @@ type IntegrationForm struct {
NtfyUsername string
NtfyPassword string
NtfyIconURL string
CuboxEnabled bool
CuboxAPILink string
}
// Merge copy form values to the model.
@@ -209,6 +211,8 @@ func (i IntegrationForm) Merge(integration *model.Integration) {
integration.NtfyUsername = i.NtfyUsername
integration.NtfyPassword = i.NtfyPassword
integration.NtfyIconURL = i.NtfyIconURL
integration.CuboxEnabled = i.CuboxEnabled
integration.CuboxAPILink = i.CuboxAPILink
}
// NewIntegrationForm returns a new IntegrationForm.
@@ -311,6 +315,8 @@ func NewIntegrationForm(r *http.Request) *IntegrationForm {
NtfyUsername: r.FormValue("ntfy_username"),
NtfyPassword: r.FormValue("ntfy_password"),
NtfyIconURL: r.FormValue("ntfy_icon_url"),
CuboxEnabled: r.FormValue("cubox_enabled") == "1",
CuboxAPILink: r.FormValue("cubox_api_link"),
}
}
+13
View File
@@ -10,6 +10,7 @@ import (
"miniflux.app/v2/internal/config"
"miniflux.app/v2/internal/locale"
"miniflux.app/v2/internal/model"
"miniflux.app/v2/internal/validator"
)
// MarkReadBehavior list all possible behaviors for automatically marking an entry as read
@@ -36,6 +37,8 @@ type SettingsForm struct {
KeyboardShortcuts bool
ShowReadingTime bool
CustomCSS string
CustomJS string
ExternalFontHosts string
EntrySwipe bool
GestureNav string
DisplayMode string
@@ -99,6 +102,8 @@ func (s *SettingsForm) Merge(user *model.User) *model.User {
user.KeyboardShortcuts = s.KeyboardShortcuts
user.ShowReadingTime = s.ShowReadingTime
user.Stylesheet = s.CustomCSS
user.CustomJS = s.CustomJS
user.ExternalFontHosts = s.ExternalFontHosts
user.EntrySwipe = s.EntrySwipe
user.GestureNav = s.GestureNav
user.DisplayMode = s.DisplayMode
@@ -146,6 +151,12 @@ func (s *SettingsForm) Validate() *locale.LocalizedError {
return locale.NewLocalizedError("error.settings_media_playback_rate_range")
}
if s.ExternalFontHosts != "" {
if !validator.IsValidDomainList(s.ExternalFontHosts) {
return locale.NewLocalizedError("error.settings_invalid_domain_list")
}
}
return nil
}
@@ -180,6 +191,8 @@ func NewSettingsForm(r *http.Request) *SettingsForm {
KeyboardShortcuts: r.FormValue("keyboard_shortcuts") == "1",
ShowReadingTime: r.FormValue("show_reading_time") == "1",
CustomCSS: r.FormValue("custom_css"),
CustomJS: r.FormValue("custom_js"),
ExternalFontHosts: r.FormValue("external_font_hosts"),
EntrySwipe: r.FormValue("entry_swipe") == "1",
GestureNav: r.FormValue("gesture_nav"),
DisplayMode: r.FormValue("display_mode"),
+2
View File
@@ -124,6 +124,8 @@ func (h *handler) showIntegrationPage(w http.ResponseWriter, r *http.Request) {
NtfyUsername: integration.NtfyUsername,
NtfyPassword: integration.NtfyPassword,
NtfyIconURL: integration.NtfyIconURL,
CuboxEnabled: integration.CuboxEnabled,
CuboxAPILink: integration.CuboxAPILink,
}
sess := session.New(h.store, request.SessionID(r))
+15 -4
View File
@@ -12,6 +12,7 @@ import (
"log/slog"
"net/http"
"net/url"
"path"
"time"
"miniflux.app/v2/internal/config"
@@ -19,6 +20,7 @@ import (
"miniflux.app/v2/internal/http/request"
"miniflux.app/v2/internal/http/response"
"miniflux.app/v2/internal/http/response/html"
"miniflux.app/v2/internal/reader/rewrite"
)
func (h *handler) mediaProxy(w http.ResponseWriter, r *http.Request) {
@@ -56,23 +58,23 @@ func (h *handler) mediaProxy(w http.ResponseWriter, r *http.Request) {
return
}
u, err := url.Parse(string(decodedURL))
parsedMediaURL, err := url.Parse(string(decodedURL))
if err != nil {
html.BadRequest(w, r, errors.New("invalid URL provided"))
return
}
if u.Scheme != "http" && u.Scheme != "https" {
if parsedMediaURL.Scheme != "http" && parsedMediaURL.Scheme != "https" {
html.BadRequest(w, r, errors.New("invalid URL provided"))
return
}
if u.Host == "" {
if parsedMediaURL.Host == "" {
html.BadRequest(w, r, errors.New("invalid URL provided"))
return
}
if !u.IsAbs() {
if !parsedMediaURL.IsAbs() {
html.BadRequest(w, r, errors.New("invalid URL provided"))
return
}
@@ -90,6 +92,10 @@ func (h *handler) mediaProxy(w http.ResponseWriter, r *http.Request) {
req.Header.Set("Connection", "close")
if referer := rewrite.GetRefererForURL(mediaURL); referer != "" {
req.Header.Set("Referer", referer)
}
forwardedRequestHeader := []string{"Range", "Accept", "Accept-Encoding", "User-Agent"}
for _, requestHeaderName := range forwardedRequestHeader {
if r.Header.Get(requestHeaderName) != "" {
@@ -140,6 +146,11 @@ func (h *handler) mediaProxy(w http.ResponseWriter, r *http.Request) {
b.WithStatus(resp.StatusCode)
b.WithHeader("Content-Security-Policy", `default-src 'self'`)
b.WithHeader("Content-Type", resp.Header.Get("Content-Type"))
if filename := path.Base(parsedMediaURL.Path); filename != "" {
b.WithHeader("Content-Disposition", fmt.Sprintf(`inline; filename="%s"`, filename))
}
forwardedResponseHeader := []string{"Content-Encoding", "Content-Type", "Content-Length", "Accept-Ranges", "Content-Range"}
for _, responseHeaderName := range forwardedResponseHeader {
if resp.Header.Get(responseHeaderName) != "" {
+2
View File
@@ -33,6 +33,8 @@ func (h *handler) showSettingsPage(w http.ResponseWriter, r *http.Request) {
KeyboardShortcuts: user.KeyboardShortcuts,
ShowReadingTime: user.ShowReadingTime,
CustomCSS: user.Stylesheet,
CustomJS: user.CustomJS,
ExternalFontHosts: user.ExternalFontHosts,
EntrySwipe: user.EntrySwipe,
GestureNav: user.GestureNav,
DisplayMode: user.DisplayMode,
+1
View File
@@ -85,6 +85,7 @@ func (h *handler) updateSettings(w http.ResponseWriter, r *http.Request) {
MediaPlaybackRate: model.OptionalNumber(settingsForm.MediaPlaybackRate),
BlockFilterEntryRules: model.OptionalString(settingsForm.BlockFilterEntryRules),
KeepFilterEntryRules: model.OptionalString(settingsForm.KeepFilterEntryRules),
ExternalFontHosts: model.OptionalString(settingsForm.ExternalFontHosts),
}
if validationErr := validator.ValidateUserModification(h.store, loggedUser.ID, userModificationRequest); validationErr != nil {
+6 -2
View File
@@ -427,7 +427,6 @@ input[type="number"] {
line-height: 20px;
width: 250px;
font-size: 99%;
margin-bottom: 10px;
margin-top: 5px;
appearance: none;
}
@@ -448,7 +447,8 @@ input[type="number"]:focus {
}
input[type="checkbox"] {
margin-bottom: 15px;
margin-top: 10px;
margin-bottom: 10px;
}
textarea {
@@ -675,6 +675,10 @@ template {
max-width: 300px;
}
.webauthn {
margin-bottom: 20px;
}
/* Counters */
.unread-counter-wrapper,
.error-feeds-counter-wrapper {
+13 -3
View File
@@ -5,10 +5,20 @@ class WebAuthnHandler {
static showErrorMessage(errorMessage) {
console.log("webauthn error: " + errorMessage);
const alertElement = document.getElementById("webauthn-error");
const alertElement = document.getElementById("webauthn-error-alert");
if (alertElement) {
alertElement.textContent += " (" + errorMessage + ")";
alertElement.classList.remove("hidden");
alertElement.remove();
}
const alertTemplateElement = document.getElementById("webauthn-error");
if (alertTemplateElement) {
const clonedElement = alertTemplateElement.content.cloneNode(true);
const errorMessageElement = clonedElement.getElementById("webauthn-error-message");
if (errorMessageElement) {
errorMessageElement.textContent = errorMessage;
}
alertTemplateElement.parentNode.insertBefore(clonedElement, alertTemplateElement);
}
}
+5 -3
View File
@@ -5,6 +5,7 @@ package ui // import "miniflux.app/v2/internal/ui"
import (
"net/http"
"regexp"
"miniflux.app/v2/internal/config"
"miniflux.app/v2/internal/http/request"
@@ -12,10 +13,11 @@ import (
"miniflux.app/v2/internal/ui/form"
"miniflux.app/v2/internal/ui/session"
"miniflux.app/v2/internal/ui/view"
"mvdan.cc/xurls/v2"
)
// Best effort url extraction regexp
var urlRe = regexp.MustCompile(`(?i)(?:https?://)?[0-9a-z.]+[.][a-z]+(?::[0-9]+)?(?:/[^ ]+|/)?`)
func (h *handler) bookmarklet(w http.ResponseWriter, r *http.Request) {
user, err := h.store.UserByID(request.UserID(r))
if err != nil {
@@ -39,7 +41,7 @@ func (h *handler) bookmarklet(w http.ResponseWriter, r *http.Request) {
// See https://bugs.chromium.org/p/chromium/issues/detail?id=789379.
text := request.QueryStringParam(r, "text", "")
if text != "" && bookmarkletURL == "" {
bookmarkletURL = xurls.Relaxed().FindString(text)
bookmarkletURL = urlRe.FindString(text)
}
sess := session.New(h.store, request.SessionID(r))
+46 -10
View File
@@ -206,6 +206,15 @@ func (h *handler) finishLogin(w http.ResponseWriter, r *http.Request) {
json.ServerError(w, r, err)
return
}
slog.Debug("WebAuthn: parsed response flags",
slog.Bool("user_present", parsedResponse.Response.AuthenticatorData.Flags.HasUserPresent()),
slog.Bool("user_verified", parsedResponse.Response.AuthenticatorData.Flags.HasUserPresent()),
slog.Bool("has_attested_credential_data", parsedResponse.Response.AuthenticatorData.Flags.HasAttestedCredentialData()),
slog.Bool("has_backup_eligible", parsedResponse.Response.AuthenticatorData.Flags.HasBackupEligible()),
slog.Bool("has_backup_state", parsedResponse.Response.AuthenticatorData.Flags.HasBackupState()),
)
sessionData := request.WebAuthnSessionData(r)
var user *model.User
@@ -218,34 +227,54 @@ func (h *handler) finishLogin(w http.ResponseWriter, r *http.Request) {
}
}
var cred *model.WebAuthnCredential
var matchingCredential *model.WebAuthnCredential
if user != nil {
creds, err := h.store.WebAuthnCredentialsByUserID(user.ID)
storedCredentials, err := h.store.WebAuthnCredentialsByUserID(user.ID)
if err != nil {
json.ServerError(w, r, err)
return
}
sessionData.SessionData.UserID = parsedResponse.Response.UserHandle
credCredential, err := web.ValidateLogin(WebAuthnUser{user, parsedResponse.Response.UserHandle, creds}, *sessionData.SessionData, parsedResponse)
webAuthUser := WebAuthnUser{user, parsedResponse.Response.UserHandle, storedCredentials}
// Since go-webauthn v0.11.0, the backup eligibility flag is strictly validated, but Miniflux does not store this flag.
// This workaround set the flag based on the parsed response, and avoid "BackupEligible flag inconsistency detected during login validation" error.
// See https://github.com/go-webauthn/webauthn/pull/240
for index := range webAuthUser.Credentials {
webAuthUser.Credentials[index].Credential.Flags.BackupEligible = parsedResponse.Response.AuthenticatorData.Flags.HasBackupEligible()
}
for _, webAuthCredential := range webAuthUser.WebAuthnCredentials() {
slog.Debug("WebAuthn: stored credential flags",
slog.Bool("user_present", webAuthCredential.Flags.UserPresent),
slog.Bool("user_verified", webAuthCredential.Flags.UserVerified),
slog.Bool("backup_eligible", webAuthCredential.Flags.BackupEligible),
slog.Bool("backup_state", webAuthCredential.Flags.BackupState),
)
}
credCredential, err := web.ValidateLogin(webAuthUser, *sessionData.SessionData, parsedResponse)
if err != nil {
slog.Warn("WebAuthn: ValidateLogin failed", slog.Any("error", err))
json.Unauthorized(w, r)
return
}
for _, credTest := range creds {
if bytes.Equal(credCredential.ID, credTest.Credential.ID) {
cred = &credTest
for _, storedCredential := range storedCredentials {
if bytes.Equal(credCredential.ID, storedCredential.Credential.ID) {
matchingCredential = &storedCredential
}
}
if cred == nil {
if matchingCredential == nil {
json.ServerError(w, r, fmt.Errorf("no matching credential for %v", credCredential))
return
}
} else {
userByHandle := func(rawID, userHandle []byte) (webauthn.User, error) {
var uid int64
uid, cred, err = h.store.WebAuthnCredentialByHandle(userHandle)
uid, matchingCredential, err = h.store.WebAuthnCredentialByHandle(userHandle)
if err != nil {
return nil, err
}
@@ -259,11 +288,18 @@ func (h *handler) finishLogin(w http.ResponseWriter, r *http.Request) {
if user == nil {
return nil, fmt.Errorf("no user found for handle %x", userHandle)
}
return WebAuthnUser{user, userHandle, []model.WebAuthnCredential{*cred}}, nil
// Since go-webauthn v0.11.0, the backup eligibility flag is strictly validated, but Miniflux does not store this flag.
// This workaround set the flag based on the parsed response, and avoid "BackupEligible flag inconsistency detected during login validation" error.
// See https://github.com/go-webauthn/webauthn/pull/240
matchingCredential.Credential.Flags.BackupEligible = parsedResponse.Response.AuthenticatorData.Flags.HasBackupEligible()
return WebAuthnUser{user, userHandle, []model.WebAuthnCredential{*matchingCredential}}, nil
}
_, err = web.ValidateDiscoverableLogin(userByHandle, *sessionData.SessionData, parsedResponse)
if err != nil {
slog.Warn("WebAuthn: ValidateDiscoverableLogin failed", slog.Any("error", err))
json.Unauthorized(w, r)
return
}
@@ -275,7 +311,7 @@ func (h *handler) finishLogin(w http.ResponseWriter, r *http.Request) {
return
}
h.store.WebAuthnSaveLogin(cred.Handle)
h.store.WebAuthnSaveLogin(matchingCredential.Handle)
slog.Info("User authenticated successfully with webauthn",
slog.Bool("authentication_successful", true),
+6
View File
@@ -123,6 +123,12 @@ func ValidateUserModification(store *storage.Storage, userID int64, changes *mod
}
}
if changes.ExternalFontHosts != nil {
if !IsValidDomainList(*changes.ExternalFontHosts) {
return locale.NewLocalizedError("error.settings_invalid_domain_list")
}
}
return nil
}
+24
View File
@@ -7,8 +7,11 @@ import (
"fmt"
"net/url"
"regexp"
"strings"
)
var domainRegex = regexp.MustCompile(`^([a-zA-Z0-9]([a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]{2,}$`)
// ValidateRange makes sure the offset/limit values are valid.
func ValidateRange(offset, limit int) error {
if offset < 0 {
@@ -43,3 +46,24 @@ func IsValidURL(absoluteURL string) bool {
_, err := url.ParseRequestURI(absoluteURL)
return err == nil
}
func IsValidDomain(domain string) bool {
domain = strings.ToLower(domain)
if len(domain) < 1 || len(domain) > 253 {
return false
}
return domainRegex.MatchString(domain)
}
func IsValidDomainList(value string) bool {
domains := strings.Split(strings.TrimSpace(value), " ")
for _, domain := range domains {
if !IsValidDomain(domain) {
return false
}
}
return true
}
+18
View File
@@ -59,3 +59,21 @@ func TestIsValidRegex(t *testing.T) {
}
}
}
func TestIsValidDomain(t *testing.T) {
scenarios := map[string]bool{
"example.org": true,
"example": false,
"example.": false,
"example..": false,
"mail.example.com:443": false,
"*.example.com": false,
}
for domain, expected := range scenarios {
result := IsValidDomain(domain)
if result != expected {
t.Errorf(`Unexpected result, got %v instead of %v`, result, expected)
}
}
}
+2 -2
View File
@@ -1,5 +1,5 @@
.\" Manpage for miniflux.
.TH "MINIFLUX" "1" "August 18, 2024" "\ \&" "\ \&"
.TH "MINIFLUX" "1" "October 26, 2024" "\ \&" "\ \&"
.SH NAME
miniflux \- Minimalist and opinionated feed reader
@@ -546,7 +546,7 @@ Enabled by default\&.
.B WEBAUTHN
Enable or disable WebAuthn/Passkey authentication\&.
.br
Note: After activating and setting up your Passkey, just enter your username and click the Passkey login button\&.
You must provide a username on the login page if your are using non-residential keys. However, this is not required for discoverable credentials\&.
.br
Default is disabled\&.
.TP